@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,254 @@
1
+ import { timingSafeEqual } from 'crypto';
2
+ import { escrowBlobIdentity } from './blobIdentity';
3
+ import type { EnclaveCreateHoldInput, EscrowHoldRecord, CancelHoldRequest } from './types';
4
+ import {
5
+ decryptEscrowBlob,
6
+ encryptEscrowBlob,
7
+ sealEscrowRelease,
8
+ parseEscrowEnvelope,
9
+ type EscrowBlobPlaintext,
10
+ } from '@learncard/sss-key-manager';
11
+ import {
12
+ EscrowBlobError,
13
+ EscrowPolicyError,
14
+ EscrowPinMismatchError,
15
+ EscrowUnavailableError,
16
+ type EscrowEnclave,
17
+ type EnclaveAttestation,
18
+ type VerifyEscrowBlobInput,
19
+ type VerifyEscrowBlobResult,
20
+ type ReleaseRequest,
21
+ type ReleaseResult,
22
+ type CarryPinVerifierInput,
23
+ type RewrapEscrowBlobInput,
24
+ } from './types';
25
+
26
+ export interface SoftwareEnclaveConfig {
27
+ privateKeys: Record<string, string>;
28
+ activeKeyId: string;
29
+ /** Supplied by the factory to match its configured host-trusted waiting period. */
30
+ holdDurationMs?: number;
31
+ }
32
+
33
+ /** In-process backend for development; host time is not an attested clock. */
34
+ export class SoftwareEnclave implements EscrowEnclave {
35
+ private readonly privateKeys: ReadonlyMap<string, string>;
36
+ private readonly activeKeyId: string;
37
+ private readonly holdDurationMs: number;
38
+ constructor(config: SoftwareEnclaveConfig) {
39
+ this.privateKeys = new Map(Object.entries(config.privateKeys));
40
+ this.activeKeyId = config.activeKeyId;
41
+ if (!this.privateKeys.has(this.activeKeyId)) throw new EscrowUnavailableError();
42
+ // Match the factory configuration without a circular import; default is seven days.
43
+ this.holdDurationMs = config.holdDurationMs ?? 7 * 24 * 60 * 60 * 1000;
44
+ }
45
+ async createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }> {
46
+ const blob = await this.decrypt(input.envelope);
47
+ if (
48
+ blob.did !== input.expectedDid ||
49
+ blob.shareVersion !== input.expectedShareVersion ||
50
+ !Number.isInteger(input.enrollmentEpoch) ||
51
+ input.enrollmentEpoch <= 0 ||
52
+ (input.releasePolicy === 'pin' && !blob.pinVerifier)
53
+ )
54
+ throw new EscrowPolicyError();
55
+ const blobHash = escrowBlobIdentity(input.envelope);
56
+ const now = Date.now();
57
+ return {
58
+ holdRecord: {
59
+ hold: {
60
+ holdId: input.holdId,
61
+ did: blob.did,
62
+ shareVersion: input.expectedShareVersion,
63
+ blobHash,
64
+ enrollmentEpoch: input.enrollmentEpoch,
65
+ releasePolicy: input.releasePolicy,
66
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
67
+ createdLo: now,
68
+ createdHi: now,
69
+ policyVersion: 1,
70
+ // Non-cryptographic placeholder: software mode trusts the host entirely.
71
+ signature: 'software-mode-unsigned',
72
+ },
73
+ holdDurationMs: input.releasePolicy === 'pin' ? 0 : this.holdDurationMs,
74
+ ledgerSeq: 0,
75
+ },
76
+ };
77
+ }
78
+ async cancelHold(input: CancelHoldRequest): Promise<void> {
79
+ const blob = await this.decrypt(input.envelope);
80
+ if (
81
+ blob.did !== input.expectedDid ||
82
+ blob.did !== input.hold.hold.did ||
83
+ input.clientEphemeralPublicKey !== input.hold.hold.clientEphemeralPublicKey
84
+ )
85
+ throw new EscrowPolicyError();
86
+ // Development only: Mongo owns cancellation; there is no software enclave ledger.
87
+ }
88
+ async getAttestation(_nonce?: Uint8Array): Promise<EnclaveAttestation> {
89
+ try {
90
+ const subtle = globalThis.crypto.subtle;
91
+ const key = await subtle.importKey(
92
+ 'pkcs8',
93
+ new Uint8Array(Buffer.from(this.privateKeys.get(this.activeKeyId)!, 'base64')),
94
+ { name: 'ECDH', namedCurve: 'P-256' },
95
+ true,
96
+ ['deriveBits']
97
+ );
98
+ // WebCrypto cannot export a private CryptoKey as SPKI. Export its public
99
+ // coordinates as JWK, import a public-only key, then export that as SPKI.
100
+ const jwk = await subtle.exportKey('jwk', key);
101
+ const publicKeyObject = await subtle.importKey(
102
+ 'jwk',
103
+ { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
104
+ { name: 'ECDH', namedCurve: 'P-256' },
105
+ true,
106
+ []
107
+ );
108
+ const publicKey = Buffer.from(await subtle.exportKey('spki', publicKeyObject)).toString(
109
+ 'base64'
110
+ );
111
+ const description = {
112
+ mode: 'software' as const,
113
+ keyId: this.activeKeyId,
114
+ // P9.1 software-enclave equivalent: every OTHER configured key is
115
+ // already decrypt-eligible in `decrypt()` below (keyed lookup by
116
+ // envelope keyId, not restricted to `activeKeyId`) — this just
117
+ // advertises that existing behaviour the same way the Nitro
118
+ // backend advertises its `ESCROW_PREVIOUS_KEY_IDS`.
119
+ previousKeyIds: [...this.privateKeys.keys()].filter(id => id !== this.activeKeyId),
120
+ publicKey,
121
+ issuedAt: new Date().toISOString(),
122
+ };
123
+ return {
124
+ ...description,
125
+ measurements: {},
126
+ document: Buffer.from(JSON.stringify(description)).toString('base64'),
127
+ };
128
+ } catch {
129
+ throw new EscrowUnavailableError();
130
+ }
131
+ }
132
+
133
+ private async decrypt(envelope: unknown): Promise<EscrowBlobPlaintext> {
134
+ try {
135
+ const parsed = parseEscrowEnvelope(envelope);
136
+ const key = this.privateKeys.get(parsed.keyId);
137
+ if (!key) throw new EscrowBlobError();
138
+ return await decryptEscrowBlob(parsed, key);
139
+ } catch {
140
+ throw new EscrowBlobError();
141
+ }
142
+ }
143
+
144
+ async verifyEscrowBlob(input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> {
145
+ const blob = await this.decrypt(input.envelope);
146
+ if (blob.did !== input.expectedDid || blob.shareVersion !== input.expectedShareVersion) {
147
+ return {
148
+ ok: false,
149
+ hasPin: !!blob.pinVerifier,
150
+ reason: 'Escrow recovery is not permitted.',
151
+ };
152
+ }
153
+ return { ok: true, hasPin: !!blob.pinVerifier };
154
+ }
155
+
156
+ /**
157
+ * Transfer only the verifier; recovery material remains sealed within the enclave.
158
+ *
159
+ * `input.sourceEnrollmentEpoch` is intentionally unused: software mode has no
160
+ * enclave ledger to reset in the first place, so there is no epoch-scoped PIN
161
+ * attempt budget to carry. The PIN attempt budget here is `escrowPin.failedAttempts`
162
+ * / `verifiedFailedAttempts` in MongoDB (see routes/escrow.ts), which the enroll
163
+ * route already copies forward unchanged on every carry, independent of
164
+ * `enrollmentEpoch` — so software mode never had the epoch-reset bug this field
165
+ * exists to fix in remote/nitro mode.
166
+ */
167
+ async carryPinVerifier(input: CarryPinVerifierInput) {
168
+ const source = await this.decrypt(input.sourceEnvelope);
169
+ const target = await this.decrypt(input.targetEnvelope);
170
+ if (
171
+ source.did !== input.expectedDid ||
172
+ target.did !== input.expectedDid ||
173
+ source.shareVersion !== input.sourceShareVersion ||
174
+ target.shareVersion !== input.targetShareVersion ||
175
+ target.shareVersion <= source.shareVersion ||
176
+ !source.pinVerifier ||
177
+ target.pinVerifier
178
+ )
179
+ throw new EscrowBlobError();
180
+ const attestation = await this.getAttestation();
181
+ return {
182
+ envelope: await encryptEscrowBlob(
183
+ { ...target, pinVerifier: source.pinVerifier },
184
+ attestation.publicKey,
185
+ attestation.keyId
186
+ ),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Software-mode equivalent of the Nitro enclave's `rewrap_escrow_blob`
192
+ * (P9.3): moves a blob sealed under any OTHER configured key onto the
193
+ * active key, unchanged otherwise. Mirrors its refusal shape — an
194
+ * envelope already under `activeKeyId` has nothing to migrate, and
195
+ * `decrypt()` already throws `EscrowBlobError` for a keyId this backend
196
+ * was never configured with, so both collapse into the same generic
197
+ * error. `sourceEnrollmentEpoch` is unused for the same reason
198
+ * `carryPinVerifier` ignores it above: no enclave ledger here, so no
199
+ * epoch-scoped PIN attempt budget to carry.
200
+ */
201
+ async rewrapEscrowBlob(input: RewrapEscrowBlobInput) {
202
+ const parsed = parseEscrowEnvelope(input.envelope);
203
+ if (parsed.keyId === this.activeKeyId) throw new EscrowBlobError();
204
+ const blob = await this.decrypt(input.envelope);
205
+ if (blob.did !== input.expectedDid || blob.shareVersion !== input.expectedShareVersion)
206
+ throw new EscrowBlobError();
207
+ const attestation = await this.getAttestation();
208
+ return {
209
+ envelope: await encryptEscrowBlob(blob, attestation.publicKey, attestation.keyId),
210
+ };
211
+ }
212
+
213
+ async releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult> {
214
+ const { hold } = input.hold;
215
+ if (
216
+ input.clientEphemeralPublicKey !== hold.clientEphemeralPublicKey ||
217
+ !((input.now ?? new Date()).getTime() >= hold.createdHi + input.hold.holdDurationMs)
218
+ )
219
+ throw new EscrowPolicyError();
220
+ const blob = await this.decrypt(input.envelope);
221
+ if (
222
+ blob.did !== input.expectedDid ||
223
+ blob.did !== hold.did ||
224
+ blob.shareVersion !== hold.shareVersion
225
+ )
226
+ throw new EscrowPolicyError();
227
+ if (hold.releasePolicy === 'pin') {
228
+ if (!blob.pinVerifier || !input.pinProof) throw new EscrowPolicyError();
229
+ const expected = Buffer.from(blob.pinVerifier, 'hex');
230
+ const actual = Buffer.from(input.pinProof, 'hex');
231
+ if (
232
+ !/^[0-9a-f]{64}$/i.test(input.pinProof) ||
233
+ actual.length !== expected.length ||
234
+ !timingSafeEqual(actual, expected)
235
+ )
236
+ throw new EscrowPinMismatchError();
237
+ }
238
+ try {
239
+ return {
240
+ sealed: await sealEscrowRelease(
241
+ {
242
+ recoveryShare: blob.recoveryShare,
243
+ did: blob.did,
244
+ shareVersion: blob.shareVersion,
245
+ holdId: hold.holdId,
246
+ },
247
+ input.clientEphemeralPublicKey
248
+ ),
249
+ };
250
+ } catch {
251
+ throw new EscrowBlobError();
252
+ }
253
+ }
254
+ }
@@ -0,0 +1,122 @@
1
+ import type { EscrowEnvelope } from '@learncard/sss-key-manager';
2
+ // Hold records are independent of host Mongo status and deadlines.
3
+
4
+ export interface EnclaveAttestation {
5
+ mode: 'software' | 'nitro';
6
+ keyId: string;
7
+ /** P9.1: retired keyIds the enclave still accepts for decrypt only (never
8
+ * for sealing output). Empty/absent means no previous key is configured. */
9
+ previousKeyIds?: string[];
10
+ publicKey: string;
11
+ measurements: { imageSha384?: string; pcr0?: string; pcr1?: string; pcr2?: string };
12
+ document: string;
13
+ issuedAt: string;
14
+ }
15
+ export interface EscrowHoldRecord {
16
+ [key: string]: unknown;
17
+ hold: {
18
+ [key: string]: unknown;
19
+ holdId: string;
20
+ did: string;
21
+ shareVersion: number;
22
+ blobHash: string;
23
+ enrollmentEpoch: number;
24
+ releasePolicy: 'hold' | 'pin';
25
+ clientEphemeralPublicKey: string;
26
+ createdLo: number;
27
+ createdHi: number;
28
+ policyVersion: number;
29
+ signature: string;
30
+ };
31
+ holdDurationMs: number;
32
+ ledgerSeq: number;
33
+ }
34
+ export interface EnclaveCreateHoldInput {
35
+ envelope: EscrowEnvelope;
36
+ holdId: string;
37
+ expectedDid: string;
38
+ expectedShareVersion: number;
39
+ enrollmentEpoch: number;
40
+ releasePolicy: 'hold' | 'pin';
41
+ clientEphemeralPublicKey: string;
42
+ }
43
+ export interface CancelHoldRequest {
44
+ envelope: EscrowEnvelope;
45
+ hold: EscrowHoldRecord;
46
+ clientEphemeralPublicKey: string;
47
+ expectedDid: string;
48
+ }
49
+ export interface VerifyEscrowBlobInput {
50
+ envelope: EscrowEnvelope;
51
+ expectedDid: string;
52
+ expectedShareVersion: number;
53
+ }
54
+ export type VerifyEscrowBlobResult =
55
+ { ok: true; hasPin: boolean } | { ok: false; hasPin: boolean; reason: string };
56
+ export interface ReleaseRequest extends CancelHoldRequest {
57
+ /** Development-only host clock; never forwarded by the remote backend. */
58
+ now?: Date;
59
+ pinProof?: string;
60
+ }
61
+ export interface ReleaseResult {
62
+ sealed: EscrowEnvelope;
63
+ }
64
+ export interface CarryPinVerifierInput {
65
+ sourceEnvelope: EscrowEnvelope;
66
+ targetEnvelope: EscrowEnvelope;
67
+ expectedDid: string;
68
+ sourceShareVersion: number;
69
+ targetShareVersion: number;
70
+ /** Signed source-record binding; blob hash, not epoch, selects the chain. */
71
+ sourceEnrollmentEpoch: number;
72
+ /** Signed binding for the output blob's first Carried record. */
73
+ targetEnrollmentEpoch: number;
74
+ }
75
+ export interface RewrapEscrowBlobInput {
76
+ envelope: EscrowEnvelope;
77
+ expectedDid: string;
78
+ expectedShareVersion: number;
79
+ /** Source and destination record binding. Rewrap preserves this epoch. */
80
+ sourceEnrollmentEpoch: number;
81
+ }
82
+ export interface EscrowEnclave {
83
+ /** `nonce` binds a nitro attestation to one client-generated challenge (64 hex chars,
84
+ * decoded to bytes); the software backend has no freshness story and ignores it. */
85
+ getAttestation(nonce?: Uint8Array): Promise<EnclaveAttestation>;
86
+ verifyEscrowBlob(input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult>;
87
+ carryPinVerifier(input: CarryPinVerifierInput): Promise<{ envelope: EscrowEnvelope }>;
88
+ /** P9.3: migrates a copy sealed under a recognised PREVIOUS key onto the
89
+ * CURRENT key (same did/shareVersion/recoveryShare/pinVerifier), so a
90
+ * previous key can eventually be retired. Refuses (generic
91
+ * `EscrowBlobError`) unless the envelope's own keyId is a recognised
92
+ * previous key — never the current key (nothing to migrate) or an
93
+ * unknown one. */
94
+ rewrapEscrowBlob(input: RewrapEscrowBlobInput): Promise<{ envelope: EscrowEnvelope }>;
95
+ releaseEscrow(input: ReleaseRequest): Promise<ReleaseResult>;
96
+ createHold(input: EnclaveCreateHoldInput): Promise<{ holdRecord: EscrowHoldRecord }>;
97
+ cancelHold(input: CancelHoldRequest): Promise<void>;
98
+ }
99
+ export class EscrowPolicyError extends Error {
100
+ constructor() {
101
+ super('Escrow recovery is not permitted.');
102
+ this.name = 'EscrowPolicyError';
103
+ }
104
+ }
105
+ export class EscrowPinMismatchError extends Error {
106
+ constructor() {
107
+ super('Incorrect PIN.');
108
+ this.name = 'EscrowPinMismatchError';
109
+ }
110
+ }
111
+ export class EscrowBlobError extends Error {
112
+ constructor() {
113
+ super('Invalid escrow payload.');
114
+ this.name = 'EscrowBlobError';
115
+ }
116
+ }
117
+ export class EscrowUnavailableError extends Error {
118
+ constructor() {
119
+ super('Escrow recovery is not available.');
120
+ this.name = 'EscrowUnavailableError';
121
+ }
122
+ }
@@ -0,0 +1,51 @@
1
+ export interface SigningAuthoritySeedIdentity {
2
+ _id: string;
3
+ ownerDid: string;
4
+ name: string;
5
+ did?: string;
6
+ }
7
+
8
+ export interface EncryptedSigningAuthoritySeed {
9
+ encryptedSeed: string;
10
+ encryptedDek: string;
11
+ keyVersion: 'kms-v1' | 'local-v1';
12
+ }
13
+
14
+ /** Untrusted persisted fields are validated before use, including on cache hits. */
15
+ export interface SigningAuthoritySeedRecord {
16
+ _id?: unknown;
17
+ ownerDid: string;
18
+ name: string;
19
+ did?: string;
20
+ seed?: string;
21
+ encryptedSeed?: unknown;
22
+ encryptedDek?: unknown;
23
+ keyVersion?: unknown;
24
+ }
25
+
26
+ export interface SeedEncryptionConfig {
27
+ kmsKeyArn?: string;
28
+ localKek?: string;
29
+ allowLocal: boolean;
30
+ }
31
+
32
+ export interface SeedEncryption {
33
+ encrypt: (
34
+ seed: string,
35
+ identity: SigningAuthoritySeedIdentity
36
+ ) => Promise<EncryptedSigningAuthoritySeed>;
37
+ decrypt: (
38
+ envelope: EncryptedSigningAuthoritySeed,
39
+ identity: SigningAuthoritySeedIdentity
40
+ ) => Promise<string>;
41
+ }
42
+
43
+ export type SeedEncryptionFailure =
44
+ | 'invalid_record'
45
+ | 'invalid_envelope'
46
+ | 'configuration'
47
+ | 'legacy_disabled'
48
+ | 'authentication_failed'
49
+ | 'kms_access_denied'
50
+ | 'kms_unavailable'
51
+ | 'kms_failed';
@@ -0,0 +1,61 @@
1
+ import type { Document, ObjectId } from 'mongodb';
2
+ import type { EncryptedSigningAuthoritySeed } from './seed-encryption';
3
+
4
+ export type SeedMigrationPhase = 'dry-run' | 'prepare' | 'verify' | 'purge';
5
+
6
+ export interface SeedMigrationRequest {
7
+ phase: SeedMigrationPhase;
8
+ batchSize?: number;
9
+ }
10
+
11
+ export interface SeedMigrationCounts {
12
+ total: number;
13
+ encrypted: number;
14
+ legacyOnly: number;
15
+ malformed: number;
16
+ plaintextRemaining: number;
17
+ }
18
+
19
+ export interface SeedMigrationResult {
20
+ phase: SeedMigrationPhase;
21
+ done: boolean;
22
+ processed: number;
23
+ counts: SeedMigrationCounts;
24
+ /** False for an intermediate batch: counts are the last full reconciliation snapshot. */
25
+ countsReconciled: boolean;
26
+ /** A final sweep found work inserted/changed behind the saved cursor. */
27
+ rescanRequired?: boolean;
28
+ }
29
+
30
+ /** Read the historical Mongo shape without coercing BSON IDs or changing metadata. */
31
+ export interface MigrationSigningAuthority extends Document {
32
+ _id: string | ObjectId;
33
+ ownerDid: string;
34
+ name: string;
35
+ did?: string;
36
+ seed?: string;
37
+ }
38
+
39
+ export interface SeedMigrationState {
40
+ _id: string;
41
+ leaseOwner?: string;
42
+ leaseExpiresAt?: Date;
43
+ phase?: SeedMigrationPhase;
44
+ epoch?: string;
45
+ verifiedEpoch?: string;
46
+ status?: 'running' | 'complete' | 'failed';
47
+ processed?: number;
48
+ counts?: SeedMigrationCounts;
49
+ updatedAt?: Date;
50
+ /** Last processed BSON ID, scoped to phase/epoch; final reconciliation rechecks earlier IDs. */
51
+ cursor?: string | ObjectId;
52
+ }
53
+
54
+ /** Ciphertext-only receipt: exact comparisons make changed rows pending during the final sweep. */
55
+ export interface SeedMigrationReceipt extends EncryptedSigningAuthoritySeed {
56
+ _id: string | ObjectId;
57
+ epoch: string;
58
+ ownerDid: string;
59
+ name: string;
60
+ did: string | null;
61
+ }