mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
387 lines
11 KiB
JavaScript
387 lines
11 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
ClusterPluginPackageIdentityAssertionAuthenticationError,
|
|
ClusterPluginPackageIdentityAssertionConfigurationError,
|
|
createClusterPluginPackageIdentityAssertionVerifier,
|
|
} = require('@qinglong/cluster-admin/plugin-package-identity-assertion');
|
|
const {
|
|
createClusterPluginPackageManagementTransport,
|
|
} = require('@qinglong/cluster-admin/plugin-package-management-transport');
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
const ISSUER = 'https://identity.example.test/ql3';
|
|
const AUDIENCE = 'qinglong3-plugin-package-management';
|
|
const PURPOSE = 'plugin-package-management';
|
|
const TYPE = 'ql3-plugin-package-management+jwt';
|
|
|
|
function reviewedKey(algorithm = 'EdDSA', kid = 'identity-key-1') {
|
|
const pair =
|
|
algorithm === 'RS256'
|
|
? generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicExponent: 0x10001,
|
|
})
|
|
: algorithm === 'ES256'
|
|
? generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
: generateKeyPairSync('ed25519');
|
|
return {
|
|
algorithm,
|
|
kid,
|
|
privateKey: pair.privateKey,
|
|
publicJwk: {
|
|
...pair.publicKey.export({ format: 'jwk' }),
|
|
kid,
|
|
use: 'sig',
|
|
alg: algorithm,
|
|
},
|
|
};
|
|
}
|
|
|
|
function encode(value) {
|
|
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
}
|
|
|
|
function assertion(key, claimOverrides = {}, headerOverrides = {}) {
|
|
const header = {
|
|
typ: TYPE,
|
|
alg: key.algorithm,
|
|
kid: key.kid,
|
|
...headerOverrides,
|
|
};
|
|
const claims = {
|
|
iss: ISSUER,
|
|
aud: AUDIENCE,
|
|
sub: 'cluster-reviewer',
|
|
jti: 'assertion-session-1',
|
|
iat: NOW / 1_000 - 10,
|
|
auth_time: NOW / 1_000 - 20,
|
|
exp: NOW / 1_000 + 120,
|
|
acr: 'urn:example:assurance:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
ql3_purpose: PURPOSE,
|
|
...claimOverrides,
|
|
};
|
|
const protectedSegment = encode(header);
|
|
const payloadSegment = encode(claims);
|
|
const signed = Buffer.from(`${protectedSegment}.${payloadSegment}`, 'ascii');
|
|
const signature =
|
|
key.algorithm === 'EdDSA'
|
|
? sign(null, signed, key.privateKey)
|
|
: key.algorithm === 'ES256'
|
|
? sign('sha256', signed, {
|
|
key: key.privateKey,
|
|
dsaEncoding: 'ieee-p1363',
|
|
})
|
|
: sign('RSA-SHA256', signed, key.privateKey);
|
|
return `${protectedSegment}.${payloadSegment}.${signature.toString(
|
|
'base64url',
|
|
)}`;
|
|
}
|
|
|
|
function verifier(key, overrides = {}) {
|
|
return createClusterPluginPackageIdentityAssertionVerifier({
|
|
issuer: ISSUER,
|
|
audience: AUDIENCE,
|
|
keys: [key.publicJwk],
|
|
assuranceMappings: [
|
|
{
|
|
acr: 'urn:example:assurance:mfa',
|
|
assurance: 'multi_factor',
|
|
requiredAmr: ['pwd', 'otp'],
|
|
},
|
|
{
|
|
acr: 'urn:example:assurance:hardware',
|
|
assurance: 'hardware',
|
|
requiredAmr: ['fido', 'hwk'],
|
|
},
|
|
],
|
|
now: () => NOW,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function fakeManagementService() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
service: {
|
|
async propose(request) {
|
|
calls.push(request);
|
|
return {
|
|
proposalStatus: 'created',
|
|
approvalStatus: 'created',
|
|
proposal: {
|
|
actionRef: request.actionRef,
|
|
projectId: 'default',
|
|
actionInput: request.actionInput,
|
|
actionDigest: 'a'.repeat(64),
|
|
previewDigest: 'b'.repeat(64),
|
|
proposalDigest: 'c'.repeat(64),
|
|
createdAtMs: request.requestedAtMs,
|
|
},
|
|
approvalRequest: {
|
|
id: request.approvalRequestId,
|
|
projectId: 'default',
|
|
version: 1,
|
|
state: 'pending',
|
|
action: {
|
|
actionDigest: 'a'.repeat(64),
|
|
previewDigest: 'b'.repeat(64),
|
|
},
|
|
risk: 'high',
|
|
decisionMode: 'separation_of_duty',
|
|
requestedAtMs: request.requestedAtMs,
|
|
expiresAtMs: request.requestedAtMs + 60_000,
|
|
decision: null,
|
|
decisionReasonCode: null,
|
|
decidedAtMs: null,
|
|
dispatchId: null,
|
|
consumedAtMs: null,
|
|
},
|
|
};
|
|
},
|
|
async decide() {
|
|
throw new Error('not used');
|
|
},
|
|
async inspect() {
|
|
return { proposal: null, approvalRequest: null };
|
|
},
|
|
async inspectAuthorized() {
|
|
return { proposal: null, approvalRequest: null };
|
|
},
|
|
async inspectInstallationAuthorized() {
|
|
return null;
|
|
},
|
|
async listInstallationsAuthorized() {
|
|
return { items: [], truncated: false };
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function proposeCommand() {
|
|
return {
|
|
schemaVersion: 1,
|
|
operation: 'plugin-package.propose',
|
|
request: {
|
|
actionRef: 'package:cluster-monitor:1',
|
|
approvalRequestId: 'approval-cluster-monitor-1',
|
|
proposalAuditEventId: 'proposal-audit-1',
|
|
approvalAuditEventId: 'approval-audit-1',
|
|
actionInput: {
|
|
manifest: {
|
|
metadata: { name: 'cluster-monitor', version: '1.0.0' },
|
|
},
|
|
plan: { operation: 'install' },
|
|
source: { kind: 'registry' },
|
|
architecture: 'arm64',
|
|
deploymentProfile: 'cluster',
|
|
targetGeneration: 1,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test('verifies a dedicated assertion and injects no raw token identity into management', async () => {
|
|
const key = reviewedKey();
|
|
const token = assertion(key);
|
|
const identity = verifier(key);
|
|
const principal = identity.verify(token);
|
|
const expectedAuthenticationId = `ql3oidc.${createHash('sha256')
|
|
.update(ISSUER)
|
|
.update('\0')
|
|
.update('assertion-session-1')
|
|
.digest('base64url')}`;
|
|
assert.deepEqual(principal, {
|
|
subject: { type: 'user', id: 'cluster-reviewer' },
|
|
authenticationId: expectedAuthenticationId,
|
|
authenticatedAtMs: NOW - 20_000,
|
|
expiresAtMs: NOW + 120_000,
|
|
assurance: 'multi_factor',
|
|
});
|
|
|
|
const fixture = fakeManagementService();
|
|
const transport = createClusterPluginPackageManagementTransport({
|
|
service: fixture.service,
|
|
now: () => NOW,
|
|
});
|
|
const result = await transport.execute(
|
|
proposeCommand(),
|
|
identity.bind(token),
|
|
);
|
|
assert.equal(fixture.calls.length, 1);
|
|
assert.deepEqual(fixture.calls[0].principal, principal);
|
|
const serialized = JSON.stringify({ result, request: fixture.calls[0] });
|
|
assert.equal(serialized.includes(token), false);
|
|
assert.equal(serialized.includes('assertion-session-1'), false);
|
|
});
|
|
|
|
test('keeps Plugin Package and Worker credential assertion purposes disjoint', () => {
|
|
const key = reviewedKey();
|
|
const workerAudience = 'qinglong3-worker-credential-management';
|
|
const workerProfile = {
|
|
type: 'ql3-worker-credential-management+jwt',
|
|
purpose: 'worker-credential-management',
|
|
};
|
|
const workerToken = assertion(
|
|
key,
|
|
{
|
|
aud: workerAudience,
|
|
ql3_purpose: workerProfile.purpose,
|
|
},
|
|
{ typ: workerProfile.type },
|
|
);
|
|
const workerVerifier = verifier(key, {
|
|
audience: workerAudience,
|
|
assertionProfile: workerProfile,
|
|
});
|
|
|
|
assert.equal(
|
|
workerVerifier.verify(workerToken).subject.id,
|
|
'cluster-reviewer',
|
|
);
|
|
assert.throws(
|
|
() => verifier(key).verify(workerToken),
|
|
ClusterPluginPackageIdentityAssertionAuthenticationError,
|
|
);
|
|
assert.throws(
|
|
() => workerVerifier.verify(assertion(key)),
|
|
ClusterPluginPackageIdentityAssertionAuthenticationError,
|
|
);
|
|
});
|
|
|
|
test('accepts reviewed EdDSA, ES256 and RS256 key algorithms', () => {
|
|
for (const algorithm of ['EdDSA', 'ES256', 'RS256']) {
|
|
const key = reviewedKey(algorithm, `key-${algorithm}`);
|
|
assert.equal(
|
|
verifier(key).verify(assertion(key)).subject.id,
|
|
'cluster-reviewer',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('maps hardware only through an exact ACR and complete AMR rule', () => {
|
|
const key = reviewedKey();
|
|
const identity = verifier(key);
|
|
const hardware = identity.verify(
|
|
assertion(key, {
|
|
acr: 'urn:example:assurance:hardware',
|
|
amr: ['fido', 'hwk'],
|
|
}),
|
|
);
|
|
assert.equal(hardware.assurance, 'hardware');
|
|
assert.throws(
|
|
() =>
|
|
identity.verify(
|
|
assertion(key, {
|
|
acr: 'urn:example:assurance:hardware',
|
|
amr: ['fido'],
|
|
}),
|
|
),
|
|
ClusterPluginPackageIdentityAssertionAuthenticationError,
|
|
);
|
|
});
|
|
|
|
test('rejects signature, algorithm, key and dedicated-token confusion', () => {
|
|
const key = reviewedKey();
|
|
const other = reviewedKey('EdDSA', 'identity-key-2');
|
|
const identity = verifier(key);
|
|
const valid = assertion(key);
|
|
const tampered = `${valid.slice(0, -1)}${valid.endsWith('A') ? 'B' : 'A'}`;
|
|
for (const candidate of [
|
|
tampered,
|
|
assertion(other),
|
|
assertion(key, {}, { typ: 'JWT' }),
|
|
assertion(key, {}, { alg: 'RS256' }),
|
|
assertion(key, { unexpected_claim: 'must-fail-closed' }),
|
|
`${valid}.extra`,
|
|
'a'.repeat(9 * 1_024),
|
|
]) {
|
|
assert.throws(
|
|
() => identity.verify(candidate),
|
|
ClusterPluginPackageIdentityAssertionAuthenticationError,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects wrong trust domain, inactive lifetime and weak assurance facts', () => {
|
|
const key = reviewedKey();
|
|
const identity = verifier(key);
|
|
for (const overrides of [
|
|
{ iss: 'https://other.example.test/ql3' },
|
|
{ aud: 'another-service' },
|
|
{ ql3_purpose: 'cluster-control' },
|
|
{ exp: NOW / 1_000 },
|
|
{ iat: NOW / 1_000 + 10 },
|
|
{ auth_time: NOW / 1_000 - 301 },
|
|
{ exp: NOW / 1_000 + 301 },
|
|
{ acr: 'urn:example:assurance:single-factor', amr: ['pwd'] },
|
|
{ acr: 'urn:example:assurance:mfa', amr: ['pwd'] },
|
|
]) {
|
|
assert.throws(
|
|
() => identity.verify(assertion(key, overrides)),
|
|
ClusterPluginPackageIdentityAssertionAuthenticationError,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects unreviewed configuration before accepting any assertion', () => {
|
|
const key = reviewedKey();
|
|
const weakRsa = reviewedKey('RS256', 'weak-rsa');
|
|
const weakRsaPair = generateKeyPairSync('rsa', {
|
|
modulusLength: 1024,
|
|
publicExponent: 0x10001,
|
|
});
|
|
weakRsa.publicJwk = {
|
|
...weakRsaPair.publicKey.export({ format: 'jwk' }),
|
|
kid: weakRsa.kid,
|
|
use: 'sig',
|
|
alg: weakRsa.algorithm,
|
|
};
|
|
const privateJwk = {
|
|
...key.privateKey.export({ format: 'jwk' }),
|
|
kid: key.kid,
|
|
use: 'sig',
|
|
alg: key.algorithm,
|
|
};
|
|
for (const overrides of [
|
|
{ issuer: 'http://identity.example.test/ql3' },
|
|
{ keys: [] },
|
|
{ keys: [key.publicJwk, key.publicJwk] },
|
|
{ keys: [privateJwk] },
|
|
{ keys: [weakRsa.publicJwk] },
|
|
{
|
|
assuranceMappings: [
|
|
{
|
|
acr: 'urn:example:assurance:mfa',
|
|
assurance: 'single_factor',
|
|
requiredAmr: ['pwd'],
|
|
},
|
|
],
|
|
},
|
|
{ maxAssertionBytes: 32 * 1024 },
|
|
{ maxLifetimeMs: 60 * 60_000 },
|
|
{ clockSkewMs: 120_000 },
|
|
{
|
|
assertionProfile: {
|
|
type: 'JWT',
|
|
purpose: 'worker-credential-management',
|
|
},
|
|
},
|
|
{
|
|
assertionProfile: {
|
|
type: 'ql3-worker-credential-management+jwt',
|
|
purpose: 'Worker Credential Management',
|
|
},
|
|
},
|
|
]) {
|
|
assert.throws(
|
|
() => verifier(key, overrides),
|
|
ClusterPluginPackageIdentityAssertionConfigurationError,
|
|
);
|
|
}
|
|
});
|