mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
417 lines
13 KiB
JavaScript
417 lines
13 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 { afterEach, test } = require('node:test');
|
|
|
|
const {
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
assertionIdentity,
|
|
parseArguments,
|
|
runWorkerCredentialManagementLiveCeremony,
|
|
validateWorkerCredentialManagementLiveCeremony,
|
|
} = require('../../scripts/ql3-worker-credential-management-live-ceremony.cjs');
|
|
|
|
const NOW_MS = 1_700_000_000_000;
|
|
const temporaryDirectories = [];
|
|
|
|
function encode(value) {
|
|
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
}
|
|
|
|
function assertion(subject, overrides = {}, headerOverrides = {}) {
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
return `${encode({
|
|
alg: 'EdDSA',
|
|
kid: `key-${subject}`,
|
|
typ: 'ql3-worker-credential-management+jwt',
|
|
...headerOverrides,
|
|
})}.${encode({
|
|
acr: 'urn:production:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: 'qinglong3-worker-credential-management',
|
|
auth_time: now - 20,
|
|
exp: now + 120,
|
|
iat: now - 10,
|
|
iss: 'https://identity.production.example.org/',
|
|
jti: `session-${subject}`,
|
|
ql3_purpose: 'worker-credential-management',
|
|
sub: subject,
|
|
...overrides,
|
|
})}.${Buffer.alloc(64, 7).toString('base64url')}`;
|
|
}
|
|
|
|
function ceremony() {
|
|
return {
|
|
schemaVersion: 1,
|
|
planRequest: {
|
|
actionRef: 'worker-credential:worker-a:generation-2',
|
|
authorityProjectId: 'cluster-authority',
|
|
action: 'rotate',
|
|
deliveryId: '123e4567-e89b-42d3-a456-426614174901',
|
|
workerId: 'worker-a',
|
|
credentialId: 'credential-generation-2',
|
|
previousCredentialId: 'credential-generation-1',
|
|
credentialNotBeforeAtMs: NOW_MS + 1_000,
|
|
credentialExpiresAtMs: NOW_MS + 10 * 60_000,
|
|
deploymentTargetDigest: 'd'.repeat(64),
|
|
deploymentGeneration: 'generation-2',
|
|
},
|
|
approvalRequestId: 'approval-worker-a-generation-2',
|
|
approvalAuditEventId: '123e4567-e89b-42d3-a456-426614174902',
|
|
requesterDecisionId: 'requester-self-decision',
|
|
requesterDecisionAuditEventId: '123e4567-e89b-42d3-a456-426614174903',
|
|
reviewerDecisionId: 'reviewer-decision',
|
|
reviewerDecisionAuditEventId: '123e4567-e89b-42d3-a456-426614174904',
|
|
decisionReasonCode: 'reviewed',
|
|
inspectionId: 'inspection-worker-a-generation-2',
|
|
};
|
|
}
|
|
|
|
function writePrivate(directory, name, value) {
|
|
const filePath = path.join(directory, name);
|
|
fs.writeFileSync(
|
|
filePath,
|
|
typeof value === 'string' ? value : `${JSON.stringify(value)}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return filePath;
|
|
}
|
|
|
|
function fixture() {
|
|
const directory = fs.realpathSync(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-worker-ceremony-test-')),
|
|
);
|
|
fs.chmodSync(directory, 0o700);
|
|
temporaryDirectories.push(directory);
|
|
return {
|
|
configFile: writePrivate(directory, 'client.json', {}),
|
|
requesterAssertionFile: writePrivate(
|
|
directory,
|
|
'requester.jwt',
|
|
assertion('operator-a'),
|
|
),
|
|
reviewerAssertionFile: writePrivate(
|
|
directory,
|
|
'reviewer.jwt',
|
|
assertion('reviewer-b'),
|
|
),
|
|
ceremonyFile: writePrivate(directory, 'ceremony.json', ceremony()),
|
|
outputFile: path.join(directory, 'evidence.json'),
|
|
};
|
|
}
|
|
|
|
function approval(state, decidedBy = null) {
|
|
return {
|
|
id: 'approval-worker-a-generation-2',
|
|
version: state === 'pending' ? 1 : 2,
|
|
state,
|
|
requestedBy: { type: 'user', id: 'operator-a' },
|
|
decidedBy,
|
|
dispatchId: null,
|
|
consumedAtMs: null,
|
|
actionDigest: 'a'.repeat(64),
|
|
previewDigest: 'b'.repeat(64),
|
|
};
|
|
}
|
|
|
|
function liveDependencies(options = {}) {
|
|
const calls = [];
|
|
let sequence = 0;
|
|
const plan = {
|
|
actionRef: ceremony().planRequest.actionRef,
|
|
authorityProjectId: ceremony().planRequest.authorityProjectId,
|
|
requestedBy: { type: 'user', id: 'operator-a' },
|
|
planDigest: 'a'.repeat(64),
|
|
previewDigest: 'b'.repeat(64),
|
|
};
|
|
return {
|
|
calls,
|
|
dependencies: {
|
|
now: () => NOW_MS,
|
|
normalize(command) {
|
|
assert.equal(command.schemaVersion, 1);
|
|
assert.match(command.operation, /^worker-credential\./);
|
|
assert.notEqual(command.operation, 'worker-credential.execute');
|
|
return command;
|
|
},
|
|
async collectOidc(identity) {
|
|
assert.equal(
|
|
identity.issuer,
|
|
'https://identity.production.example.org/',
|
|
);
|
|
return {
|
|
discoveryDocumentSha256: `sha256:${'c'.repeat(64)}`,
|
|
jwksSha256: `sha256:${'d'.repeat(64)}`,
|
|
};
|
|
},
|
|
async execute(paths) {
|
|
const command = JSON.parse(fs.readFileSync(paths.commandFile, 'utf8'));
|
|
calls.push({
|
|
operation: command.operation,
|
|
actor: path.basename(paths.assertionFile),
|
|
});
|
|
sequence += 1;
|
|
if (command.operation === 'worker-credential.plan') {
|
|
return {
|
|
requestId: 'request-plan',
|
|
result: { status: 'created', plan },
|
|
};
|
|
}
|
|
if (command.operation === 'worker-credential.propose') {
|
|
return {
|
|
requestId: 'request-propose',
|
|
result: {
|
|
approvalStatus: 'created',
|
|
plan,
|
|
approval: approval('pending'),
|
|
},
|
|
};
|
|
}
|
|
if (
|
|
command.operation === 'worker-credential.decide' &&
|
|
path.basename(paths.assertionFile) === 'requester.jwt'
|
|
) {
|
|
if (options.acceptSelfDecision) {
|
|
return {
|
|
requestId: 'request-self',
|
|
result: {
|
|
status: 'decided',
|
|
approval: approval('approved', {
|
|
type: 'user',
|
|
id: 'operator-a',
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
throw {
|
|
statusCode: 403,
|
|
responseCode: 'forbidden',
|
|
requestId: 'request-self',
|
|
};
|
|
}
|
|
if (command.operation === 'worker-credential.decide') {
|
|
return {
|
|
requestId: 'request-reviewer',
|
|
result: {
|
|
status: 'decided',
|
|
approval: approval('approved', {
|
|
type: 'user',
|
|
id: 'reviewer-b',
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
assert.equal(command.operation, 'worker-credential.inspect');
|
|
return {
|
|
requestId: 'request-inspect',
|
|
result: {
|
|
stale: false,
|
|
approval: approval('approved', { type: 'user', id: 'reviewer-b' }),
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('runs one external two-User ceremony without execution or sensitive evidence', async () => {
|
|
const paths = fixture();
|
|
const live = liveDependencies();
|
|
const report = await runWorkerCredentialManagementLiveCeremony(
|
|
paths,
|
|
live.dependencies,
|
|
);
|
|
|
|
assert.deepEqual(live.calls, [
|
|
{ operation: 'worker-credential.plan', actor: 'requester.jwt' },
|
|
{ operation: 'worker-credential.propose', actor: 'requester.jwt' },
|
|
{ operation: 'worker-credential.decide', actor: 'requester.jwt' },
|
|
{ operation: 'worker-credential.decide', actor: 'reviewer.jwt' },
|
|
{ operation: 'worker-credential.inspect', actor: 'reviewer.jwt' },
|
|
]);
|
|
assert.equal(report.gates.passed, true);
|
|
assert.equal(report.ceremony.dispatchCreated, false);
|
|
assert.equal(report.ceremony.approvalConsumed, false);
|
|
assert.equal(
|
|
validateWorkerCredentialManagementLiveCeremony(report).compatible,
|
|
true,
|
|
);
|
|
const serialized = JSON.stringify(report);
|
|
assert.doesNotMatch(serialized, /operator-a|reviewer-b|session-|eyJ/);
|
|
assert.equal(fs.statSync(paths.outputFile).mode & 0o777, 0o600);
|
|
assert.deepEqual(
|
|
JSON.parse(fs.readFileSync(paths.outputFile, 'utf8')),
|
|
report,
|
|
);
|
|
const audit = spawnSync(
|
|
process.execPath,
|
|
[
|
|
path.resolve(
|
|
__dirname,
|
|
'../../scripts/ql3-worker-credential-management-live-ceremony-audit.cjs',
|
|
),
|
|
`--report=${paths.outputFile}`,
|
|
],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
assert.equal(audit.status, 0, audit.stderr);
|
|
assert.equal(JSON.parse(audit.stdout).compatible, true);
|
|
});
|
|
|
|
test('rejects same-User, wrong-purpose and non-external identities before requests', async () => {
|
|
for (const [requester, reviewer] of [
|
|
[assertion('same-user'), assertion('same-user')],
|
|
[
|
|
assertion('operator-a', { ql3_purpose: 'plugin-package-management' }),
|
|
assertion('reviewer-b'),
|
|
],
|
|
[
|
|
assertion('operator-a', { iss: 'https://identity.example.test/' }),
|
|
assertion('reviewer-b', { iss: 'https://identity.example.test/' }),
|
|
],
|
|
]) {
|
|
const paths = fixture();
|
|
fs.writeFileSync(paths.requesterAssertionFile, requester, { mode: 0o600 });
|
|
fs.writeFileSync(paths.reviewerAssertionFile, reviewer, { mode: 0o600 });
|
|
const live = liveDependencies();
|
|
await assert.rejects(
|
|
runWorkerCredentialManagementLiveCeremony(paths, live.dependencies),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
assert.deepEqual(live.calls, []);
|
|
assert.equal(fs.existsSync(paths.outputFile), false);
|
|
}
|
|
});
|
|
|
|
test('fails closed when requester self-approval is accepted', async () => {
|
|
const paths = fixture();
|
|
const live = liveDependencies({ acceptSelfDecision: true });
|
|
await assert.rejects(
|
|
runWorkerCredentialManagementLiveCeremony(paths, live.dependencies),
|
|
/self-decision was accepted/,
|
|
);
|
|
assert.equal(fs.existsSync(paths.outputFile), false);
|
|
assert.equal(
|
|
live.calls.some(
|
|
({ operation }) => operation === 'worker-credential.inspect',
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('rejects broad, symlinked and replaceable private evidence files', async () => {
|
|
{
|
|
const paths = fixture();
|
|
fs.chmodSync(paths.requesterAssertionFile, 0o640);
|
|
const live = liveDependencies();
|
|
await assert.rejects(
|
|
runWorkerCredentialManagementLiveCeremony(paths, live.dependencies),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
assert.deepEqual(live.calls, []);
|
|
}
|
|
{
|
|
const paths = fixture();
|
|
const target = writePrivate(
|
|
path.dirname(paths.ceremonyFile),
|
|
'ceremony-target.json',
|
|
ceremony(),
|
|
);
|
|
fs.rmSync(paths.ceremonyFile);
|
|
fs.symlinkSync(target, paths.ceremonyFile);
|
|
const live = liveDependencies();
|
|
await assert.rejects(
|
|
runWorkerCredentialManagementLiveCeremony(paths, live.dependencies),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
assert.deepEqual(live.calls, []);
|
|
}
|
|
{
|
|
const paths = fixture();
|
|
fs.writeFileSync(paths.outputFile, '{}\n', { mode: 0o600 });
|
|
const live = liveDependencies();
|
|
await assert.rejects(
|
|
runWorkerCredentialManagementLiveCeremony(paths, live.dependencies),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
assert.deepEqual(live.calls, []);
|
|
}
|
|
});
|
|
|
|
test('offline audit rejects widened, false-gate and sensitive reports', async () => {
|
|
const paths = fixture();
|
|
const live = liveDependencies();
|
|
const report = await runWorkerCredentialManagementLiveCeremony(
|
|
paths,
|
|
live.dependencies,
|
|
);
|
|
for (const candidate of [
|
|
{ ...report, debug: true },
|
|
{ ...report, gates: { ...report.gates, passed: false } },
|
|
{ ...report, identity: { ...report.identity, secret: 'must-not-exist' } },
|
|
]) {
|
|
assert.equal(
|
|
validateWorkerCredentialManagementLiveCeremony(candidate).compatible,
|
|
false,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('parses only the five path-based CLI arguments', () => {
|
|
assert.deepEqual(
|
|
parseArguments([
|
|
'--config=/private/client.json',
|
|
'--requester-assertion=/private/requester.jwt',
|
|
'--reviewer-assertion=/private/reviewer.jwt',
|
|
'--ceremony=/private/ceremony.json',
|
|
'--output=/private/evidence.json',
|
|
]),
|
|
{
|
|
configFile: '/private/client.json',
|
|
requesterAssertionFile: '/private/requester.jwt',
|
|
reviewerAssertionFile: '/private/reviewer.jwt',
|
|
ceremonyFile: '/private/ceremony.json',
|
|
outputFile: '/private/evidence.json',
|
|
},
|
|
);
|
|
assert.throws(
|
|
() => parseArguments(['--requester-assertion=raw-jwt']),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
});
|
|
|
|
test('validates the Worker-specific assertion envelope and lifetime', () => {
|
|
assert.deepEqual(
|
|
assertionIdentity(assertion('operator-a'), NOW_MS).subject,
|
|
'operator-a',
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertionIdentity(
|
|
assertion('operator-a', { exp: NOW_MS / 1000 - 1 }),
|
|
NOW_MS,
|
|
),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertionIdentity(
|
|
assertion(
|
|
'operator-a',
|
|
{},
|
|
{ typ: 'ql3-plugin-package-management+jwt' },
|
|
),
|
|
NOW_MS,
|
|
),
|
|
WorkerCredentialManagementLiveCeremonyError,
|
|
);
|
|
});
|