@learncard/lca-api-service 1.4.0 → 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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,490 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { EscrowEnvelope } from '@learncard/sss-key-manager';
3
+ import { createRemoteEnclave, type RemoteEnclaveTransportResponse } from './remoteEnclave';
4
+ import {
5
+ EscrowBlobError,
6
+ EscrowPinMismatchError,
7
+ EscrowPolicyError,
8
+ EscrowUnavailableError,
9
+ type EscrowHoldRecord,
10
+ } from './types';
11
+
12
+ const baseUrl = 'https://enclave-host.internal';
13
+ const token = 't'.repeat(32);
14
+ const timeoutMs = 10_000;
15
+
16
+ const envelope: EscrowEnvelope = {
17
+ version: 1,
18
+ algorithm: 'P-256-HKDF-SHA256-AES-256-GCM',
19
+ keyId: 'key-1',
20
+ ephemeralPublicKey: 'AA==',
21
+ salt: 'AA==',
22
+ iv: 'AA==',
23
+ ciphertext: 'AA==',
24
+ };
25
+
26
+ const hold: EscrowHoldRecord = {
27
+ hold: {
28
+ holdId: 'hold-1',
29
+ did: 'did:example:alice',
30
+ shareVersion: 1,
31
+ blobHash: 'ab'.repeat(32),
32
+ enrollmentEpoch: 1,
33
+ releasePolicy: 'hold',
34
+ clientEphemeralPublicKey: 'BB==',
35
+ createdLo: 1700000000000,
36
+ createdHi: 1700000000000,
37
+ policyVersion: 1,
38
+ signature: 'signed-test-record',
39
+ },
40
+ holdDurationMs: 604800000,
41
+ ledgerSeq: 1,
42
+ };
43
+
44
+ const attestationBody = {
45
+ mode: 'nitro' as const,
46
+ keyId: 'key-1',
47
+ previousKeyIds: ['key-0'],
48
+ publicKey: 'CC==',
49
+ measurements: { pcr0: '00', pcr1: '11', pcr2: '22' },
50
+ document: 'ZG9j',
51
+ issuedAt: '2026-09-25T00:00:00.000Z',
52
+ };
53
+
54
+ const respond = (status: number, data: unknown): RemoteEnclaveTransportResponse => ({
55
+ status,
56
+ data,
57
+ });
58
+
59
+ describe('createRemoteEnclave', () => {
60
+ let consoleSpies: ReturnType<typeof vi.spyOn>[];
61
+
62
+ beforeEach(() => {
63
+ consoleSpies = (['log', 'info', 'warn', 'error', 'debug'] as const).map(method =>
64
+ vi.spyOn(console, method).mockImplementation(() => {})
65
+ );
66
+ });
67
+
68
+ afterEach(() => {
69
+ // Envelopes and PIN proofs must never reach any log sink, on success or failure.
70
+ for (const spy of consoleSpies) expect(spy).not.toHaveBeenCalled();
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ it('fetches attestation, forwarding the nonce as a byte array with the bearer token', async () => {
75
+ const post = vi.fn().mockResolvedValue(respond(200, attestationBody));
76
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
77
+ const nonce = Uint8Array.from([1, 2, 3]);
78
+
79
+ await expect(enclave.getAttestation(nonce)).resolves.toEqual(attestationBody);
80
+
81
+ expect(post).toHaveBeenCalledWith(
82
+ `${baseUrl}/v1/attest`,
83
+ { nonce: [1, 2, 3] },
84
+ { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs }
85
+ );
86
+ });
87
+
88
+ it('sends an empty nonce array when none is given', async () => {
89
+ const post = vi.fn().mockResolvedValue(respond(200, attestationBody));
90
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
91
+
92
+ await enclave.getAttestation();
93
+
94
+ expect(post).toHaveBeenCalledWith(`${baseUrl}/v1/attest`, { nonce: [] }, expect.anything());
95
+ });
96
+
97
+ it('passes the attested mode through verbatim instead of upgrading it to nitro', async () => {
98
+ const post = vi
99
+ .fn()
100
+ .mockResolvedValue(respond(200, { ...attestationBody, mode: 'software' }));
101
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
102
+
103
+ await expect(enclave.getAttestation()).resolves.toMatchObject({ mode: 'software' });
104
+ });
105
+
106
+ it('verifies an escrow blob', async () => {
107
+ const post = vi.fn().mockResolvedValue(respond(200, { ok: true, hasPin: false }));
108
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
109
+
110
+ await expect(
111
+ enclave.verifyEscrowBlob({
112
+ envelope,
113
+ expectedDid: 'did:example:alice',
114
+ expectedShareVersion: 1,
115
+ })
116
+ ).resolves.toEqual({ ok: true, hasPin: false });
117
+ expect(post).toHaveBeenCalledWith(
118
+ `${baseUrl}/v1/verify-blob`,
119
+ { envelope, expectedDid: 'did:example:alice', expectedShareVersion: 1 },
120
+ expect.anything()
121
+ );
122
+ });
123
+
124
+ it('surfaces a failed verification result without throwing', async () => {
125
+ const post = vi.fn().mockResolvedValue(
126
+ respond(200, {
127
+ ok: false,
128
+ hasPin: true,
129
+ reason: 'Escrow recovery is not permitted.',
130
+ })
131
+ );
132
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
133
+
134
+ await expect(
135
+ enclave.verifyEscrowBlob({
136
+ envelope,
137
+ expectedDid: 'did:example:alice',
138
+ expectedShareVersion: 1,
139
+ })
140
+ ).resolves.toEqual({
141
+ ok: false,
142
+ hasPin: true,
143
+ reason: 'Escrow recovery is not permitted.',
144
+ });
145
+ });
146
+
147
+ it('carries a PIN verifier across a share-version rotation', async () => {
148
+ const post = vi.fn().mockResolvedValue(respond(200, { envelope }));
149
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
150
+ const input = {
151
+ sourceEnvelope: envelope,
152
+ targetEnvelope: envelope,
153
+ expectedDid: 'did:example:alice',
154
+ sourceShareVersion: 1,
155
+ targetShareVersion: 2,
156
+ targetEnrollmentEpoch: 2,
157
+ sourceEnrollmentEpoch: 1,
158
+ };
159
+
160
+ await expect(enclave.carryPinVerifier(input)).resolves.toEqual({ envelope });
161
+
162
+ expect(post).toHaveBeenCalledWith(
163
+ `${baseUrl}/v1/carry-pin-verifier`,
164
+ {
165
+ sourceEnvelope: envelope,
166
+ targetEnvelope: envelope,
167
+ expectedDid: 'did:example:alice',
168
+ sourceShareVersion: 1,
169
+ targetShareVersion: 2,
170
+ targetEnrollmentEpoch: 2,
171
+ sourceEnrollmentEpoch: 1,
172
+ },
173
+ { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs }
174
+ );
175
+ });
176
+
177
+ it.each([
178
+ ['policy', EscrowPolicyError],
179
+ ['pinMismatch', EscrowPinMismatchError],
180
+ ['blob', EscrowBlobError],
181
+ ['unavailable', EscrowUnavailableError],
182
+ ['ledger', EscrowUnavailableError],
183
+ ['time', EscrowUnavailableError],
184
+ ] as const)(
185
+ 'carryPinVerifier maps wire error code %s to the matching EscrowEnclave error',
186
+ async (code, ErrorClass) => {
187
+ const post = vi
188
+ .fn()
189
+ .mockResolvedValue(respond(400, { code, message: 'private detail' }));
190
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
191
+
192
+ await expect(
193
+ enclave.carryPinVerifier({
194
+ sourceEnvelope: envelope,
195
+ targetEnvelope: envelope,
196
+ expectedDid: 'did:example:alice',
197
+ sourceShareVersion: 1,
198
+ targetShareVersion: 2,
199
+ targetEnrollmentEpoch: 2,
200
+ sourceEnrollmentEpoch: 1,
201
+ })
202
+ ).rejects.toBeInstanceOf(ErrorClass);
203
+ }
204
+ );
205
+
206
+ it('carryPinVerifier fails closed on a malformed/schema-invalid response', async () => {
207
+ const post = vi
208
+ .fn()
209
+ .mockResolvedValue(respond(200, { envelope: { ...envelope, version: 2 } }));
210
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
211
+
212
+ await expect(
213
+ enclave.carryPinVerifier({
214
+ sourceEnvelope: envelope,
215
+ targetEnvelope: envelope,
216
+ expectedDid: 'did:example:alice',
217
+ sourceShareVersion: 1,
218
+ targetShareVersion: 2,
219
+ targetEnrollmentEpoch: 2,
220
+ sourceEnrollmentEpoch: 1,
221
+ })
222
+ ).rejects.toBeInstanceOf(EscrowUnavailableError);
223
+ });
224
+
225
+ it('rewraps an escrow blob sealed under a previous key onto the current key', async () => {
226
+ const post = vi.fn().mockResolvedValue(respond(200, { envelope }));
227
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
228
+ const input = {
229
+ envelope,
230
+ expectedDid: 'did:example:alice',
231
+ expectedShareVersion: 1,
232
+ sourceEnrollmentEpoch: 1,
233
+ };
234
+
235
+ await expect(enclave.rewrapEscrowBlob(input)).resolves.toEqual({ envelope });
236
+
237
+ expect(post).toHaveBeenCalledWith(
238
+ `${baseUrl}/v1/rewrap-escrow-blob`,
239
+ {
240
+ envelope,
241
+ expectedDid: 'did:example:alice',
242
+ expectedShareVersion: 1,
243
+ sourceEnrollmentEpoch: 1,
244
+ },
245
+ { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs }
246
+ );
247
+ });
248
+
249
+ it.each([
250
+ ['policy', EscrowPolicyError],
251
+ ['pinMismatch', EscrowPinMismatchError],
252
+ ['blob', EscrowBlobError],
253
+ ['unavailable', EscrowUnavailableError],
254
+ ['ledger', EscrowUnavailableError],
255
+ ['time', EscrowUnavailableError],
256
+ ] as const)(
257
+ 'rewrapEscrowBlob maps wire error code %s to the matching EscrowEnclave error',
258
+ async (code, ErrorClass) => {
259
+ const post = vi
260
+ .fn()
261
+ .mockResolvedValue(respond(400, { code, message: 'private detail' }));
262
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
263
+
264
+ await expect(
265
+ enclave.rewrapEscrowBlob({
266
+ envelope,
267
+ expectedDid: 'did:example:alice',
268
+ expectedShareVersion: 1,
269
+ sourceEnrollmentEpoch: 1,
270
+ })
271
+ ).rejects.toBeInstanceOf(ErrorClass);
272
+ }
273
+ );
274
+
275
+ it('rewrapEscrowBlob fails closed on a malformed/schema-invalid response', async () => {
276
+ const post = vi
277
+ .fn()
278
+ .mockResolvedValue(respond(200, { envelope: { ...envelope, version: 2 } }));
279
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
280
+
281
+ await expect(
282
+ enclave.rewrapEscrowBlob({
283
+ envelope,
284
+ expectedDid: 'did:example:alice',
285
+ expectedShareVersion: 1,
286
+ sourceEnrollmentEpoch: 1,
287
+ })
288
+ ).rejects.toBeInstanceOf(EscrowUnavailableError);
289
+ });
290
+
291
+ it('releases escrow, omitting host-supplied time', async () => {
292
+ const post = vi.fn().mockResolvedValue(respond(200, { sealed: envelope }));
293
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
294
+
295
+ await expect(
296
+ enclave.releaseEscrow({
297
+ envelope,
298
+ hold,
299
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
300
+ expectedDid: hold.hold.did,
301
+ now: new Date(),
302
+ })
303
+ ).resolves.toEqual({ sealed: envelope });
304
+
305
+ const [, body] = post.mock.calls[0]!;
306
+ expect(body).toEqual({
307
+ envelope,
308
+ hold,
309
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
310
+ expectedDid: hold.hold.did,
311
+ });
312
+ });
313
+
314
+ it('includes pinProof only when provided', async () => {
315
+ const post = vi.fn().mockResolvedValue(respond(200, { sealed: envelope }));
316
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
317
+
318
+ await enclave.releaseEscrow({
319
+ envelope,
320
+ hold,
321
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
322
+ expectedDid: hold.hold.did,
323
+ pinProof: 'ab'.repeat(32),
324
+ });
325
+
326
+ const [, body] = post.mock.calls[0]!;
327
+ expect(body).toMatchObject({ pinProof: 'ab'.repeat(32) });
328
+ });
329
+
330
+ it.each([
331
+ ['policy', EscrowPolicyError],
332
+ ['pinMismatch', EscrowPinMismatchError],
333
+ ['blob', EscrowBlobError],
334
+ ['unavailable', EscrowUnavailableError],
335
+ ['ledger', EscrowUnavailableError],
336
+ ['time', EscrowUnavailableError],
337
+ ] as const)(
338
+ 'maps wire error code %s to the matching EscrowEnclave error',
339
+ async (code, ErrorClass) => {
340
+ const post = vi.fn().mockResolvedValue(respond(400, { code, message: 'Refused.' }));
341
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
342
+
343
+ await expect(
344
+ enclave.releaseEscrow({
345
+ envelope,
346
+ hold,
347
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
348
+ expectedDid: hold.hold.did,
349
+ })
350
+ ).rejects.toBeInstanceOf(ErrorClass);
351
+ }
352
+ );
353
+
354
+ it('fails closed on a network error', async () => {
355
+ const post = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
356
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
357
+
358
+ await expect(enclave.getAttestation()).rejects.toBeInstanceOf(EscrowUnavailableError);
359
+ });
360
+
361
+ it('fails closed on a timeout', async () => {
362
+ const post = vi
363
+ .fn()
364
+ .mockRejectedValue(
365
+ Object.assign(new Error('timeout of 10000ms exceeded'), { code: 'ECONNABORTED' })
366
+ );
367
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
368
+
369
+ await expect(enclave.getAttestation()).rejects.toBeInstanceOf(EscrowUnavailableError);
370
+ });
371
+
372
+ it('fails closed on a 500 with no recognizable error code', async () => {
373
+ const post = vi.fn().mockResolvedValue(respond(500, { message: 'internal server error' }));
374
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
375
+
376
+ await expect(enclave.getAttestation()).rejects.toBeInstanceOf(EscrowUnavailableError);
377
+ });
378
+
379
+ it('fails closed on a malformed/schema-invalid success response', async () => {
380
+ const post = vi.fn().mockResolvedValue(respond(200, 'not-an-object'));
381
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
382
+
383
+ await expect(enclave.getAttestation()).rejects.toBeInstanceOf(EscrowUnavailableError);
384
+ });
385
+
386
+ it('defaults to a real HTTP transport when none is injected', () => {
387
+ expect(() => createRemoteEnclave({ baseUrl, token, timeoutMs })).not.toThrow();
388
+ });
389
+ const createInput = {
390
+ envelope,
391
+ holdId: hold.hold.holdId,
392
+ expectedDid: hold.hold.did,
393
+ expectedShareVersion: 1,
394
+ enrollmentEpoch: 1,
395
+ releasePolicy: 'hold' as const,
396
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
397
+ };
398
+ const cancelInput = {
399
+ envelope,
400
+ hold,
401
+ expectedDid: hold.hold.did,
402
+ clientEphemeralPublicKey: hold.hold.clientEphemeralPublicKey,
403
+ };
404
+ it('creates an opaque full record and forwards it unchanged to cancel and release', async () => {
405
+ const opaque = { ...hold, extension: 'outer', hold: { ...hold.hold, extension: 'inner' } };
406
+ const post = vi
407
+ .fn()
408
+ .mockResolvedValueOnce(respond(200, opaque))
409
+ .mockResolvedValueOnce(respond(200, { ok: true }))
410
+ .mockResolvedValueOnce(respond(200, { sealed: envelope }));
411
+ const enclave = createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } });
412
+ const created = await enclave.createHold(createInput);
413
+ expect(created).toEqual({ holdRecord: opaque });
414
+ const config = { headers: { Authorization: `Bearer ${token}` }, timeout: timeoutMs };
415
+ expect(post).toHaveBeenNthCalledWith(1, `${baseUrl}/v1/create-hold`, createInput, config);
416
+ await expect(
417
+ enclave.cancelHold({ ...cancelInput, hold: created.holdRecord })
418
+ ).resolves.toBeUndefined();
419
+ expect(post).toHaveBeenNthCalledWith(
420
+ 2,
421
+ `${baseUrl}/v1/cancel-hold`,
422
+ { ...cancelInput, hold: opaque },
423
+ config
424
+ );
425
+ await enclave.releaseEscrow({ ...cancelInput, hold: created.holdRecord });
426
+ expect(post).toHaveBeenNthCalledWith(
427
+ 3,
428
+ `${baseUrl}/v1/release`,
429
+ { ...cancelInput, hold: opaque },
430
+ config
431
+ );
432
+ });
433
+ for (const operation of ['createHold', 'cancelHold'] as const) {
434
+ const invoke = (enclave: ReturnType<typeof createRemoteEnclave>) =>
435
+ operation === 'createHold'
436
+ ? enclave.createHold(createInput)
437
+ : enclave.cancelHold(cancelInput);
438
+ it.each([
439
+ ['policy', EscrowPolicyError],
440
+ ['pinMismatch', EscrowPinMismatchError],
441
+ ['blob', EscrowBlobError],
442
+ ['unavailable', EscrowUnavailableError],
443
+ ['ledger', EscrowUnavailableError],
444
+ ['time', EscrowUnavailableError],
445
+ ] as const)(
446
+ `${operation} maps %s without leaking the remote message`,
447
+ async (code, ErrorClass) => {
448
+ const post = vi
449
+ .fn()
450
+ .mockResolvedValue(respond(400, { code, message: 'private remote detail' }));
451
+ const enclave = createRemoteEnclave({
452
+ baseUrl,
453
+ token,
454
+ timeoutMs,
455
+ transport: { post },
456
+ });
457
+ await expect(invoke(enclave)).rejects.toBeInstanceOf(ErrorClass);
458
+ }
459
+ );
460
+ it.each([
461
+ null,
462
+ 'malformed',
463
+ {},
464
+ { ok: false },
465
+ { ...hold, hold: { ...hold.hold, enrollmentEpoch: 0 } },
466
+ { ...hold, ledgerSeq: 'bad' },
467
+ ])(`${operation} fails closed on invalid response %#`, async data => {
468
+ const post = vi.fn().mockResolvedValue(respond(200, data));
469
+ await expect(
470
+ invoke(createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } }))
471
+ ).rejects.toBeInstanceOf(EscrowUnavailableError);
472
+ });
473
+ it.each(['ECONNREFUSED', 'ECONNABORTED'])(`${operation} fails closed on %s`, async code => {
474
+ const post = vi
475
+ .fn()
476
+ .mockRejectedValue(Object.assign(new Error('transport failed'), { code }));
477
+ await expect(
478
+ invoke(createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } }))
479
+ ).rejects.toBeInstanceOf(EscrowUnavailableError);
480
+ });
481
+ it(`${operation} rejects unrecognized HTTP errors`, async () => {
482
+ const post = vi
483
+ .fn()
484
+ .mockResolvedValue(respond(500, { code: 'unknown', message: 'private' }));
485
+ await expect(
486
+ invoke(createRemoteEnclave({ baseUrl, token, timeoutMs, transport: { post } }))
487
+ ).rejects.toBeInstanceOf(EscrowUnavailableError);
488
+ });
489
+ }
490
+ });
@@ -0,0 +1,62 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer, type Server } from 'node:https';
3
+ import type { AddressInfo } from 'node:net';
4
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
+ import { createHttpTransport } from './remoteEnclave';
6
+ import { pinnedEnclaveCa } from './enclaveTrustAnchors';
7
+
8
+ const fixture = (name: string): string =>
9
+ readFileSync(new URL(`./__fixtures__/tls/${name}`, import.meta.url), 'utf8');
10
+
11
+ const listen = async (prefix: 'pinned' | 'rogue'): Promise<Server> => {
12
+ const server = createServer(
13
+ { cert: fixture(`${prefix}-leaf.pem`), key: fixture(`${prefix}-leaf.key`) },
14
+ (_request, response) => {
15
+ response.setHeader('content-type', 'application/json');
16
+ response.end('{"ok":true}');
17
+ }
18
+ );
19
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
20
+ return server;
21
+ };
22
+
23
+ const url = (server: Server): string =>
24
+ `https://localhost:${(server.address() as AddressInfo).port}/v1/attest`;
25
+
26
+ describe('remote enclave TLS trust', () => {
27
+ let pinned: Server;
28
+ let rogue: Server;
29
+ const config = { headers: {}, timeout: 5_000 };
30
+
31
+ beforeAll(async () => {
32
+ [pinned, rogue] = await Promise.all([listen('pinned'), listen('rogue')]);
33
+ });
34
+ afterAll(() => {
35
+ pinned.close();
36
+ rogue.close();
37
+ });
38
+
39
+ it('accepts a server certificate issued by the pinned CA', async () => {
40
+ const transport = createHttpTransport(fixture('pinned-ca.pem'));
41
+ await expect(transport.post(url(pinned), {}, config)).resolves.toEqual({
42
+ status: 200,
43
+ data: { ok: true },
44
+ });
45
+ });
46
+
47
+ it('rejects a certificate from any other CA when a CA is pinned', async () => {
48
+ const transport = createHttpTransport(fixture('pinned-ca.pem'));
49
+ await expect(transport.post(url(rogue), {}, config)).rejects.toThrow();
50
+ });
51
+
52
+ it('does not trust a private CA unless it is pinned', async () => {
53
+ await expect(createHttpTransport().post(url(pinned), {}, config)).rejects.toThrow();
54
+ });
55
+
56
+ it('pins the staging CA by hostname only', () => {
57
+ expect(pinnedEnclaveCa('https://escrow-enclave.staging.internal:8443')).toContain(
58
+ 'BEGIN CERTIFICATE'
59
+ );
60
+ expect(pinnedEnclaveCa('https://enclave-host.internal')).toBeUndefined();
61
+ });
62
+ });