@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.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +22 -13
- package/project.json +8 -1
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +174 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- 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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
});
|