mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user