workshop-credential-envelope.test.mjs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import crypto from 'node:crypto';
  4. import { sealCredential, sha256 } from '../scripts/lib/workshop-credential-envelope.mjs';
  5. import { bindingUpdate } from '../scripts/restore-workshop-credential-binding.mjs';
  6. import { decryptEnvelope } from '../docs/workshop-auth-recovery-20261108/receipts/r1-return/files/public-recipient/decrypt-module.mjs';
  7. // Test-only keys and token. Production recipient private material is never needed here.
  8. const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 3072 });
  9. const publicKey = pair.publicKey.export({ type: 'spki', format: 'pem' });
  10. const env = { KSF_FUNCTIONS_URL: 'https://kangshifu.fmode.cn/api/functions', KSF_PARSE_APP_ID: 'FKieRbIoGt_main', KSF_SERVICE_TOKEN: 'T'.repeat(43) };
  11. const parameters = { publicKey, host: '0123456789ab', directory: '/opt/data/ksf-key-receive-20261031-test',
  12. credentialId: 'test-credential', expiresAt: '2030-01-02T00:00:00.000Z', env, now: Date.parse('2030-01-01T00:00:00Z') };
  13. const context = { host: parameters.host, directory: parameters.directory,
  14. publicKeySha256: sha256(pair.publicKey.export({ type: 'spki', format: 'der' })), now: parameters.now };
  15. test('original returned decryptor accepts the renewed envelope and preserves exact identity/config', () => {
  16. const envelope = sealCredential(parameters);
  17. const result = decryptEnvelope(envelope, pair.privateKey, context);
  18. assert.equal(result.credentialId, parameters.credentialId);
  19. assert.equal(result.expiresAt, parameters.expiresAt);
  20. assert.deepEqual(result.env, env);
  21. assert.equal(JSON.stringify(envelope).includes(env.KSF_SERVICE_TOKEN), false);
  22. });
  23. test('original decryptor rejects expired credentials even if encryption itself is intact', () => {
  24. const envelope = sealCredential(parameters);
  25. assert.throws(() => decryptEnvelope(envelope, pair.privateKey, { ...context, now: Date.parse(parameters.expiresAt) }), { code: 'CREDENTIAL_EXPIRED_OR_MISMATCH' });
  26. });
  27. test('recipient host and key binding are enforced by the unchanged decryptor', () => {
  28. const envelope = sealCredential(parameters);
  29. assert.throws(() => decryptEnvelope(envelope, pair.privateKey, { ...context, host: 'different-host' }), { code: 'WRONG_RECIPIENT' });
  30. assert.throws(() => decryptEnvelope(envelope, pair.privateKey, { ...context, publicKeySha256: '0'.repeat(64) }), { code: 'KEY_FINGERPRINT_MISMATCH' });
  31. });
  32. test('expiry cannot be extended by editing only the public header', () => {
  33. const envelope = sealCredential(parameters);
  34. envelope.header.expiresAt = '2040-01-01T00:00:00Z';
  35. assert.throws(() => decryptEnvelope(envelope, pair.privateKey, context), { code: 'HEADER_INCONSISTENT' });
  36. envelope.aadBase64 = Buffer.from(JSON.stringify(envelope.header)).toString('base64');
  37. assert.throws(() => decryptEnvelope(envelope, pair.privateKey, context));
  38. });
  39. test('ciphertext tampering is rejected and encryption uses fresh random material', () => {
  40. const first = sealCredential(parameters), second = sealCredential(parameters);
  41. assert.notEqual(first.ciphertextBase64, second.ciphertextBase64);
  42. const bytes = Buffer.from(first.ciphertextBase64, 'base64'); bytes[0] ^= 1;
  43. first.ciphertextBase64 = bytes.toString('base64');
  44. assert.throws(() => decryptEnvelope(first, pair.privateKey, context));
  45. });
  46. test('sealer rejects wrong endpoint, extra credential keys, weak public keys and expired input', () => {
  47. assert.throws(() => sealCredential({ ...parameters, env: { ...env, MASTER_KEY: 'not-allowed' } }), { code: 'ENV_KEYS_NOT_ALLOWED' });
  48. assert.throws(() => sealCredential({ ...parameters, env: { ...env, KSF_FUNCTIONS_URL: 'https://other.example/api/functions' } }), { code: 'BAD_SERVICE_CONFIG' });
  49. assert.throws(() => sealCredential({ ...parameters, now: Date.parse(parameters.expiresAt) }), { code: 'EXPIRED_CREDENTIAL' });
  50. const weak = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).publicKey.export({ type: 'spki', format: 'pem' });
  51. assert.throws(() => sealCredential({ ...parameters, publicKey: weak }), { code: 'BAD_PUBLIC_KEY' });
  52. });
  53. const binding = { version: 'ksf.encrypted-entry.v1', purpose: 'KSF-credential-receive-20261031', host: parameters.host,
  54. keyDirectory: parameters.directory, publicKeySha256: context.publicKeySha256, decryptModule: '/opt/data/receiver.mjs',
  55. decryptModuleSha256: 'a'.repeat(64), envelopeFile: '/opt/data/old.json', envelopeSha256: 'b'.repeat(64), preservedExtra: 'keep' };
  56. const bindingBytes = Buffer.from(JSON.stringify(binding));
  57. const release = { ...binding, expectedBindingSha256: sha256(bindingBytes), oldEnvelopeFile: binding.envelopeFile,
  58. oldEnvelopeSha256: binding.envelopeSha256, installEnvelope: '/opt/data/new.json', envelopeSha256: 'c'.repeat(64) };
  59. test('binding update changes only the two envelope fields and supports an identical replay', () => {
  60. const plan = bindingUpdate(bindingBytes, release, parameters.host);
  61. assert.equal(plan.alreadyApplied, false);
  62. assert.deepEqual(plan.after, { ...binding, envelopeFile: release.installEnvelope, envelopeSha256: release.envelopeSha256 });
  63. const replay = bindingUpdate(plan.afterBytes, release, parameters.host);
  64. assert.equal(replay.alreadyApplied, true);
  65. assert.deepEqual(replay.afterBytes, plan.afterBytes);
  66. });
  67. test('binding changes, other recipient hosts and key substitutions are blocked', () => {
  68. assert.throws(() => bindingUpdate(Buffer.from(JSON.stringify({ ...binding, preservedExtra: 'concurrent' })), release, parameters.host), { code: 'UNKNOWN_BINDING_VERSION' });
  69. assert.throws(() => bindingUpdate(bindingBytes, release, 'other-container'), { code: 'WRONG_CONTAINER' });
  70. assert.throws(() => bindingUpdate(bindingBytes, { ...release, publicKeySha256: 'f'.repeat(64) }, parameters.host), { code: 'RECIPIENT_BINDING_CHANGED' });
  71. });