@learncard/lca-api-service 1.4.0 → 1.5.1

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 (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  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/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,263 @@
1
+ import { Agent } from 'node:https';
2
+ import axios from 'axios';
3
+ import { z } from 'zod';
4
+ import {
5
+ ESCROW_ALGORITHM,
6
+ ESCROW_ENVELOPE_VERSION,
7
+ type EscrowEnvelope,
8
+ } from '@learncard/sss-key-manager';
9
+ import {
10
+ EscrowBlobError,
11
+ EscrowPinMismatchError,
12
+ EscrowPolicyError,
13
+ EscrowUnavailableError,
14
+ type CarryPinVerifierInput,
15
+ type EnclaveAttestation,
16
+ type EscrowEnclave,
17
+ type ReleaseRequest,
18
+ type ReleaseResult,
19
+ type RewrapEscrowBlobInput,
20
+ type VerifyEscrowBlobInput,
21
+ type VerifyEscrowBlobResult,
22
+ } from './types';
23
+
24
+ // Mirrors services/escrow-enclave-app/src/wire.rs camelCase field names exactly,
25
+ // minus the vsock wire's `method` tag (the HTTP paths below take its place).
26
+ const measurementsValidator = z.object({
27
+ imageSha384: z.string().optional(),
28
+ pcr0: z.string().optional(),
29
+ pcr1: z.string().optional(),
30
+ pcr2: z.string().optional(),
31
+ });
32
+
33
+ const attestResponseValidator = z.object({
34
+ mode: z.enum(['software', 'nitro']),
35
+ keyId: z.string(),
36
+ // Optional/defaulted (not required, unlike the host's own wire.rs field) so
37
+ // a not-yet-upgraded host during a rolling deploy still parses; P9.1.
38
+ previousKeyIds: z.array(z.string()).optional().default([]),
39
+ publicKey: z.string(),
40
+ measurements: measurementsValidator,
41
+ document: z.string(),
42
+ issuedAt: z.string(),
43
+ });
44
+
45
+ const envelopeValidator = z.object({
46
+ version: z.literal(ESCROW_ENVELOPE_VERSION),
47
+ algorithm: z.literal(ESCROW_ALGORITHM),
48
+ keyId: z.string(),
49
+ ephemeralPublicKey: z.string(),
50
+ salt: z.string(),
51
+ iv: z.string(),
52
+ ciphertext: z.string(),
53
+ });
54
+
55
+ const verifyBlobResponseValidator = z.union([
56
+ z.object({ ok: z.literal(true), hasPin: z.boolean() }),
57
+ z.object({ ok: z.literal(false), hasPin: z.boolean(), reason: z.string() }),
58
+ ]);
59
+
60
+ const carryPinVerifierResponseValidator = z.object({ envelope: envelopeValidator });
61
+ const rewrapEscrowBlobResponseValidator = z.object({ envelope: envelopeValidator });
62
+
63
+ // Opaque SignedHoldRecord JSON: validate shape while preserving signed extensions.
64
+ const holdRecordValidator = z
65
+ .object({
66
+ hold: z
67
+ .object({
68
+ holdId: z.string().min(1),
69
+ did: z.string().min(1),
70
+ shareVersion: z.number().int().positive(),
71
+ blobHash: z.string().regex(/^[0-9a-f]{64}$/),
72
+ enrollmentEpoch: z.number().int().positive(),
73
+ releasePolicy: z.enum(['hold', 'pin']),
74
+ clientEphemeralPublicKey: z.string().min(1),
75
+ createdLo: z.number().int().nonnegative(),
76
+ createdHi: z.number().int().nonnegative(),
77
+ policyVersion: z.number().int().positive(),
78
+ signature: z.string().min(1),
79
+ })
80
+ .passthrough(),
81
+ holdDurationMs: z.number().int().nonnegative(),
82
+ ledgerSeq: z.number().int().nonnegative(),
83
+ })
84
+ .passthrough();
85
+ const validatedHold = (hold: ReleaseRequest['hold']): ReleaseRequest['hold'] => {
86
+ if (!holdRecordValidator.safeParse(hold).success) throw new EscrowUnavailableError();
87
+ return hold;
88
+ };
89
+ const releaseResponseValidator = z.object({ sealed: envelopeValidator });
90
+
91
+ const errorCodeValidator = z.enum([
92
+ 'policy',
93
+ 'pinMismatch',
94
+ 'blob',
95
+ 'unavailable',
96
+ 'ledger',
97
+ 'time',
98
+ ]);
99
+ const errorResponseValidator = z.object({ code: errorCodeValidator, message: z.string() });
100
+
101
+ const errorForCode = (code: z.infer<typeof errorCodeValidator>): Error => {
102
+ if (code === 'policy') return new EscrowPolicyError();
103
+ if (code === 'pinMismatch') return new EscrowPinMismatchError();
104
+ if (code === 'blob') return new EscrowBlobError();
105
+ return new EscrowUnavailableError(); // 'unavailable' | 'ledger' | 'time'
106
+ };
107
+
108
+ export interface RemoteEnclaveTransportResponse {
109
+ status: number;
110
+ data: unknown;
111
+ }
112
+
113
+ export interface RemoteEnclaveTransport {
114
+ post(
115
+ url: string,
116
+ body: unknown,
117
+ config: { headers: Record<string, string>; timeout: number }
118
+ ): Promise<RemoteEnclaveTransportResponse>;
119
+ }
120
+
121
+ /** With `caPem`, the connection trusts only that CA (system roots are not consulted). */
122
+ export const createHttpTransport = (caPem?: string): RemoteEnclaveTransport => {
123
+ const httpsAgent = caPem ? new Agent({ ca: caPem, keepAlive: true }) : undefined;
124
+ return {
125
+ post: async (url, body, config) => {
126
+ const response = await axios.post(url, body, {
127
+ ...config,
128
+ httpsAgent,
129
+ validateStatus: () => true,
130
+ });
131
+ return { status: response.status, data: response.data };
132
+ },
133
+ };
134
+ };
135
+
136
+ export interface RemoteEnclaveConfig {
137
+ /** Origin of the enclave-host parent service (P3.3), e.g. `https://escrow.internal`. */
138
+ baseUrl: string;
139
+ token: string;
140
+ timeoutMs: number;
141
+ /** Private CA pinned for this host (see enclaveTrustAnchors.ts); system roots otherwise. */
142
+ caPem?: string;
143
+ /** Injectable for tests; defaults to a real HTTP (axios) transport. */
144
+ transport?: RemoteEnclaveTransport;
145
+ }
146
+
147
+ /**
148
+ * `EscrowEnclave` backend for `ESCROW_ENCLAVE_MODE=remote`: an HTTP client to the
149
+ * enclave-host parent service fronting a real Nitro Enclave. See issues.md
150
+ * "P4.1 report" for the full contract (paths, bodies, error format).
151
+ *
152
+ * Fail-closed: any transport failure (network error, timeout, non-2xx without a
153
+ * recognized error code, or a response failing its zod schema) becomes
154
+ * `EscrowUnavailableError`. Request/response bodies (sealed envelopes, PIN
155
+ * proofs) are never logged anywhere in this module.
156
+ */
157
+ export const createRemoteEnclave = (config: RemoteEnclaveConfig): EscrowEnclave => {
158
+ const transport = config.transport ?? createHttpTransport(config.caPem);
159
+
160
+ const call = async <T>(path: string, body: unknown, validator: z.ZodType<T>): Promise<T> => {
161
+ let status: number;
162
+ let data: unknown;
163
+ try {
164
+ const response = await transport.post(`${config.baseUrl}${path}`, body, {
165
+ headers: { Authorization: `Bearer ${config.token}` },
166
+ timeout: config.timeoutMs,
167
+ });
168
+ status = response.status;
169
+ data = response.data;
170
+ } catch {
171
+ throw new EscrowUnavailableError();
172
+ }
173
+ if (status < 200 || status >= 300) {
174
+ const parsedError = errorResponseValidator.safeParse(data);
175
+ if (parsedError.success) throw errorForCode(parsedError.data.code);
176
+ throw new EscrowUnavailableError();
177
+ }
178
+ const parsed = validator.safeParse(data);
179
+ if (!parsed.success) throw new EscrowUnavailableError();
180
+ return parsed.data;
181
+ };
182
+
183
+ return {
184
+ getAttestation: (nonce?: Uint8Array): Promise<EnclaveAttestation> =>
185
+ call('/v1/attest', { nonce: Array.from(nonce ?? []) }, attestResponseValidator),
186
+ verifyEscrowBlob: (input: VerifyEscrowBlobInput): Promise<VerifyEscrowBlobResult> =>
187
+ call(
188
+ '/v1/verify-blob',
189
+ {
190
+ envelope: input.envelope,
191
+ expectedDid: input.expectedDid,
192
+ expectedShareVersion: input.expectedShareVersion,
193
+ },
194
+ verifyBlobResponseValidator
195
+ ),
196
+ carryPinVerifier: (input: CarryPinVerifierInput): Promise<{ envelope: EscrowEnvelope }> =>
197
+ call(
198
+ '/v1/carry-pin-verifier',
199
+ {
200
+ sourceEnvelope: input.sourceEnvelope,
201
+ targetEnvelope: input.targetEnvelope,
202
+ expectedDid: input.expectedDid,
203
+ sourceShareVersion: input.sourceShareVersion,
204
+ targetShareVersion: input.targetShareVersion,
205
+ sourceEnrollmentEpoch: input.sourceEnrollmentEpoch,
206
+ targetEnrollmentEpoch: input.targetEnrollmentEpoch,
207
+ },
208
+ carryPinVerifierResponseValidator
209
+ ),
210
+ rewrapEscrowBlob: (input: RewrapEscrowBlobInput): Promise<{ envelope: EscrowEnvelope }> =>
211
+ call(
212
+ '/v1/rewrap-escrow-blob',
213
+ {
214
+ envelope: input.envelope,
215
+ expectedDid: input.expectedDid,
216
+ expectedShareVersion: input.expectedShareVersion,
217
+ sourceEnrollmentEpoch: input.sourceEnrollmentEpoch,
218
+ },
219
+ rewrapEscrowBlobResponseValidator
220
+ ),
221
+ createHold: async input => ({
222
+ holdRecord: await call(
223
+ '/v1/create-hold',
224
+ {
225
+ envelope: input.envelope,
226
+ holdId: input.holdId,
227
+ expectedDid: input.expectedDid,
228
+ expectedShareVersion: input.expectedShareVersion,
229
+ enrollmentEpoch: input.enrollmentEpoch,
230
+ releasePolicy: input.releasePolicy,
231
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
232
+ },
233
+ holdRecordValidator
234
+ ),
235
+ }),
236
+ cancelHold: async input => {
237
+ await call(
238
+ '/v1/cancel-hold',
239
+ {
240
+ envelope: input.envelope,
241
+ hold: validatedHold(input.hold),
242
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
243
+ expectedDid: input.expectedDid,
244
+ },
245
+ z.object({ ok: z.literal(true) })
246
+ );
247
+ },
248
+ releaseEscrow: async (input: ReleaseRequest): Promise<ReleaseResult> => {
249
+ validatedHold(input.hold);
250
+ return call(
251
+ '/v1/release',
252
+ {
253
+ envelope: input.envelope,
254
+ hold: input.hold,
255
+ clientEphemeralPublicKey: input.clientEphemeralPublicKey,
256
+ expectedDid: input.expectedDid,
257
+ ...(input.pinProof !== undefined ? { pinProof: input.pinProof } : {}),
258
+ },
259
+ releaseResponseValidator
260
+ );
261
+ },
262
+ };
263
+ };
@@ -9,7 +9,7 @@ import {
9
9
  EscrowBlobError,
10
10
  EscrowPolicyError,
11
11
  EscrowPinMismatchError,
12
- type EscrowHoldForEnclave,
12
+ type EscrowHoldRecord,
13
13
  } from './types';
14
14
 
15
15
  describe('software enclave', () => {
@@ -58,6 +58,9 @@ describe('software enclave', () => {
58
58
  expectedDid: scenario === 'expected DID' ? 'did:key:other' : did,
59
59
  sourceShareVersion: scenario === 'source version' ? 1 : 2,
60
60
  targetShareVersion: scenario === 'target version' ? 4 : targetVersion,
61
+ targetEnrollmentEpoch: 2,
62
+ // Unused in software mode (see softwareEnclave.ts); any value is fine here.
63
+ sourceEnrollmentEpoch: 1,
61
64
  });
62
65
  if (scenario !== 'success') {
63
66
  await expect(result).rejects.toBeInstanceOf(EscrowBlobError);
@@ -74,13 +77,21 @@ describe('software enclave', () => {
74
77
  pinProof: pinVerifier,
75
78
  clientEphemeralPublicKey: client.publicKey,
76
79
  hold: {
77
- _id: 'carry',
78
- status: 'pending',
79
- releasePolicy: 'pin',
80
- releaseAfter: new Date(0),
81
- primaryDid: did,
82
- shareVersion: 3,
83
- clientEphemeralPublicKey: client.publicKey,
80
+ hold: {
81
+ holdId: 'carry',
82
+ did,
83
+ shareVersion: 3,
84
+ blobHash: 'ab'.repeat(32),
85
+ enrollmentEpoch: 1,
86
+ releasePolicy: 'pin',
87
+ clientEphemeralPublicKey: client.publicKey,
88
+ createdLo: 0,
89
+ createdHi: 0,
90
+ policyVersion: 1,
91
+ signature: 'test-signature',
92
+ },
93
+ holdDurationMs: 0,
94
+ ledgerSeq: 0,
84
95
  },
85
96
  });
86
97
  expect(await openEscrowRelease(sealed, client.privateKey)).toEqual({
@@ -90,6 +101,84 @@ describe('software enclave', () => {
90
101
  });
91
102
  });
92
103
 
104
+ it.each(['success', 'current key', 'unknown key', 'wrong DID', 'wrong version'])(
105
+ 'rewraps an escrow blob sealed under a previous key (P9.3): %s',
106
+ async scenario => {
107
+ const current = await generateEscrowKeyPair();
108
+ const previous = await generateEscrowKeyPair();
109
+ const client = await generateEscrowKeyPair();
110
+ const enclave = new SoftwareEnclave({
111
+ privateKeys: { current: current.privateKey, previous: previous.privateKey },
112
+ activeKeyId: 'current',
113
+ });
114
+ const did = 'did:key:test';
115
+ const pinVerifier = 'ab'.repeat(32);
116
+ const target = { recoveryShare: 'ef'.repeat(33), did, shareVersion: 1 };
117
+ const sourceKeys = scenario === 'current key' ? current : previous;
118
+ const sourceKeyId = scenario === 'current key' ? 'current' : 'previous';
119
+ const sourceEnvelope = await encryptEscrowBlob(
120
+ { ...target, pinVerifier },
121
+ sourceKeys.publicKey,
122
+ sourceKeyId
123
+ );
124
+ const unknown =
125
+ scenario === 'unknown key'
126
+ ? await encryptEscrowBlob(
127
+ target,
128
+ (await generateEscrowKeyPair()).publicKey,
129
+ 'retired-and-removed'
130
+ )
131
+ : undefined;
132
+ const result = enclave.rewrapEscrowBlob({
133
+ envelope: unknown ?? sourceEnvelope,
134
+ expectedDid: scenario === 'wrong DID' ? 'did:key:other' : did,
135
+ expectedShareVersion: scenario === 'wrong version' ? 2 : 1,
136
+ sourceEnrollmentEpoch: 1,
137
+ });
138
+ if (scenario !== 'success') {
139
+ await expect(result).rejects.toBeInstanceOf(EscrowBlobError);
140
+ return;
141
+ }
142
+ const { envelope } = await result;
143
+ expect(envelope.ciphertext).not.toBe(sourceEnvelope.ciphertext);
144
+ expect(
145
+ await enclave.verifyEscrowBlob({
146
+ envelope,
147
+ expectedDid: did,
148
+ expectedShareVersion: 1,
149
+ })
150
+ ).toEqual({ ok: true, hasPin: true });
151
+ const { sealed } = await enclave.releaseEscrow({
152
+ envelope,
153
+ expectedDid: did,
154
+ pinProof: pinVerifier,
155
+ clientEphemeralPublicKey: client.publicKey,
156
+ hold: {
157
+ hold: {
158
+ holdId: 'rewrap',
159
+ did,
160
+ shareVersion: 1,
161
+ blobHash: 'ab'.repeat(32),
162
+ enrollmentEpoch: 1,
163
+ releasePolicy: 'pin',
164
+ clientEphemeralPublicKey: client.publicKey,
165
+ createdLo: 0,
166
+ createdHi: 0,
167
+ policyVersion: 1,
168
+ signature: 'test-signature',
169
+ },
170
+ holdDurationMs: 0,
171
+ ledgerSeq: 0,
172
+ },
173
+ });
174
+ expect(await openEscrowRelease(sealed, client.privateKey)).toEqual({
175
+ ...target,
176
+ version: 1,
177
+ holdId: 'rewrap',
178
+ });
179
+ }
180
+ );
181
+
93
182
  it('derives the attestation key, verifies enrollment, and enforces release policy', async () => {
94
183
  const keys = await generateEscrowKeyPair();
95
184
  const client = await generateEscrowKeyPair();
@@ -102,6 +191,7 @@ describe('software enclave', () => {
102
191
  expect(JSON.parse(Buffer.from(attestation.document, 'base64').toString())).toEqual({
103
192
  mode: 'software',
104
193
  keyId: 'test',
194
+ previousKeyIds: [],
105
195
  publicKey: keys.publicKey,
106
196
  issuedAt: attestation.issuedAt,
107
197
  });
@@ -121,33 +211,86 @@ describe('software enclave', () => {
121
211
  expectedShareVersion: 1,
122
212
  })
123
213
  ).toMatchObject({ ok: false });
124
- const hold: EscrowHoldForEnclave = {
125
- _id: 'test-hold',
126
- primaryDid: plaintext.did,
127
- shareVersion: 1,
128
- status: 'pending',
129
- releaseAfter: new Date(1000),
214
+ const { holdRecord: created } = await enclave.createHold({
215
+ envelope,
216
+ holdId: 'test-hold',
217
+ expectedDid: plaintext.did,
218
+ expectedShareVersion: 1,
219
+ enrollmentEpoch: 1,
130
220
  releasePolicy: 'hold',
131
221
  clientEphemeralPublicKey: client.publicKey,
222
+ });
223
+ const hold: EscrowHoldRecord = {
224
+ ...created,
225
+ hold: { ...created.hold, createdLo: 0, createdHi: 0 },
226
+ holdDurationMs: 1000,
227
+ };
228
+ expect(created).toMatchObject({
229
+ hold: {
230
+ holdId: 'test-hold',
231
+ did: plaintext.did,
232
+ shareVersion: 1,
233
+ enrollmentEpoch: 1,
234
+ releasePolicy: 'hold',
235
+ clientEphemeralPublicKey: client.publicKey,
236
+ createdLo: expect.any(Number),
237
+ createdHi: expect.any(Number),
238
+ policyVersion: 1,
239
+ signature: 'software-mode-unsigned',
240
+ blobHash: expect.stringMatching(/^[0-9a-f]{64}$/),
241
+ },
242
+ holdDurationMs: 604800000,
243
+ ledgerSeq: 0,
244
+ });
245
+ const createInput = {
246
+ envelope,
247
+ holdId: 'test-hold',
248
+ expectedDid: plaintext.did,
249
+ expectedShareVersion: 1,
250
+ enrollmentEpoch: 1,
251
+ releasePolicy: 'hold' as const,
252
+ clientEphemeralPublicKey: client.publicKey,
132
253
  };
254
+ for (const change of [
255
+ { expectedDid: 'did:key:other' },
256
+ { expectedShareVersion: 2 },
257
+ { enrollmentEpoch: 0 },
258
+ { releasePolicy: 'pin' as const },
259
+ ]) {
260
+ await expect(enclave.createHold({ ...createInput, ...change })).rejects.toBeInstanceOf(
261
+ EscrowPolicyError
262
+ );
263
+ }
264
+ await expect(
265
+ enclave.createHold({ ...createInput, envelope: { ...envelope, ciphertext: 'bad' } })
266
+ ).rejects.toBeInstanceOf(EscrowBlobError);
133
267
  const request = {
134
268
  envelope,
135
269
  hold,
136
270
  clientEphemeralPublicKey: client.publicKey,
137
271
  expectedDid: plaintext.did,
138
272
  };
273
+ await expect(enclave.cancelHold(request)).resolves.toBeUndefined();
274
+ for (const change of [
275
+ { expectedDid: 'did:key:other' },
276
+ { clientEphemeralPublicKey: keys.publicKey },
277
+ { hold: { ...hold, hold: { ...hold.hold, did: 'did:key:other' } } },
278
+ ]) {
279
+ await expect(enclave.cancelHold({ ...request, ...change })).rejects.toBeInstanceOf(
280
+ EscrowPolicyError
281
+ );
282
+ }
283
+ await expect(
284
+ enclave.cancelHold({ ...request, envelope: { ...envelope, ciphertext: 'bad' } })
285
+ ).rejects.toBeInstanceOf(EscrowBlobError);
139
286
  await expect(
140
287
  enclave.releaseEscrow({ ...request, now: new Date(999) })
141
288
  ).rejects.toBeInstanceOf(EscrowPolicyError);
142
- for (const changed of [
143
- { status: 'cancelled' as const },
144
- { shareVersion: 2 },
145
- { primaryDid: 'did:key:wrong' },
146
- ]) {
289
+ for (const changed of [{ shareVersion: 2 }, { did: 'did:key:wrong' }]) {
147
290
  await expect(
148
291
  enclave.releaseEscrow({
149
292
  ...request,
150
- hold: { ...hold, ...changed },
293
+ hold: { ...hold, hold: { ...hold.hold, ...changed } },
151
294
  now: new Date(1000),
152
295
  })
153
296
  ).rejects.toBeInstanceOf(EscrowPolicyError);
@@ -166,7 +309,7 @@ describe('software enclave', () => {
166
309
  expect(await openEscrowRelease(released.sealed, client.privateKey)).toEqual({
167
310
  ...plaintext,
168
311
  version: 1,
169
- holdId: hold._id,
312
+ holdId: hold.hold.holdId,
170
313
  });
171
314
  await expect(
172
315
  enclave.verifyEscrowBlob({
@@ -220,20 +363,29 @@ describe('software enclave', () => {
220
363
  expectedShareVersion: 1,
221
364
  })
222
365
  ).toEqual({ ok: true, hasPin: verifier });
366
+ const createInput = {
367
+ envelope,
368
+ holdId: 'pin-hold',
369
+ expectedDid: plaintext.did,
370
+ expectedShareVersion: 1,
371
+ enrollmentEpoch: 1,
372
+ releasePolicy: policy,
373
+ clientEphemeralPublicKey: client.publicKey,
374
+ };
375
+ if (policy === 'pin' && !verifier) {
376
+ await expect(enclave.createHold(createInput)).rejects.toBeInstanceOf(
377
+ EscrowPolicyError
378
+ );
379
+ return;
380
+ }
381
+ const { holdRecord } = await enclave.createHold(createInput);
223
382
  const request = {
224
383
  envelope,
225
384
  expectedDid: plaintext.did,
226
385
  clientEphemeralPublicKey: client.publicKey,
227
386
  pinProof: proof,
228
- hold: {
229
- _id: 'pin-hold',
230
- status: 'pending' as const,
231
- primaryDid: plaintext.did,
232
- shareVersion: 1,
233
- releasePolicy: policy,
234
- releaseAfter: new Date(0),
235
- clientEphemeralPublicKey: client.publicKey,
236
- },
387
+ hold: holdRecord,
388
+ now: new Date(holdRecord.hold.createdHi + holdRecord.holdDurationMs),
237
389
  };
238
390
  if (error) await expect(enclave.releaseEscrow(request)).rejects.toBeInstanceOf(error);
239
391
  else {
@@ -246,4 +398,53 @@ describe('software enclave', () => {
246
398
  }
247
399
  }
248
400
  );
401
+
402
+ // P9.1 software-enclave equivalent: every configured key other than
403
+ // activeKeyId is already usable for decrypt (verifyEscrowBlob/createHold/
404
+ // releaseEscrow key the plaintext lookup by the envelope's own keyId, not
405
+ // by activeKeyId) — this test asserts the attestation now ADVERTISES that
406
+ // existing capability, and that a blob still sealed under a retired key
407
+ // decrypts/creates a hold/releases exactly like a current-key blob.
408
+ it('advertises other configured keys as previousKeyIds and still releases a blob sealed under one', async () => {
409
+ const active = await generateEscrowKeyPair();
410
+ const retired = await generateEscrowKeyPair();
411
+ const client = await generateEscrowKeyPair();
412
+ const enclave = new SoftwareEnclave({
413
+ privateKeys: { active: active.privateKey, retired: retired.privateKey },
414
+ activeKeyId: 'active',
415
+ });
416
+ const attestation = await enclave.getAttestation();
417
+ expect(attestation.keyId).toBe('active');
418
+ expect(attestation.previousKeyIds).toEqual(['retired']);
419
+ const plaintext = { recoveryShare: 'cd'.repeat(33), did: 'did:key:test', shareVersion: 1 };
420
+ const envelope = await encryptEscrowBlob(plaintext, retired.publicKey, 'retired');
421
+ expect(
422
+ await enclave.verifyEscrowBlob({
423
+ envelope,
424
+ expectedDid: plaintext.did,
425
+ expectedShareVersion: 1,
426
+ })
427
+ ).toEqual({ ok: true, hasPin: false });
428
+ const { holdRecord } = await enclave.createHold({
429
+ envelope,
430
+ holdId: 'retired-key-hold',
431
+ expectedDid: plaintext.did,
432
+ expectedShareVersion: 1,
433
+ enrollmentEpoch: 1,
434
+ releasePolicy: 'hold',
435
+ clientEphemeralPublicKey: client.publicKey,
436
+ });
437
+ const result = await enclave.releaseEscrow({
438
+ envelope,
439
+ hold: holdRecord,
440
+ expectedDid: plaintext.did,
441
+ clientEphemeralPublicKey: client.publicKey,
442
+ now: new Date(holdRecord.hold.createdHi + holdRecord.holdDurationMs),
443
+ });
444
+ expect(await openEscrowRelease(result.sealed, client.privateKey)).toEqual({
445
+ ...plaintext,
446
+ version: 1,
447
+ holdId: 'retired-key-hold',
448
+ });
449
+ });
249
450
  });