@learncard/lca-api-service 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ // Use Serverless's own YAML parser. An optional argument validates its packaged JSON template.
8
+ const require = createRequire(import.meta.url);
9
+ const serverlessRequire = createRequire(require.resolve('serverless/package.json'));
10
+ const config = serverlessRequire('js-yaml').load(
11
+ readFileSync(fileURLToPath(new URL('../serverless.yml', import.meta.url)), 'utf8')
12
+ );
13
+ const packaged = process.argv[2] ? JSON.parse(readFileSync(process.argv[2], 'utf8')) : undefined;
14
+ const resources = packaged?.Resources ?? config.resources.Resources;
15
+ const key = resources.SigningAuthoritySeedKey;
16
+ assert.equal(key.Type, 'AWS::KMS::Key');
17
+ assert.equal(key.DeletionPolicy, 'Retain');
18
+ assert.equal(key.UpdateReplacePolicy, 'Retain');
19
+ assert.equal(key.Properties.KeySpec, 'SYMMETRIC_DEFAULT');
20
+ assert.equal(key.Properties.KeyUsage, 'ENCRYPT_DECRYPT');
21
+ assert.equal(key.Properties.EnableKeyRotation, true);
22
+
23
+ const roleArn = { 'Fn::GetAtt': ['SigningAuthorityExecutionRole', 'Arn'] };
24
+ const keyArn = { 'Fn::GetAtt': ['SigningAuthoritySeedKey', 'Arn'] };
25
+ const purpose = { StringEquals: { 'kms:EncryptionContext:purpose': 'lca-signing-authority-seed' } };
26
+ const statements = key.Properties.KeyPolicy.Statement;
27
+ const use = statements.find(statement => statement.Sid === 'LcaApiSeedEncryption');
28
+ assert.deepEqual(use.Principal, { AWS: roleArn });
29
+ assert.deepEqual(use.Action, ['kms:GenerateDataKey', 'kms:Decrypt']);
30
+ assert.deepEqual(use.Condition, purpose);
31
+ const deny = statements.find(statement => statement.Sid === 'DenyOtherCryptographicPrincipals');
32
+ assert.equal(deny.Effect, 'Deny');
33
+ assert.equal(deny.Principal, '*');
34
+ assert.deepEqual(deny.Condition, { ArnNotEquals: { 'aws:PrincipalArn': roleArn } });
35
+ assert(deny.Action.includes('kms:Decrypt'));
36
+ assert(deny.Action.includes('kms:ReEncrypt*'));
37
+ const admin = statements.find(statement => statement.Sid === 'AccountKeyAdministration');
38
+ assert(admin.Action.includes('kms:DeleteAlias'));
39
+ assert(admin.Action.includes('kms:GetKeyPolicy'));
40
+ assert(
41
+ !admin.Action.some(action =>
42
+ ['kms:*', 'kms:Decrypt', 'kms:CreateGrant', 'kms:ReEncrypt*'].includes(action)
43
+ )
44
+ );
45
+ const iam = resources.SigningAuthoritySeedKeyPolicy.Properties;
46
+ assert.deepEqual(iam.Roles, [{ Ref: 'SigningAuthorityExecutionRole' }]);
47
+ assert.deepEqual(iam.PolicyDocument.Statement, [
48
+ {
49
+ Effect: 'Allow',
50
+ Action: ['kms:GenerateDataKey', 'kms:Decrypt'],
51
+ Resource: keyArn,
52
+ Condition: purpose,
53
+ },
54
+ ]);
55
+
56
+ const worker = config.functions.seedMigration;
57
+ assert.equal(worker.timeout, 900);
58
+ assert.equal(worker.reservedConcurrency, 1);
59
+ assert.equal(worker.events, undefined);
60
+ assert.equal(worker.url, undefined);
61
+ assert.deepEqual(worker.vpc, config.functions.api.vpc);
62
+ for (const name of ['api', 'trpc', 'seedMigration']) {
63
+ assert(config.functions[name].dependsOn.includes('SigningAuthoritySeedKeyPolicy'));
64
+ assert.equal(config.functions[name].role, 'SigningAuthorityExecutionRole');
65
+ }
66
+ for (const name of ['swagger', 'didWeb']) {
67
+ assert.equal(config.functions[name].role, undefined);
68
+ }
69
+ assert.equal(resources.SigningAuthorityExecutionRole.Type, 'AWS::IAM::Role');
70
+ // provider.environment is a Serverless file variable; resolve it like Serverless does, in
71
+ // both runtime-secrets modes, so the KMS ARN and the no-local-KEK invariant are still checked.
72
+ assert.equal(config.provider.environment, '${file(./serverless.function-env.cjs):provider}');
73
+ const functionEnv = require('../serverless.function-env.cjs');
74
+ const savedEnv = { ...process.env };
75
+ try {
76
+ for (const bundle of ['', 'lca-api/dev/runtime-secrets']) {
77
+ process.env.RUNTIME_SECRETS_ID = bundle;
78
+ process.env.SA_SEED_LOCAL_KEK = 'must-not-reach-lambda';
79
+ const provider = functionEnv.provider({ options: { stage: 'dev' } });
80
+ assert.deepEqual(provider.SA_SEED_KMS_KEY_ARN, keyArn);
81
+ // Deployed functions use KMS only; the local KEK is for offline development.
82
+ for (const env of [provider, functionEnv.api(), functionEnv.escrow()])
83
+ assert.equal(env.SA_SEED_LOCAL_KEK, undefined);
84
+ }
85
+ } finally {
86
+ process.env = savedEnv;
87
+ }
88
+ if (packaged) {
89
+ const lambda = resources.SeedMigrationLambdaFunction;
90
+ assert.equal(lambda.Properties.Timeout, 900);
91
+ assert.equal(lambda.Properties.ReservedConcurrentExecutions, 1);
92
+ assert.deepEqual(lambda.Properties.Role, roleArn);
93
+ assert.deepEqual(lambda.Properties.Environment.Variables.SA_SEED_KMS_KEY_ARN, keyArn);
94
+ for (const name of ['Api', 'Trpc']) {
95
+ assert.deepEqual(resources[`${name}LambdaFunction`].Properties.Role, roleArn);
96
+ }
97
+ for (const name of ['Swagger', 'DidWeb']) {
98
+ assert.deepEqual(resources[`${name}LambdaFunction`].Properties.Role, {
99
+ 'Fn::GetAtt': ['IamRoleLambdaExecution', 'Arn'],
100
+ });
101
+ }
102
+ for (const resource of Object.values(resources)) {
103
+ if (resource.Type === 'AWS::Lambda::Permission' || resource.Type === 'AWS::Lambda::Url') {
104
+ assert(!JSON.stringify(resource).includes('SeedMigrationLambdaFunction'));
105
+ }
106
+ }
107
+ }
108
+
109
+ // Validate the entire dependency graph, including generated role/function resources when packaged.
110
+ const references = value => {
111
+ const found = new Set();
112
+ const visit = node => {
113
+ if (!node || typeof node !== 'object') return;
114
+ if (typeof node.Ref === 'string') found.add(node.Ref);
115
+ const attribute = node['Fn::GetAtt'];
116
+ if (attribute) found.add(Array.isArray(attribute) ? attribute[0] : attribute.split('.')[0]);
117
+ const sub = node['Fn::Sub'];
118
+ const source = Array.isArray(sub) ? sub[0] : sub;
119
+ if (typeof source === 'string') {
120
+ for (const match of source.matchAll(/\$\{([^}.]+)(?:\.[^}]+)?\}/g)) found.add(match[1]);
121
+ }
122
+ for (const child of Object.values(node)) visit(child);
123
+ };
124
+ visit(value);
125
+ for (const name of [].concat(value.DependsOn ?? [])) found.add(name);
126
+ return [...found].filter(name => name in resources);
127
+ };
128
+ const finished = new Set();
129
+ const visit = (name, active = new Set()) => {
130
+ assert(!active.has(name), `Circular CloudFormation dependency at ${name}`);
131
+ if (finished.has(name)) return;
132
+ const next = new Set(active).add(name);
133
+ for (const dependency of references(resources[name])) visit(dependency, next);
134
+ finished.add(name);
135
+ };
136
+ for (const name of Object.keys(resources)) visit(name);
137
+ console.log(
138
+ `Seed encryption infrastructure checks passed (${packaged ? 'packaged template' : 'source config'}, ${finished.size} resources).`
139
+ );
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import Redis from 'ioredis';
5
+
6
+ const LEGACY_SEED_PATTERN = 'sa|*';
7
+
8
+ /** Read key names only. SCAN can repeat keys, so only a zero count proves absence. */
9
+ export const countLegacySigningAuthorityKeys = async redis => {
10
+ let cursor = '0';
11
+ let matches = 0;
12
+ const deadline = Date.now() + 60_000;
13
+ do {
14
+ if (Date.now() >= deadline) throw new Error('Redis audit timed out.');
15
+ const [nextCursor, keys] = await redis.scan(
16
+ cursor,
17
+ 'MATCH',
18
+ LEGACY_SEED_PATTERN,
19
+ 'COUNT',
20
+ 500
21
+ );
22
+ matches += keys.length;
23
+ cursor = nextCursor;
24
+ } while (cursor !== '0');
25
+ return matches;
26
+ };
27
+
28
+ /** Require an explicit real endpoint; never fall back to an empty in-memory cache. */
29
+ export const getAuditRedisOptions = (env = process.env) => {
30
+ const port = Number(env.REDIS_PORT);
31
+ const db = Number(env.REDIS_DB ?? '0');
32
+ if (
33
+ !env.REDIS_HOST?.trim() ||
34
+ !Number.isInteger(port) ||
35
+ port < 1 ||
36
+ port > 65535 ||
37
+ !Number.isInteger(db) ||
38
+ db < 0 ||
39
+ (env.REDIS_TLS !== undefined && !['true', 'false'].includes(env.REDIS_TLS))
40
+ ) {
41
+ throw new Error(
42
+ 'Set REDIS_HOST and REDIS_PORT; REDIS_DB defaults to 0 and REDIS_TLS accepts true or false.'
43
+ );
44
+ }
45
+ return {
46
+ host: env.REDIS_HOST,
47
+ port,
48
+ db,
49
+ username: env.REDIS_USERNAME,
50
+ password: env.REDIS_PASSWORD,
51
+ ...(env.REDIS_TLS === 'true' ? { tls: {} } : {}),
52
+ lazyConnect: true,
53
+ connectTimeout: 2_000,
54
+ commandTimeout: 5_000,
55
+ maxRetriesPerRequest: 0,
56
+ retryStrategy: () => null,
57
+ };
58
+ };
59
+
60
+ const main = async () => {
61
+ let redis;
62
+ try {
63
+ const options = getAuditRedisOptions();
64
+ redis = new Redis(options);
65
+ // Driver errors can include endpoint/auth details; report only the sanitized failure below.
66
+ redis.on('error', () => undefined);
67
+ await redis.connect();
68
+ const legacyKeyMatches = await countLegacySigningAuthorityKeys(redis);
69
+ console.log(
70
+ JSON.stringify({
71
+ database: options.db,
72
+ pattern: LEGACY_SEED_PATTERN,
73
+ legacyKeyMatches,
74
+ auditedAt: new Date().toISOString(),
75
+ })
76
+ );
77
+ if (legacyKeyMatches > 0) {
78
+ console.error(
79
+ 'Legacy signing-authority cache keys remain. Follow the seed-encryption runbook before completing rollout.'
80
+ );
81
+ process.exitCode = 1;
82
+ }
83
+ } catch {
84
+ console.error(
85
+ 'Redis audit failed. Check REDIS_HOST, REDIS_PORT, optional REDIS_DB/TLS/auth settings, and private network access. No clean result was established.'
86
+ );
87
+ process.exitCode = 1;
88
+ } finally {
89
+ redis?.disconnect();
90
+ }
91
+ };
92
+
93
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,227 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Pure-logic test only: the script module (and, transitively, src/models/UserKey.ts)
4
+ // imports the real Mongo connection module for the CLI's live-query path, which
5
+ // requires a real Mongo global outside test-mode integration setup. Stub it so
6
+ // importing the module here never needs one — same pattern as
7
+ // src/models/UserKey.escrowPin.test.ts, but by relative path: `scripts/` sits
8
+ // outside this package's tsconfig `include`, so path-alias mocks (`@mongo`)
9
+ // resolved from here don't reliably match the alias-resolved import inside
10
+ // src/models/UserKey.ts, even though both point at the same file.
11
+ vi.mock('../src/mongo', () => ({ default: {} }));
12
+
13
+ import {
14
+ buildCloudWatchMetricPayload,
15
+ buildReport,
16
+ activeModeFromEnv,
17
+ classifyEnclaveKeyIds,
18
+ CLOUDWATCH_NAMESPACE,
19
+ type RawEscrowCounts,
20
+ } from './escrow-blob-mode-report';
21
+
22
+ const emptyRaw: RawEscrowCounts = {
23
+ totalUserKeys: 0,
24
+ enclaveModeCounts: [],
25
+ enclaveKeyIdCounts: [],
26
+ escrowPinEnabledCount: 0,
27
+ optedOutCount: 0,
28
+ pendingHoldsByPolicy: [],
29
+ };
30
+
31
+ describe('activeModeFromEnv', () => {
32
+ it('maps remote to nitro', () => {
33
+ expect(activeModeFromEnv('remote')).toBe('nitro');
34
+ });
35
+
36
+ it('maps software, undefined, and anything else to software', () => {
37
+ expect(activeModeFromEnv('software')).toBe('software');
38
+ expect(activeModeFromEnv(undefined)).toBe('software');
39
+ expect(activeModeFromEnv('bogus')).toBe('software');
40
+ });
41
+ });
42
+
43
+ describe('buildReport', () => {
44
+ const now = new Date('2026-09-25T00:00:00.000Z');
45
+
46
+ it('reports zero counts for known modes/policies with no data', () => {
47
+ const report = buildReport(emptyRaw, 'nitro', now);
48
+
49
+ expect(report.generatedAt).toBe(now.toISOString());
50
+ expect(report.activeEnclaveMode).toBe('nitro');
51
+ expect(report.byEnclaveMode).toEqual({ software: 0, nitro: 0 });
52
+ expect(report.pendingHoldsByReleasePolicy).toEqual({ hold: 0, pin: 0 });
53
+ expect(report.totals).toEqual({
54
+ userKeys: 0,
55
+ escrowBlobs: 0,
56
+ escrowPinEnabled: 0,
57
+ optedOut: 0,
58
+ pendingHolds: 0,
59
+ });
60
+ expect(report.reseal).toEqual({ activeCount: 0, staleCount: 0, staleRatePercent: 0 });
61
+ });
62
+
63
+ it('computes the re-seal split for a normal software/nitro mix', () => {
64
+ const raw: RawEscrowCounts = {
65
+ ...emptyRaw,
66
+ totalUserKeys: 20,
67
+ enclaveModeCounts: [
68
+ { mode: 'software', count: 7 },
69
+ { mode: 'nitro', count: 3 },
70
+ ],
71
+ escrowPinEnabledCount: 2,
72
+ optedOutCount: 1,
73
+ };
74
+
75
+ const report = buildReport(raw, 'nitro', now);
76
+
77
+ expect(report.byEnclaveMode).toEqual({ software: 7, nitro: 3 });
78
+ expect(report.totals.escrowBlobs).toBe(10);
79
+ expect(report.reseal).toEqual({ activeCount: 3, staleCount: 7, staleRatePercent: 70 });
80
+ });
81
+
82
+ it('surfaces an unknown enclaveMode value instead of dropping it, and counts it as stale', () => {
83
+ const raw: RawEscrowCounts = {
84
+ ...emptyRaw,
85
+ enclaveModeCounts: [
86
+ { mode: 'software', count: 1 },
87
+ { mode: 'nitro', count: 1 },
88
+ { mode: 'legacy-v0', count: 2 },
89
+ ],
90
+ };
91
+
92
+ const report = buildReport(raw, 'nitro', now);
93
+
94
+ expect(report.byEnclaveMode).toEqual({ software: 1, nitro: 1, 'legacy-v0': 2 });
95
+ expect(report.totals.escrowBlobs).toBe(4);
96
+ // activeCount only counts the active mode bucket; the unknown mode is stale too.
97
+ expect(report.reseal).toEqual({ activeCount: 1, staleCount: 3, staleRatePercent: 75 });
98
+ });
99
+
100
+ it('rounds the stale rate to 2 decimal places', () => {
101
+ const raw: RawEscrowCounts = {
102
+ ...emptyRaw,
103
+ enclaveModeCounts: [
104
+ { mode: 'software', count: 1 },
105
+ { mode: 'nitro', count: 2 },
106
+ ],
107
+ };
108
+
109
+ const report = buildReport(raw, 'nitro', now);
110
+
111
+ // stale = 1, total = 3 -> 33.333...% rounds to 33.33
112
+ expect(report.reseal.staleRatePercent).toBe(33.33);
113
+ });
114
+
115
+ it('passes through enclaveKeyId counts and an unknown release policy value', () => {
116
+ const raw: RawEscrowCounts = {
117
+ ...emptyRaw,
118
+ enclaveKeyIdCounts: [
119
+ { keyId: 'key-a', count: 4 },
120
+ { keyId: 'key-b', count: 1 },
121
+ ],
122
+ pendingHoldsByPolicy: [
123
+ { policy: 'hold', count: 3 },
124
+ { policy: 'weird-future-policy', count: 1 },
125
+ ],
126
+ };
127
+
128
+ const report = buildReport(raw, 'software', now);
129
+
130
+ expect(report.byEnclaveKeyId).toEqual({ 'key-a': 4, 'key-b': 1 });
131
+ expect(report.pendingHoldsByReleasePolicy).toEqual({
132
+ hold: 3,
133
+ pin: 0,
134
+ 'weird-future-policy': 1,
135
+ });
136
+ expect(report.totals.pendingHolds).toBe(4);
137
+ });
138
+ });
139
+
140
+ describe('buildCloudWatchMetricPayload', () => {
141
+ it('emits EscrowBlobs per mode dimension plus a single EscrowBlobsStale datum', () => {
142
+ const report = buildReport(
143
+ {
144
+ ...emptyRaw,
145
+ enclaveModeCounts: [
146
+ { mode: 'software', count: 5 },
147
+ { mode: 'nitro', count: 2 },
148
+ ],
149
+ },
150
+ 'nitro',
151
+ new Date('2026-09-25T00:00:00.000Z')
152
+ );
153
+
154
+ const payload = buildCloudWatchMetricPayload(report);
155
+
156
+ expect(payload.Namespace).toBe(CLOUDWATCH_NAMESPACE);
157
+ expect(payload.MetricData).toHaveLength(3);
158
+ expect(payload.MetricData).toEqual(
159
+ expect.arrayContaining([
160
+ {
161
+ MetricName: 'EscrowBlobs',
162
+ Value: 5,
163
+ Unit: 'Count',
164
+ Timestamp: report.generatedAt,
165
+ Dimensions: [{ Name: 'EnclaveMode', Value: 'software' }],
166
+ },
167
+ {
168
+ MetricName: 'EscrowBlobs',
169
+ Value: 2,
170
+ Unit: 'Count',
171
+ Timestamp: report.generatedAt,
172
+ Dimensions: [{ Name: 'EnclaveMode', Value: 'nitro' }],
173
+ },
174
+ {
175
+ MetricName: 'EscrowBlobsStale',
176
+ Value: 5,
177
+ Unit: 'Count',
178
+ Timestamp: report.generatedAt,
179
+ },
180
+ ])
181
+ );
182
+ });
183
+
184
+ it('still emits a zero-value EscrowBlobsStale datum when there is no data at all', () => {
185
+ const report = buildReport(emptyRaw, 'software', new Date('2026-09-25T00:00:00.000Z'));
186
+
187
+ const payload = buildCloudWatchMetricPayload(report);
188
+
189
+ const stale = payload.MetricData.find(datum => datum.MetricName === 'EscrowBlobsStale');
190
+ expect(stale?.Value).toBe(0);
191
+ });
192
+ });
193
+
194
+ describe('classifyEnclaveKeyIds (P9.3)', () => {
195
+ it('splits counts into current/previous/unknown, listing unknown keyIds sorted', () => {
196
+ const classification = classifyEnclaveKeyIds(
197
+ { 'key-2': 10, 'key-1': 4, 'key-0': 1, unknown: 1 },
198
+ 'key-2',
199
+ ['key-1', 'key-0']
200
+ );
201
+
202
+ expect(classification).toEqual({
203
+ current: 10,
204
+ previous: 5,
205
+ unknown: 1,
206
+ unknownKeyIds: ['unknown'],
207
+ });
208
+ });
209
+
210
+ it('reports zero previous/unknown when every blob is already on the current key', () => {
211
+ const classification = classifyEnclaveKeyIds({ 'key-2': 3 }, 'key-2', ['key-1']);
212
+
213
+ expect(classification).toEqual({ current: 3, previous: 0, unknown: 0, unknownKeyIds: [] });
214
+ });
215
+
216
+ it('treats an empty previous-key list as everything non-current being unknown', () => {
217
+ const classification = classifyEnclaveKeyIds(
218
+ { 'key-2': 2, 'retired-1': 1, 'retired-2': 1 },
219
+ 'key-2',
220
+ []
221
+ );
222
+
223
+ expect(classification.previous).toBe(0);
224
+ expect(classification.unknown).toBe(2);
225
+ expect(classification.unknownKeyIds).toEqual(['retired-1', 'retired-2']);
226
+ });
227
+ });