mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
517 lines
15 KiB
JavaScript
517 lines
15 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { spawnSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
runLocalSecretCommandFile,
|
|
} = require('@qinglong/local-owner-cli/secret-command');
|
|
const {
|
|
provisionLocalOwnerPepperKey,
|
|
} = require('@qinglong/local-owner-console');
|
|
const {
|
|
LocalSecretKeyringFileProvider,
|
|
provisionLocalSecretKeyring,
|
|
} = require('@qinglong/local-secret');
|
|
const {
|
|
createLocalSecretAdministrationService,
|
|
} = require('@qinglong/local-admin/secret-administration');
|
|
const {
|
|
openLocalSqliteSecretAdministrationDatabase,
|
|
} = require('@qinglong/local-sqlite/secret-administration');
|
|
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
|
const {
|
|
LocalSecretAuthorizationFenceConflictError,
|
|
} = require('@qinglong/runtime-core/local-secret-administration');
|
|
const {
|
|
apiCredentialSecretDigest,
|
|
formatApiCredentialToken,
|
|
} = require('@qinglong/runtime-core/api-credential-token');
|
|
const { parseSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
|
|
|
const CREDENTIAL_ID = 'secret-owner';
|
|
const PEPPER_KEY_ID = 'secret-owner-v1';
|
|
const PEPPER = Buffer.alloc(32, 101).toString('base64url');
|
|
const CREDENTIAL_SECRET = Buffer.alloc(32, 102).toString('base64url');
|
|
const TOKEN = formatApiCredentialToken(CREDENTIAL_ID, CREDENTIAL_SECRET);
|
|
|
|
async function fixture(t, { role = 'owner' } = {}) {
|
|
const deploymentRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ql3-secret-command-'),
|
|
);
|
|
fs.chmodSync(deploymentRoot, 0o700);
|
|
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
|
const commandsDirectory = path.join(deploymentRoot, 'commands');
|
|
const ownerPepperKeyringDirectory = path.join(deploymentRoot, 'owner-keys');
|
|
fs.mkdirSync(commandsDirectory, { mode: 0o700 });
|
|
fs.mkdirSync(ownerPepperKeyringDirectory, { mode: 0o700 });
|
|
const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite');
|
|
const credentialFilePath = path.join(deploymentRoot, 'credential.json');
|
|
const secretKeyringPath = path.join(
|
|
deploymentRoot,
|
|
'local-secret-keyring.json',
|
|
);
|
|
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
|
await provisionLocalSecretKeyring(secretKeyringPath);
|
|
const pepperSummary = provisionLocalOwnerPepperKey({
|
|
keyringDirectory: ownerPepperKeyringDirectory,
|
|
pepperKeyId: PEPPER_KEY_ID,
|
|
randomBytes: () => Buffer.alloc(32, 101),
|
|
});
|
|
const now = Date.now();
|
|
const secretDigest = apiCredentialSecretDigest(
|
|
PEPPER,
|
|
CREDENTIAL_ID,
|
|
CREDENTIAL_SECRET,
|
|
);
|
|
const notBeforeAtMs = now - 1_000;
|
|
const expiresAtMs = now + 10 * 60 * 1_000;
|
|
const database = new DatabaseSync(databasePath);
|
|
try {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
|
"pepper_key_id", "material_digest", "backup_digest", "state",
|
|
"version", "register_mutation_id", "activate_mutation_id",
|
|
"registered_at_ms", "activated_at_ms"
|
|
) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
PEPPER_KEY_ID,
|
|
pepperSummary.digest,
|
|
'd'.repeat(64),
|
|
'61000000-0000-4000-8000-000000000001',
|
|
'61000000-0000-4000-8000-000000000002',
|
|
now - 2_000,
|
|
now - 1_500,
|
|
);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
|
"generation", "mutation_id", "expected_generation",
|
|
"previous_pepper_key_id", "active_pepper_key_id",
|
|
"material_digest", "backup_digest", "activated_at_ms"
|
|
) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
'61000000-0000-4000-8000-000000000002',
|
|
PEPPER_KEY_ID,
|
|
pepperSummary.digest,
|
|
'd'.repeat(64),
|
|
now - 1_500,
|
|
);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO "QingLong3IdentitySubjects" (
|
|
"subject_type", "subject_id", "status", "version",
|
|
"created_at_ms", "updated_at_ms"
|
|
) VALUES ('user', 'owner-user', 'active', 1, ?, ?)`,
|
|
)
|
|
.run(now - 1_000, now - 1_000);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ApiCredentials" (
|
|
"credential_id", "version", "state", "subject_type",
|
|
"subject_id", "secret_digest", "created_at_ms",
|
|
"not_before_at_ms", "expires_at_ms"
|
|
) VALUES (?, 1, 'active', 'user', 'owner-user', ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
CREDENTIAL_ID,
|
|
secretDigest,
|
|
now - 1_000,
|
|
notBeforeAtMs,
|
|
expiresAtMs,
|
|
);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
|
"credential_id", "credential_version", "pepper_key_id"
|
|
) VALUES (?, 1, ?)`,
|
|
)
|
|
.run(CREDENTIAL_ID, PEPPER_KEY_ID);
|
|
database
|
|
.prepare(
|
|
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
|
"project_id", "subject_type", "subject_id", "version", "state",
|
|
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
|
"created_at_ms"
|
|
) VALUES (
|
|
'default', 'user', 'owner-user', 1, 'active', ?,
|
|
'secret-owner-binding', 'user', 'owner-user', ?
|
|
)`,
|
|
)
|
|
.run(role, now - 500);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
fs.chmodSync(databasePath, 0o600);
|
|
fs.writeFileSync(
|
|
credentialFilePath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
kind: 'qinglong3-local-identity-credential-presentation',
|
|
token: TOKEN,
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return {
|
|
deploymentRoot,
|
|
commandsDirectory,
|
|
databasePath,
|
|
credentialFilePath,
|
|
ownerPepperKeyringDirectory,
|
|
secretKeyringPath,
|
|
pepperSummary,
|
|
now,
|
|
fence: {
|
|
credentialId: CREDENTIAL_ID,
|
|
credentialVersion: 1,
|
|
pepperKeyId: PEPPER_KEY_ID,
|
|
materialDigest: pepperSummary.digest,
|
|
subjectType: 'user',
|
|
subjectId: 'owner-user',
|
|
secretDigest,
|
|
notBeforeAtMs,
|
|
expiresAtMs,
|
|
},
|
|
options: {
|
|
deploymentRoot,
|
|
databasePath,
|
|
profile: 'edge',
|
|
ownerPepperKeyringDirectory,
|
|
credentialFilePath,
|
|
secretKeyringPath,
|
|
},
|
|
};
|
|
}
|
|
|
|
function secretValueFile(value, plaintext, name) {
|
|
const filePath = path.join(value.commandsDirectory, `${name}.value.json`);
|
|
fs.writeFileSync(
|
|
filePath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
kind: 'qinglong3-local-secret-value',
|
|
value: plaintext,
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return filePath;
|
|
}
|
|
|
|
function commandFile(value, request, name) {
|
|
const commandPath = path.join(value.commandsDirectory, `${name}.json`);
|
|
fs.writeFileSync(
|
|
commandPath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
operation: 'secret.put',
|
|
options: value.options,
|
|
request,
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return commandPath;
|
|
}
|
|
|
|
function request(value, suffix, expectedCurrentVersion, secretValueFilePath) {
|
|
return {
|
|
projectId: 'default',
|
|
name: 'API_TOKEN',
|
|
secretValueFilePath,
|
|
mutationId: `62000000-0000-4000-8000-00000000000${suffix}`,
|
|
requestId: `secret-command-${suffix}`,
|
|
failureAuditEventId: `63000000-0000-4000-8000-00000000000${suffix}`,
|
|
expectedCurrentVersion,
|
|
};
|
|
}
|
|
|
|
test('creates, replays and rotates an encrypted Secret without echoing material', async (t) => {
|
|
const value = await fixture(t);
|
|
const firstPlaintext = 'first-secret-never-echo';
|
|
const secondPlaintext = 'rotated-secret-never-echo';
|
|
const firstFile = commandFile(
|
|
value,
|
|
request(value, '1', 0, secretValueFile(value, firstPlaintext, 'first')),
|
|
'create',
|
|
);
|
|
const created = await runLocalSecretCommandFile(firstFile);
|
|
assert.deepEqual(created, {
|
|
schemaVersion: 1,
|
|
operation: 'secret.put',
|
|
status: 'inserted',
|
|
version: 1,
|
|
secretRef: created.secretRef,
|
|
});
|
|
assert.deepEqual(parseSecretRef(created.secretRef), {
|
|
projectId: 'default',
|
|
name: 'API_TOKEN',
|
|
version: 1,
|
|
});
|
|
assert.equal((await runLocalSecretCommandFile(firstFile)).status, 'existing');
|
|
|
|
const secondFile = commandFile(
|
|
value,
|
|
request(value, '2', 1, secretValueFile(value, secondPlaintext, 'second')),
|
|
'rotate',
|
|
);
|
|
const rotated = await runLocalSecretCommandFile(secondFile);
|
|
assert.equal(rotated.status, 'inserted');
|
|
assert.equal(rotated.version, 2);
|
|
assert.deepEqual(parseSecretRef(rotated.secretRef), {
|
|
projectId: 'default',
|
|
name: 'API_TOKEN',
|
|
version: 2,
|
|
});
|
|
|
|
const child = spawnSync(
|
|
process.execPath,
|
|
[
|
|
path.join(__dirname, '../dist/security-management/secretCli.js'),
|
|
'run',
|
|
'--command-file',
|
|
secondFile,
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.equal(child.status, 0, child.stderr);
|
|
assert.equal(JSON.parse(child.stdout).status, 'existing');
|
|
for (const sensitive of [TOKEN, firstPlaintext, secondPlaintext]) {
|
|
assert.equal(JSON.stringify(created).includes(sensitive), false);
|
|
assert.equal(child.stdout.includes(sensitive), false);
|
|
assert.equal(child.stderr.includes(sensitive), false);
|
|
}
|
|
|
|
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
const secrets = database
|
|
.prepare(
|
|
`SELECT version, ciphertext
|
|
FROM "QingLong3LocalSecretEnvelopes"
|
|
WHERE project_id = 'default' AND secret_name = 'API_TOKEN'
|
|
ORDER BY version`,
|
|
)
|
|
.all();
|
|
assert.deepEqual(
|
|
secrets.map((entry) => entry.version),
|
|
[1, 2],
|
|
);
|
|
const ciphertext = secrets
|
|
.map((entry) => Buffer.from(entry.ciphertext).toString('utf8'))
|
|
.join('');
|
|
assert.equal(ciphertext.includes(firstPlaintext), false);
|
|
assert.equal(ciphertext.includes(secondPlaintext), false);
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM "QingLong3SecurityAuditEvents"
|
|
WHERE operation_id IN ('secret.create', 'secret.rotate')
|
|
AND outcome = 'allowed'`,
|
|
)
|
|
.get().count,
|
|
2,
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
});
|
|
|
|
test('rejects a non-private value file and records only a low-sensitive failure', async (t) => {
|
|
const value = await fixture(t);
|
|
const plaintext = 'must-not-appear-in-audit';
|
|
const secretFile = secretValueFile(value, plaintext, 'public');
|
|
fs.chmodSync(secretFile, 0o644);
|
|
const command = commandFile(
|
|
value,
|
|
request(value, '3', 0, secretFile),
|
|
'public-value',
|
|
);
|
|
await assert.rejects(runLocalSecretCommandFile(command), {
|
|
code: 'LOCAL_SECRET_COMMAND_CONFIGURATION_INVALID',
|
|
});
|
|
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT outcome, reasons_json
|
|
FROM "QingLong3SecurityAuditEvents"
|
|
WHERE event_id = ?`,
|
|
)
|
|
.get('63000000-0000-4000-8000-000000000003');
|
|
assert.deepEqual(
|
|
{ ...row },
|
|
{
|
|
outcome: 'denied',
|
|
reasons_json: '["secret_value_rejected"]',
|
|
},
|
|
);
|
|
assert.equal(JSON.stringify(row).includes(plaintext), false);
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
});
|
|
|
|
test('keeps viewer policy denied and never touches the Secret key', async (t) => {
|
|
const value = await fixture(t, { role: 'viewer' });
|
|
const command = commandFile(
|
|
value,
|
|
request(
|
|
value,
|
|
'4',
|
|
0,
|
|
secretValueFile(value, 'viewer-cannot-write', 'viewer'),
|
|
),
|
|
'viewer',
|
|
);
|
|
await assert.rejects(runLocalSecretCommandFile(command), {
|
|
code: 'LOCAL_SECRET_ADMINISTRATION_FORBIDDEN',
|
|
});
|
|
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
|
WHERE event_id = ?`,
|
|
)
|
|
.get('62000000-0000-4000-8000-000000000004').outcome,
|
|
'denied',
|
|
);
|
|
assert.equal(
|
|
database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
});
|
|
|
|
test('rejects a revoked credential before Secret materialization', async (t) => {
|
|
const value = await fixture(t);
|
|
const database = new DatabaseSync(value.databasePath);
|
|
try {
|
|
database
|
|
.prepare(
|
|
`UPDATE "QingLong3ApiCredentials"
|
|
SET state = 'revoked'
|
|
WHERE credential_id = ? AND version = 1`,
|
|
)
|
|
.run(CREDENTIAL_ID);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
const command = commandFile(
|
|
value,
|
|
request(
|
|
value,
|
|
'5',
|
|
0,
|
|
secretValueFile(value, 'revoked-cannot-write', 'revoked'),
|
|
),
|
|
'revoked',
|
|
);
|
|
await assert.rejects(runLocalSecretCommandFile(command), {
|
|
code: 'AUTHENTICATED_LOCAL_COMMAND_AUTHENTICATION_FAILED',
|
|
});
|
|
const read = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
assert.equal(
|
|
read
|
|
.prepare(
|
|
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
|
WHERE event_id = ?`,
|
|
)
|
|
.get('63000000-0000-4000-8000-000000000005').outcome,
|
|
'authentication_rejected',
|
|
);
|
|
assert.equal(
|
|
read
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
read.close();
|
|
}
|
|
});
|
|
|
|
test('rechecks the credential fence inside the Secret write transaction', async (t) => {
|
|
const value = await fixture(t);
|
|
const database = await openLocalSqliteSecretAdministrationDatabase({
|
|
databasePath: value.databasePath,
|
|
profile: 'edge',
|
|
});
|
|
t.after(() => database.close());
|
|
database.activateUserCredentialFence(value.fence);
|
|
const mutator = new DatabaseSync(value.databasePath);
|
|
try {
|
|
mutator
|
|
.prepare(
|
|
`UPDATE "QingLong3ApiCredentials"
|
|
SET state = 'revoked'
|
|
WHERE credential_id = ? AND version = 1`,
|
|
)
|
|
.run(CREDENTIAL_ID);
|
|
} finally {
|
|
mutator.close();
|
|
}
|
|
const service = createLocalSecretAdministrationService(
|
|
database.projectPolicy,
|
|
database.localSecretAdministration,
|
|
database.securityAudit,
|
|
new LocalSecretKeyringFileProvider(value.secretKeyringPath),
|
|
);
|
|
await assert.rejects(
|
|
service.put({
|
|
projectId: 'default',
|
|
name: 'ATOMIC_FENCE',
|
|
plaintext: 'must-not-commit',
|
|
mutationId: '62000000-0000-4000-8000-000000000006',
|
|
requestId: 'secret-command-atomic-fence',
|
|
expectedCurrentVersion: 0,
|
|
principal: {
|
|
subject: { type: 'user', id: 'owner-user' },
|
|
authenticationId: 'local_secret:atomic-fence',
|
|
authenticatedAtMs: value.now - 1_000,
|
|
expiresAtMs: value.now + 60_000,
|
|
assurance: 'local_console',
|
|
},
|
|
}),
|
|
LocalSecretAuthorizationFenceConflictError,
|
|
);
|
|
const read = new DatabaseSync(value.databasePath, { readOnly: true });
|
|
try {
|
|
assert.equal(
|
|
read
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM "QingLong3LocalSecretEnvelopes"
|
|
WHERE secret_name = 'ATOMIC_FENCE'`,
|
|
)
|
|
.get().count,
|
|
0,
|
|
);
|
|
} finally {
|
|
read.close();
|
|
}
|
|
});
|