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,548 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { chmod, mkdtemp, rename, rm, writeFile } = require('node:fs/promises');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join } = require('node:path');
|
||||
const { generateKeyPairSync, sign } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterPluginPackageIdentityKeysetUnavailableError,
|
||||
createClusterPluginPackageIdentityKeysetFile,
|
||||
createClusterWorkerCredentialIdentityKeysetFile,
|
||||
createClusterAutomationIdentityKeysetFile,
|
||||
createClusterApprovalIdentityKeysetFile,
|
||||
createClusterModelProviderCredentialIdentityKeysetFile,
|
||||
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
|
||||
|
||||
const NOW_MS = 1_700_000_000_000;
|
||||
const ISSUER = 'https://identity.example.test/';
|
||||
const AUDIENCE = 'qinglong3-package-management';
|
||||
|
||||
function reviewedKey(kid) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
||||
return {
|
||||
kid,
|
||||
privateKey,
|
||||
publicJwk: {
|
||||
...publicKey.export({ format: 'jwk' }),
|
||||
alg: 'EdDSA',
|
||||
kid,
|
||||
use: 'sig',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function keyset(generation, keys, revokedKids = []) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generation,
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
keys: keys.map((key) => key.publicJwk),
|
||||
revokedKids,
|
||||
assuranceMappings: [
|
||||
{
|
||||
acr: 'urn:ql3:mfa',
|
||||
assurance: 'multi_factor',
|
||||
requiredAmr: ['pwd', 'otp'],
|
||||
},
|
||||
],
|
||||
constraints: {
|
||||
maxAssertionBytes: 8 * 1024,
|
||||
maxLifetimeMs: 5 * 60 * 1000,
|
||||
maxAuthenticationAgeMs: 5 * 60 * 1000,
|
||||
clockSkewMs: 5 * 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-plugin-package-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: AUDIENCE,
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `assertion-${key.kid}`,
|
||||
ql3_purpose: 'plugin-package-management',
|
||||
sub: 'user-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function workerAssertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-worker-credential-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: 'qinglong3-worker-credential-management',
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `worker-assertion-${key.kid}`,
|
||||
ql3_purpose: 'worker-credential-management',
|
||||
sub: 'worker-operator-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function automationAssertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-automation-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: 'qinglong3-automation-management',
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `automation-assertion-${key.kid}`,
|
||||
ql3_purpose: 'automation-management',
|
||||
sub: 'automation-operator-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function approvalAssertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-approval-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: 'qinglong3-approval-management',
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `approval-assertion-${key.kid}`,
|
||||
ql3_purpose: 'approval-management',
|
||||
sub: 'approval-owner-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function providerCredentialAssertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-model-provider-credential-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: 'qinglong3-model-provider-credential-management',
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `provider-credential-assertion-${key.kid}`,
|
||||
ql3_purpose: 'model-provider-credential-management',
|
||||
sub: 'provider-credential-operator-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function atomicWrite(filePath, document) {
|
||||
const nextPath = `${filePath}.next`;
|
||||
await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 });
|
||||
await rename(nextPath, filePath);
|
||||
}
|
||||
|
||||
async function fixture(run) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'ql3-identity-keyset-'));
|
||||
const filePath = join(directory, 'keyset.json');
|
||||
try {
|
||||
return await run({ directory, filePath });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('loads one bounded keyset and authenticates through the current generation', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
await atomicWrite(filePath, keyset(1, [first]));
|
||||
const provider = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
|
||||
assert.deepEqual(await provider.reload(), {
|
||||
schemaVersion: 1,
|
||||
generation: 1,
|
||||
digest: (await provider.reload()).digest,
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
activeKeyIds: ['issuer-key-1'],
|
||||
revokedKeyIds: [],
|
||||
});
|
||||
const principal = await provider.bind(assertion(first)).authenticate();
|
||||
assert.deepEqual(principal.subject, { type: 'user', id: 'user-1' });
|
||||
assert.equal(principal.assurance, 'multi_factor');
|
||||
});
|
||||
});
|
||||
|
||||
test('loads a Worker credential keyset with a distinct assertion purpose', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const key = reviewedKey('worker-identity-key-1');
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [key]),
|
||||
audience: 'qinglong3-worker-credential-management',
|
||||
});
|
||||
const provider = createClusterWorkerCredentialIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
|
||||
const principal = await provider.bind(workerAssertion(key)).authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'worker-operator-1',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider
|
||||
.bind(
|
||||
assertion(key, {
|
||||
aud: 'qinglong3-worker-credential-management',
|
||||
}),
|
||||
)
|
||||
.authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('loads an automation keyset with a purpose isolated from other management planes', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const key = reviewedKey('automation-identity-key-1');
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [key]),
|
||||
audience: 'qinglong3-automation-management',
|
||||
});
|
||||
const provider = createClusterAutomationIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider
|
||||
.bind(automationAssertion(key))
|
||||
.authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'automation-operator-1',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.bind(workerAssertion(key)).authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('loads an Approval keyset isolated by type, purpose and audience', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const key = reviewedKey('approval-identity-key-1');
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [key]),
|
||||
audience: 'qinglong3-approval-management',
|
||||
});
|
||||
const provider = createClusterApprovalIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider.bind(approvalAssertion(key)).authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'approval-owner-1',
|
||||
});
|
||||
await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('loads a provider credential keyset isolated by type, purpose and audience', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const key = reviewedKey('provider-credential-identity-key-1');
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [key]),
|
||||
audience: 'qinglong3-model-provider-credential-management',
|
||||
});
|
||||
const provider = createClusterModelProviderCredentialIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider
|
||||
.bind(providerCredentialAssertion(key))
|
||||
.authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'provider-credential-operator-1',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.bind(automationAssertion(key)).authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
await assert.rejects(
|
||||
provider
|
||||
.bind(
|
||||
providerCredentialAssertion(key, {
|
||||
ql3_purpose: 'automation-management',
|
||||
}),
|
||||
)
|
||||
.authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('supports overlap rotation then immediately revokes the previous key', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
const second = reviewedKey('issuer-key-2');
|
||||
await atomicWrite(filePath, keyset(1, [first]));
|
||||
const provider = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
await provider.reload();
|
||||
|
||||
await atomicWrite(filePath, keyset(2, [first, second]));
|
||||
assert.deepEqual((await provider.reload()).activeKeyIds, [
|
||||
'issuer-key-1',
|
||||
'issuer-key-2',
|
||||
]);
|
||||
assert.equal(
|
||||
(await provider.bind(assertion(second)).authenticate()).subject.id,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
await atomicWrite(filePath, keyset(3, [first, second], ['issuer-key-1']));
|
||||
assert.deepEqual(await provider.reload(), {
|
||||
schemaVersion: 1,
|
||||
generation: 3,
|
||||
digest: (await provider.reload()).digest,
|
||||
issuer: ISSUER,
|
||||
audience: AUDIENCE,
|
||||
activeKeyIds: ['issuer-key-2'],
|
||||
revokedKeyIds: ['issuer-key-1'],
|
||||
});
|
||||
await assert.rejects(provider.bind(assertion(first)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
assert.equal(
|
||||
(await provider.bind(assertion(second)).authenticate()).subject.id,
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects generation rollback, same-generation rewrite and implicit removal', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
const second = reviewedKey('issuer-key-2');
|
||||
await atomicWrite(filePath, keyset(1, [first]));
|
||||
const provider = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
await provider.reload();
|
||||
|
||||
await atomicWrite(filePath, keyset(1, [first, second]));
|
||||
await assert.rejects(provider.reload(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
||||
});
|
||||
|
||||
await atomicWrite(filePath, keyset(2, [first, second]));
|
||||
await provider.reload();
|
||||
await atomicWrite(filePath, keyset(3, [second]));
|
||||
await assert.rejects(provider.reload(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
||||
});
|
||||
|
||||
await atomicWrite(filePath, keyset(1, [first]));
|
||||
await assert.rejects(provider.reload(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps revocation append-only and rejects stale fallback on file failure', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
const second = reviewedKey('issuer-key-2');
|
||||
await atomicWrite(filePath, keyset(1, [first, second]));
|
||||
const provider = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
await provider.reload();
|
||||
await atomicWrite(filePath, keyset(2, [first, second], ['issuer-key-1']));
|
||||
await provider.reload();
|
||||
|
||||
await atomicWrite(filePath, keyset(3, [first, second]));
|
||||
await assert.rejects(provider.reload(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
||||
});
|
||||
|
||||
await atomicWrite(filePath, keyset(3, [first, second], ['issuer-key-1']));
|
||||
await chmod(filePath, 0o666);
|
||||
await assert.rejects(
|
||||
provider.bind(assertion(second)).authenticate(),
|
||||
ClusterPluginPackageIdentityKeysetUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('rechecks one durable ledger across unchanged files and fresh replicas', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
const second = reviewedKey('issuer-key-2');
|
||||
let minimumGeneration = 1;
|
||||
const observations = [];
|
||||
const ledger = {
|
||||
async observe(snapshot) {
|
||||
observations.push(snapshot);
|
||||
if (snapshot.generation < minimumGeneration) {
|
||||
throw new Error('durable generation rollback');
|
||||
}
|
||||
minimumGeneration = snapshot.generation;
|
||||
},
|
||||
};
|
||||
await atomicWrite(filePath, keyset(1, [first]));
|
||||
const firstReplica = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
ledger,
|
||||
});
|
||||
await firstReplica.reload();
|
||||
await firstReplica.reload();
|
||||
assert.equal(observations.length, 2);
|
||||
|
||||
await atomicWrite(filePath, keyset(2, [first, second], ['issuer-key-1']));
|
||||
await firstReplica.reload();
|
||||
assert.equal(minimumGeneration, 2);
|
||||
|
||||
await atomicWrite(filePath, keyset(1, [first]));
|
||||
const restartedReplica = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
ledger,
|
||||
});
|
||||
await assert.rejects(
|
||||
restartedReplica.reload(),
|
||||
ClusterPluginPackageIdentityKeysetUnavailableError,
|
||||
);
|
||||
|
||||
await atomicWrite(filePath, keyset(2, [first, second], ['issuer-key-1']));
|
||||
minimumGeneration = 3;
|
||||
await assert.rejects(
|
||||
firstReplica.bind(assertion(second)).authenticate(),
|
||||
ClusterPluginPackageIdentityKeysetUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malformed, private and oversized trust documents', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
const provider = createClusterPluginPackageIdentityKeysetFile({
|
||||
filePath,
|
||||
maxFileBytes: 4 * 1024,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [first]),
|
||||
keys: [{ ...first.publicJwk, d: 'private' }],
|
||||
});
|
||||
await assert.rejects(provider.reload(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
||||
});
|
||||
|
||||
await writeFile(filePath, Buffer.alloc(4 * 1024 + 1, 0x20), {
|
||||
mode: 0o644,
|
||||
});
|
||||
await assert.rejects(provider.reload(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user