Files
qinglong/packages/ql3-cluster-admin/test/promptOutputExternalRecoveryVerifier.test.cjs

337 lines
11 KiB
JavaScript

const assert = require('node:assert/strict');
const {
chmodSync,
mkdtempSync,
rmSync,
symlinkSync,
writeFileSync,
} = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
const { afterEach, test } = require('node:test');
const {
createPluginPackagePromptOutputArtifact,
} = require('../../ql3-ai/dist/prompt-output/pluginPackagePromptOutputArtifact.js');
const {
createPluginPackagePromptOutputExternalCustodyReceipt,
} = require('../../ql3-ai/dist/prompt-output/custody/pluginPackagePromptOutputExternalCustody.js');
const {
createPluginPackagePromptOutputExternalCustodyBundle,
} = require('../../ql3-ai/dist/prompt-output/custody/pluginPackagePromptOutputExternalCustodyBundle.js');
const {
createPluginPackagePromptOutputExternalRecoveryAuthorization,
} = require('../../ql3-ai/dist/prompt-output/custody/pluginPackagePromptOutputExternalRecoveryAuthorization.js');
const {
pluginPackagePromptOutputKeyRotationMaterialProof,
} = require('../../ql3-ai/dist/prompt-output/key-management/pluginPackagePromptOutputKeyRotation.js');
const {
disposeClusterPromptOutputExternalRecoveryInput,
readClusterPromptOutputExternalRecoveryCommand,
readClusterPromptOutputExternalRecoveryInput,
} = require('../dist/prompt-output/external-recovery/promptOutputExternalRecoveryInput.js');
const {
ClusterPromptOutputExternalRecoveryVerifierConfigError,
runClusterPromptOutputExternalRecoveryVerifier,
} = require('../dist/prompt-output/external-recovery/promptOutputExternalRecoveryVerifier.js');
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function privateFile(directory, name, value, mode = 0o440) {
const target = path.join(directory, name);
writeFileSync(target, value, { mode: 0o600 });
chmodSync(target, mode);
return target;
}
function fixture() {
const directory = mkdtempSync(path.join(os.tmpdir(), 'ql3-recovery-'));
temporaryDirectories.push(directory);
const now = Date.now();
const material = Buffer.alloc(32, 0x5c);
const wrapped = Buffer.from('provider-wrapped-key-material');
const custodyKeys = generateKeyPairSync('ed25519');
const approverAKeys = generateKeyPairSync('ed25519');
const approverBKeys = generateKeyPairSync('ed25519');
const catalogDigest = '1'.repeat(64);
const artifact = createPluginPackagePromptOutputArtifact(
{
projectId: 'project-offline-recovery',
runId: 'run-offline-recovery',
stepRunId: 'step-offline-recovery',
invocationId: 'invocation-offline-recovery',
requestedBy: { type: 'user', id: 'requester-user' },
result: {
provider: 'openai-compatible',
model: 'bounded-model',
text: 'private offline recovery output',
finishReason: 'stop',
usage: {
inputTokens: 4,
outputTokens: 5,
totalTokens: 9,
costMicros: 10,
},
},
retentionPolicy: {
revision: 'offline-recovery-v1',
retentionMs: 86_400_000,
},
keyId: 'offline-recovery-key',
key: Buffer.from(material),
sealedAtMs: now - 60_000,
},
() => Buffer.alloc(12, 0x39),
);
const receipt = createPluginPackagePromptOutputExternalCustodyReceipt(
{
custodyId: 'offline-custody',
keyId: 'offline-recovery-key',
materialProof: pluginPackagePromptOutputKeyRotationMaterialProof(
'offline-recovery-key',
material,
),
sourceGeneration: 4,
sourceCatalogDigest: catalogDigest,
wrappingProvider: 'external-kms',
wrappingKeyRefDigest: '2'.repeat(64),
wrappedMaterialDigest: createHash('sha256').update(wrapped).digest('hex'),
wrappedMaterialBytes: wrapped.length,
createdAtMs: now - 120_000,
},
{
publicKey: custodyKeys.publicKey,
sign: (message) => sign(null, message, custodyKeys.privateKey),
},
);
const custodyBundle = createPluginPackagePromptOutputExternalCustodyBundle(
receipt,
custodyKeys.publicKey,
wrapped,
);
const approvalSigner = (userId, authenticationId, keys, approvedAtMs) => ({
userId,
authenticationId,
authenticatedAtMs: approvedAtMs - 1_000,
approvedAtMs,
publicKey: keys.publicKey,
sign: (message) => sign(null, message, keys.privateKey),
});
const authorization =
createPluginPackagePromptOutputExternalRecoveryAuthorization(
{
recoveryId: 'offline-recovery-001',
requestId: 'offline-request-001',
custodyId: receipt.custodyId,
custodyReceiptDigest: receipt.receiptDigest,
keyId: receipt.keyId,
artifactId: artifact.artifactId,
artifactDigest: artifact.artifactDigest,
policyDigest: '3'.repeat(64),
requestedBy: {
userId: 'requester-user',
authenticationId: 'requester-auth',
authenticatedAtMs: now - 4_000,
},
requestedAtMs: now - 3_000,
expiresAtMs: now + 10 * 60_000,
},
[
approvalSigner(
'reviewer-a',
'reviewer-a-auth',
approverAKeys,
now - 2_000,
),
approvalSigner(
'reviewer-b',
'reviewer-b-auth',
approverBKeys,
now - 1_000,
),
],
);
const files = {
authorizationFile: privateFile(
directory,
'authorization.json',
JSON.stringify(authorization),
),
custodyBundleFile: privateFile(
directory,
'custody-bundle.json',
JSON.stringify(custodyBundle),
),
recoveredMaterialFile: privateFile(directory, 'material.bin', material),
durableKeyFactFile: privateFile(
directory,
'durable-fact.json',
JSON.stringify({
keyId: receipt.keyId,
materialProof: receipt.materialProof,
catalogDigest: receipt.sourceCatalogDigest,
}),
),
artifactFile: privateFile(
directory,
'artifact.json',
JSON.stringify(artifact),
),
custodyPublicKeyFile: privateFile(
directory,
'custody-public.pem',
custodyKeys.publicKey.export({ format: 'pem', type: 'spki' }),
),
approverAFile: privateFile(
directory,
'reviewer-a-public.pem',
approverAKeys.publicKey.export({ format: 'pem', type: 'spki' }),
),
approverBFile: privateFile(
directory,
'reviewer-b-public.pem',
approverBKeys.publicKey.export({ format: 'pem', type: 'spki' }),
),
};
const command = {
schemaVersion: 1,
operation: 'cluster.prompt-output-key.verify-recovery',
authorizationFile: files.authorizationFile,
custodyBundleFile: files.custodyBundleFile,
recoveredMaterialFile: files.recoveredMaterialFile,
durableKeyFactFile: files.durableKeyFactFile,
artifactFile: files.artifactFile,
custodyPublicKeyFile: files.custodyPublicKeyFile,
approverPublicKeyFiles: [
{ userId: 'reviewer-b', filePath: files.approverBFile },
{ userId: 'reviewer-a', filePath: files.approverAFile },
],
};
const commandFile = privateFile(
directory,
'command.json',
JSON.stringify(command),
0o444,
);
return { directory, now, files, command, commandFile };
}
test('reads one private recovery workspace and emits a content-free proof', () => {
const value = fixture();
const command = readClusterPromptOutputExternalRecoveryCommand(
value.commandFile,
);
assert.deepEqual(
command.approverPublicKeyFiles.map(({ userId }) => userId),
['reviewer-a', 'reviewer-b'],
);
const input = readClusterPromptOutputExternalRecoveryInput(command);
const proof = runClusterPromptOutputExternalRecoveryVerifier(
input,
value.now + 3_000,
);
assert.equal(proof.schema.includes('authorized'), true);
assert.equal(proof.authorizationDigest.length, 64);
assert.equal(JSON.stringify(proof).includes('private offline'), false);
disposeClusterPromptOutputExternalRecoveryInput(input);
assert.equal(
input.material.every((byte) => byte === 0),
true,
);
assert.equal(
input.wrappedMaterial.every((byte) => byte === 0),
true,
);
});
test('CLI verifies the workspace without database, Kubernetes or KMS input', () => {
const value = fixture();
const result = spawnSync(
process.execPath,
[
path.resolve(
__dirname,
'../dist/prompt-output/external-recovery/promptOutputExternalRecoveryCli.js',
),
'run',
'--command-file',
value.commandFile,
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr);
const report = JSON.parse(result.stdout);
assert.equal(report.event, 'recovery_verified');
assert.equal(report.proofDigest.length, 64);
assert.equal(result.stdout.includes('private offline'), false);
});
test('rejects writable or symlinked recovery material and custody bundles', () => {
for (const mutation of [
(value) => chmodSync(value.files.recoveredMaterialFile, 0o640),
(value) => chmodSync(value.files.custodyBundleFile, 0o640),
(value) => {
const link = path.join(value.directory, 'material-link.bin');
symlinkSync(value.files.recoveredMaterialFile, link);
const command = {
...value.command,
recoveredMaterialFile: link,
};
chmodSync(value.commandFile, 0o600);
writeFileSync(value.commandFile, JSON.stringify(command));
chmodSync(value.commandFile, 0o444);
},
(value) => {
const link = path.join(value.directory, 'custody-bundle-link.json');
symlinkSync(value.files.custodyBundleFile, link);
const command = {
...value.command,
custodyBundleFile: link,
};
chmodSync(value.commandFile, 0o600);
writeFileSync(value.commandFile, JSON.stringify(command));
chmodSync(value.commandFile, 0o444);
},
]) {
const value = fixture();
mutation(value);
const command = readClusterPromptOutputExternalRecoveryCommand(
value.commandFile,
);
assert.throws(() => readClusterPromptOutputExternalRecoveryInput(command));
}
});
test('rejects authorization and Artifact drift with a content-free error', () => {
const value = fixture();
const command = readClusterPromptOutputExternalRecoveryCommand(
value.commandFile,
);
const input = readClusterPromptOutputExternalRecoveryInput(command);
try {
assert.throws(
() =>
runClusterPromptOutputExternalRecoveryVerifier(
{
...input,
artifact: {
...input.artifact,
artifactDigest: '4'.repeat(64),
},
},
value.now + 3_000,
),
ClusterPromptOutputExternalRecoveryVerifierConfigError,
);
} finally {
disposeClusterPromptOutputExternalRecoveryInput(input);
}
});