@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.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +76 -0
- 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/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 +23 -13
- 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 +172 -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,344 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import {
|
|
5
|
+
encryptEscrowBlob,
|
|
6
|
+
generateEscrowKeyPair,
|
|
7
|
+
openEscrowRelease,
|
|
8
|
+
} from '@learncard/sss-key-manager';
|
|
9
|
+
import { createRemoteEnclave } from './remoteEnclave';
|
|
10
|
+
import { escrowBlobIdentity } from './blobIdentity';
|
|
11
|
+
import { EscrowPinMismatchError, EscrowPolicyError } from './types';
|
|
12
|
+
|
|
13
|
+
// Runs the HTTP contract implemented in remoteEnclave.ts against a live
|
|
14
|
+
// `escrow-enclave --emulate` process (services/escrow-enclave-app, P1.8).
|
|
15
|
+
//
|
|
16
|
+
// Requires an explicitly configured live endpoint; skipped in normal CI.
|
|
17
|
+
// Start the enclave with all four fake features, --emulate 127.0.0.1:5000,
|
|
18
|
+
// --emulate-http 127.0.0.1:8443, and ESCROW_ENCLAVE_EMULATE_TOKEN.
|
|
19
|
+
// Set ESCROW_ENCLAVE_EMULATE_FIXTURE in BOTH processes to the same disposable
|
|
20
|
+
// JSON fixture (initial contents: {}). Enrollment is provisioned through this
|
|
21
|
+
// fake-only authority, never trusted from the HTTP create-hold request.
|
|
22
|
+
//
|
|
23
|
+
// ESCROW_ENCLAVE_CONTRACT_URL=http://127.0.0.1:8443 \
|
|
24
|
+
// bunx vitest run --config vitest.config.ts src/services/escrow-enclave/remoteEnclave.contract.test.ts
|
|
25
|
+
//
|
|
26
|
+
// Optionally set ESCROW_ENCLAVE_CONTRACT_TOKEN to match whatever bearer token
|
|
27
|
+
// the emulate server is configured to accept.
|
|
28
|
+
const contractUrl = process.env.ESCROW_ENCLAVE_CONTRACT_URL;
|
|
29
|
+
|
|
30
|
+
describe.skipIf(!contractUrl)('remote enclave contract (escrow-enclave --emulate)', () => {
|
|
31
|
+
const enclave = createRemoteEnclave({
|
|
32
|
+
baseUrl: contractUrl ?? '',
|
|
33
|
+
token: process.env.ESCROW_ENCLAVE_CONTRACT_TOKEN ?? 'contract-test-token',
|
|
34
|
+
timeoutMs: 10_000,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a well-formed attestation for a fresh nonce', async () => {
|
|
38
|
+
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
39
|
+
|
|
40
|
+
const attestation = await enclave.getAttestation(nonce);
|
|
41
|
+
|
|
42
|
+
expect(['software', 'nitro']).toContain(attestation.mode);
|
|
43
|
+
expect(attestation.keyId.length).toBeGreaterThan(0);
|
|
44
|
+
expect(attestation.publicKey.length).toBeGreaterThan(0);
|
|
45
|
+
expect(attestation.document.length).toBeGreaterThan(0);
|
|
46
|
+
expect(() => new Date(attestation.issuedAt).toISOString()).not.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('verifies a blob encrypted to the attested public key', async () => {
|
|
50
|
+
const attestation = await enclave.getAttestation();
|
|
51
|
+
const did = 'did:key:contract-test';
|
|
52
|
+
const envelope = await encryptEscrowBlob(
|
|
53
|
+
{ recoveryShare: 'ab'.repeat(33), did, shareVersion: 1 },
|
|
54
|
+
attestation.publicKey,
|
|
55
|
+
attestation.keyId
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
enclave.verifyEscrowBlob({ envelope, expectedDid: did, expectedShareVersion: 1 })
|
|
60
|
+
).resolves.toEqual({ ok: true, hasPin: false });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('creates, releases, opens, refuses replay, rejects wrong PIN, and cancels real holds', async () => {
|
|
64
|
+
const fixturePath = process.env.ESCROW_ENCLAVE_EMULATE_FIXTURE;
|
|
65
|
+
if (!fixturePath)
|
|
66
|
+
throw new Error(
|
|
67
|
+
'Set ESCROW_ENCLAVE_EMULATE_FIXTURE for the emulator and contract test'
|
|
68
|
+
);
|
|
69
|
+
const original = await readFile(fixturePath, 'utf8');
|
|
70
|
+
const replace = async (contents: string) => {
|
|
71
|
+
const temporary = `${fixturePath}.${randomUUID()}.tmp`;
|
|
72
|
+
await writeFile(temporary, contents, { mode: 0o600 });
|
|
73
|
+
await rename(temporary, fixturePath);
|
|
74
|
+
};
|
|
75
|
+
const attestation = await enclave.getAttestation();
|
|
76
|
+
expect(attestation.mode).toBe('software');
|
|
77
|
+
const client = await generateEscrowKeyPair();
|
|
78
|
+
const did = `did:key:contract-${randomUUID()}`;
|
|
79
|
+
const plaintext = { recoveryShare: 'ab'.repeat(33), did, shareVersion: 1 };
|
|
80
|
+
const pinProof = 'ab'.repeat(32);
|
|
81
|
+
const envelope = await encryptEscrowBlob(
|
|
82
|
+
{ ...plaintext, pinVerifier: pinProof },
|
|
83
|
+
attestation.publicKey,
|
|
84
|
+
attestation.keyId
|
|
85
|
+
);
|
|
86
|
+
const blobHash = escrowBlobIdentity(envelope);
|
|
87
|
+
try {
|
|
88
|
+
await replace(
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
nowMs: 1_700_000_000_000,
|
|
91
|
+
enrollments: { [did]: { epoch: 1, shareVersion: 1, blobHash } },
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
const create = async () => {
|
|
95
|
+
const holdId = randomUUID();
|
|
96
|
+
const { holdRecord } = await enclave.createHold({
|
|
97
|
+
envelope,
|
|
98
|
+
holdId,
|
|
99
|
+
expectedDid: did,
|
|
100
|
+
expectedShareVersion: 1,
|
|
101
|
+
enrollmentEpoch: 1,
|
|
102
|
+
releasePolicy: 'pin',
|
|
103
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
104
|
+
});
|
|
105
|
+
expect(holdRecord.hold.holdId).toBe(holdId);
|
|
106
|
+
expect(holdRecord.holdDurationMs).toBe(604_800_000);
|
|
107
|
+
return {
|
|
108
|
+
holdId,
|
|
109
|
+
request: {
|
|
110
|
+
envelope,
|
|
111
|
+
hold: holdRecord,
|
|
112
|
+
expectedDid: did,
|
|
113
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const successful = await create();
|
|
118
|
+
const request = { ...successful.request, pinProof };
|
|
119
|
+
const released = await enclave.releaseEscrow(request);
|
|
120
|
+
expect(await openEscrowRelease(released.sealed, client.privateKey)).toEqual({
|
|
121
|
+
...plaintext,
|
|
122
|
+
version: 1,
|
|
123
|
+
holdId: successful.holdId,
|
|
124
|
+
});
|
|
125
|
+
await expect(enclave.releaseEscrow(request)).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
126
|
+
|
|
127
|
+
const wrong = await create();
|
|
128
|
+
await expect(
|
|
129
|
+
enclave.releaseEscrow({ ...wrong.request, pinProof: 'cd'.repeat(32) })
|
|
130
|
+
).rejects.toBeInstanceOf(EscrowPinMismatchError);
|
|
131
|
+
|
|
132
|
+
const cancelled = await create();
|
|
133
|
+
await enclave.cancelHold(cancelled.request);
|
|
134
|
+
await expect(
|
|
135
|
+
enclave.releaseEscrow({ ...cancelled.request, pinProof })
|
|
136
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
137
|
+
} finally {
|
|
138
|
+
await replace(original);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('carries a PIN verifier into a newer blob and releases it with the original PIN', async () => {
|
|
143
|
+
const fixturePath = process.env.ESCROW_ENCLAVE_EMULATE_FIXTURE;
|
|
144
|
+
if (!fixturePath)
|
|
145
|
+
throw new Error(
|
|
146
|
+
'Set ESCROW_ENCLAVE_EMULATE_FIXTURE for the emulator and contract test'
|
|
147
|
+
);
|
|
148
|
+
const original = await readFile(fixturePath, 'utf8');
|
|
149
|
+
const replace = async (contents: string) => {
|
|
150
|
+
const temporary = `${fixturePath}.${randomUUID()}.tmp`;
|
|
151
|
+
await writeFile(temporary, contents, { mode: 0o600 });
|
|
152
|
+
await rename(temporary, fixturePath);
|
|
153
|
+
};
|
|
154
|
+
const attestation = await enclave.getAttestation();
|
|
155
|
+
const client = await generateEscrowKeyPair();
|
|
156
|
+
const did = `did:key:contract-carry-${randomUUID()}`;
|
|
157
|
+
const pinProof = 'ab'.repeat(32);
|
|
158
|
+
const sourceEnvelope = await encryptEscrowBlob(
|
|
159
|
+
{ recoveryShare: 'ab'.repeat(33), did, shareVersion: 1, pinVerifier: pinProof },
|
|
160
|
+
attestation.publicKey,
|
|
161
|
+
attestation.keyId
|
|
162
|
+
);
|
|
163
|
+
const targetPlaintext = { recoveryShare: 'cd'.repeat(33), did, shareVersion: 2 };
|
|
164
|
+
const targetEnvelope = await encryptEscrowBlob(
|
|
165
|
+
targetPlaintext,
|
|
166
|
+
attestation.publicKey,
|
|
167
|
+
attestation.keyId
|
|
168
|
+
);
|
|
169
|
+
try {
|
|
170
|
+
// Carry now signs a ledger genesis using trusted time; hold time must
|
|
171
|
+
// not move backwards relative to that record.
|
|
172
|
+
await replace(JSON.stringify({ nowMs: 1_700_000_000_000 }));
|
|
173
|
+
const { envelope: carried } = await enclave.carryPinVerifier({
|
|
174
|
+
sourceEnvelope,
|
|
175
|
+
targetEnvelope,
|
|
176
|
+
expectedDid: did,
|
|
177
|
+
sourceShareVersion: 1,
|
|
178
|
+
targetShareVersion: 2,
|
|
179
|
+
targetEnrollmentEpoch: 2,
|
|
180
|
+
sourceEnrollmentEpoch: 1,
|
|
181
|
+
});
|
|
182
|
+
await expect(
|
|
183
|
+
enclave.verifyEscrowBlob({
|
|
184
|
+
envelope: carried,
|
|
185
|
+
expectedDid: did,
|
|
186
|
+
expectedShareVersion: 2,
|
|
187
|
+
})
|
|
188
|
+
).resolves.toEqual({ ok: true, hasPin: true });
|
|
189
|
+
|
|
190
|
+
await replace(
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
nowMs: 1_700_000_000_000,
|
|
193
|
+
enrollments: {
|
|
194
|
+
[did]: { epoch: 2, shareVersion: 2, blobHash: escrowBlobIdentity(carried) },
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
const holdId = randomUUID();
|
|
199
|
+
const { holdRecord } = await enclave.createHold({
|
|
200
|
+
envelope: carried,
|
|
201
|
+
holdId,
|
|
202
|
+
expectedDid: did,
|
|
203
|
+
expectedShareVersion: 2,
|
|
204
|
+
enrollmentEpoch: 2,
|
|
205
|
+
releasePolicy: 'pin',
|
|
206
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
207
|
+
});
|
|
208
|
+
const released = await enclave.releaseEscrow({
|
|
209
|
+
envelope: carried,
|
|
210
|
+
hold: holdRecord,
|
|
211
|
+
expectedDid: did,
|
|
212
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
213
|
+
pinProof,
|
|
214
|
+
});
|
|
215
|
+
expect(await openEscrowRelease(released.sealed, client.privateKey)).toEqual({
|
|
216
|
+
...targetPlaintext,
|
|
217
|
+
version: 1,
|
|
218
|
+
holdId,
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
await replace(original);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Carry changes the blob and therefore the chain. Its signed genesis must
|
|
226
|
+
// preserve the spent budget regardless of the destination epoch.
|
|
227
|
+
it('carries the PIN attempt budget across a rotation: N failures then only 10-N remain', async () => {
|
|
228
|
+
const fixturePath = process.env.ESCROW_ENCLAVE_EMULATE_FIXTURE;
|
|
229
|
+
if (!fixturePath)
|
|
230
|
+
throw new Error(
|
|
231
|
+
'Set ESCROW_ENCLAVE_EMULATE_FIXTURE for the emulator and contract test'
|
|
232
|
+
);
|
|
233
|
+
const original = await readFile(fixturePath, 'utf8');
|
|
234
|
+
const replace = async (contents: string) => {
|
|
235
|
+
const temporary = `${fixturePath}.${randomUUID()}.tmp`;
|
|
236
|
+
await writeFile(temporary, contents, { mode: 0o600 });
|
|
237
|
+
await rename(temporary, fixturePath);
|
|
238
|
+
};
|
|
239
|
+
const attestation = await enclave.getAttestation();
|
|
240
|
+
const client = await generateEscrowKeyPair();
|
|
241
|
+
const did = `did:key:contract-budget-${randomUUID()}`;
|
|
242
|
+
const pinProof = 'ab'.repeat(32);
|
|
243
|
+
const wrongProof = 'cd'.repeat(32);
|
|
244
|
+
const sourceEnvelope = await encryptEscrowBlob(
|
|
245
|
+
{ recoveryShare: 'ab'.repeat(33), did, shareVersion: 1, pinVerifier: pinProof },
|
|
246
|
+
attestation.publicKey,
|
|
247
|
+
attestation.keyId
|
|
248
|
+
);
|
|
249
|
+
const attempt = async (
|
|
250
|
+
envelope: typeof sourceEnvelope,
|
|
251
|
+
shareVersion: number,
|
|
252
|
+
epoch: number
|
|
253
|
+
) => {
|
|
254
|
+
const holdId = randomUUID();
|
|
255
|
+
const { holdRecord } = await enclave.createHold({
|
|
256
|
+
envelope,
|
|
257
|
+
holdId,
|
|
258
|
+
expectedDid: did,
|
|
259
|
+
expectedShareVersion: shareVersion,
|
|
260
|
+
enrollmentEpoch: epoch,
|
|
261
|
+
releasePolicy: 'pin',
|
|
262
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
263
|
+
});
|
|
264
|
+
return enclave.releaseEscrow({
|
|
265
|
+
envelope,
|
|
266
|
+
hold: holdRecord,
|
|
267
|
+
expectedDid: did,
|
|
268
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
269
|
+
pinProof: wrongProof,
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
try {
|
|
273
|
+
await replace(
|
|
274
|
+
JSON.stringify({
|
|
275
|
+
nowMs: 1_700_000_000_000,
|
|
276
|
+
enrollments: {
|
|
277
|
+
[did]: {
|
|
278
|
+
epoch: 1,
|
|
279
|
+
shareVersion: 1,
|
|
280
|
+
blobHash: escrowBlobIdentity(sourceEnvelope),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
const failedAttempts = 3;
|
|
286
|
+
for (let i = 0; i < failedAttempts; i += 1) {
|
|
287
|
+
await expect(attempt(sourceEnvelope, 1, 1)).rejects.toBeInstanceOf(
|
|
288
|
+
EscrowPinMismatchError
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const targetEnvelope = await encryptEscrowBlob(
|
|
292
|
+
{ recoveryShare: 'ef'.repeat(33), did, shareVersion: 2 },
|
|
293
|
+
attestation.publicKey,
|
|
294
|
+
attestation.keyId
|
|
295
|
+
);
|
|
296
|
+
const { envelope: carried } = await enclave.carryPinVerifier({
|
|
297
|
+
sourceEnvelope,
|
|
298
|
+
targetEnvelope,
|
|
299
|
+
expectedDid: did,
|
|
300
|
+
sourceShareVersion: 1,
|
|
301
|
+
targetShareVersion: 2,
|
|
302
|
+
targetEnrollmentEpoch: 2,
|
|
303
|
+
sourceEnrollmentEpoch: 1,
|
|
304
|
+
});
|
|
305
|
+
// Every real blob write bumps enrollmentEpoch; simulate that here.
|
|
306
|
+
await replace(
|
|
307
|
+
JSON.stringify({
|
|
308
|
+
nowMs: 1_700_000_000_000,
|
|
309
|
+
enrollments: {
|
|
310
|
+
[did]: { epoch: 2, shareVersion: 2, blobHash: escrowBlobIdentity(carried) },
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
);
|
|
314
|
+
const remaining = 10 - failedAttempts;
|
|
315
|
+
for (let i = 0; i < remaining; i += 1) {
|
|
316
|
+
await expect(attempt(carried, 2, 2)).rejects.toBeInstanceOf(EscrowPinMismatchError);
|
|
317
|
+
}
|
|
318
|
+
// One more than the remaining budget: refused before comparing the
|
|
319
|
+
// PIN at all (even the CORRECT PIN fails), because 3 (carried) + 7
|
|
320
|
+
// (this epoch) already equals the ten-attempt lifetime maximum.
|
|
321
|
+
const holdId = randomUUID();
|
|
322
|
+
const { holdRecord } = await enclave.createHold({
|
|
323
|
+
envelope: carried,
|
|
324
|
+
holdId,
|
|
325
|
+
expectedDid: did,
|
|
326
|
+
expectedShareVersion: 2,
|
|
327
|
+
enrollmentEpoch: 2,
|
|
328
|
+
releasePolicy: 'pin',
|
|
329
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
330
|
+
});
|
|
331
|
+
await expect(
|
|
332
|
+
enclave.releaseEscrow({
|
|
333
|
+
envelope: carried,
|
|
334
|
+
hold: holdRecord,
|
|
335
|
+
expectedDid: did,
|
|
336
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
337
|
+
pinProof,
|
|
338
|
+
})
|
|
339
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
340
|
+
} finally {
|
|
341
|
+
await replace(original);
|
|
342
|
+
}
|
|
343
|
+
}, 20_000);
|
|
344
|
+
});
|