@learncard/lca-api-service 1.3.2 → 1.4.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 +26 -2
- package/AGENTS.md +59 -44
- package/CHANGELOG.md +41 -0
- package/compose.env +5 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +297 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/environment.d.ts +152 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/EscrowHold.d.ts +75 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +316 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/routes/escrow.d.ts +150 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +181 -7
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/services/escrow-enclave/index.d.ts +9 -0
- package/dist/services/escrow-enclave/notifications.d.ts +9 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +67 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/lambda.ts +8 -3
- package/package.json +17 -11
- package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/seedMigrationLambda.test.ts +145 -0
- package/seedMigrationLambda.ts +50 -0
- package/serverless-local.yml +3 -0
- package/serverless.escrow-env.cjs +20 -0
- package/serverless.yml +133 -0
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/app.ts +2 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/environment.test.ts +116 -0
- package/src/config/environment.ts +97 -0
- package/src/docker-entry.ts +4 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/EscrowHold.ts +225 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1024 -40
- package/src/models/index.ts +1 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +826 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1017 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
- package/src/services/escrow-enclave/README.md +51 -0
- package/src/services/escrow-enclave/index.ts +36 -0
- package/src/services/escrow-enclave/notifications.ts +14 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
- package/src/services/escrow-enclave/types.ts +76 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/credentials.spec.ts +2 -0
- package/test/keys-escrow.spec.ts +1389 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/escrow.spec.ts +210 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +40 -0
- package/vitest.config.ts +1 -1
- package/vitest.integration.config.ts +5 -0
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
package/src/routes/qr-login.ts
CHANGED
|
@@ -20,8 +20,9 @@ import crypto from 'crypto';
|
|
|
20
20
|
|
|
21
21
|
import { t, openRoute } from '@routes';
|
|
22
22
|
import cache from '@cache';
|
|
23
|
-
import { verifyAuthToken,
|
|
24
|
-
import {
|
|
23
|
+
import { verifyAuthToken, AuthProviderType } from '@helpers/auth.helpers';
|
|
24
|
+
import { findUserKeyByAuthProvider } from '@models';
|
|
25
|
+
import { checkRateLimit } from '@helpers/rateLimit.helpers';
|
|
25
26
|
import { sendPushNotification } from '@helpers/pushNotifications.helpers';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
@@ -40,47 +41,18 @@ const CODE_PREFIX = 'qr-login:code:';
|
|
|
40
41
|
const RATE_PREFIX = 'qr-login:rate:';
|
|
41
42
|
|
|
42
43
|
/** Rate limiting */
|
|
43
|
-
const CREATE_RATE_LIMIT = 10;
|
|
44
|
-
const CREATE_RATE_WINDOW = 600;
|
|
45
|
-
const LOOKUP_RATE_LIMIT = 20;
|
|
46
|
-
const LOOKUP_RATE_WINDOW = 60;
|
|
47
|
-
const APPROVE_RATE_LIMIT = 5;
|
|
48
|
-
const NOTIFY_RATE_LIMIT = 3;
|
|
49
|
-
const NOTIFY_RATE_WINDOW = 300;
|
|
44
|
+
const CREATE_RATE_LIMIT = 10; // max session creations per window
|
|
45
|
+
const CREATE_RATE_WINDOW = 600; // 10-minute window (seconds)
|
|
46
|
+
const LOOKUP_RATE_LIMIT = 20; // max lookups per window
|
|
47
|
+
const LOOKUP_RATE_WINDOW = 60; // 1-minute window (seconds)
|
|
48
|
+
const APPROVE_RATE_LIMIT = 5; // max approve attempts per session
|
|
49
|
+
const NOTIFY_RATE_LIMIT = 3; // max notify calls per window
|
|
50
|
+
const NOTIFY_RATE_WINDOW = 300; // 5-minute window (seconds)
|
|
50
51
|
|
|
51
52
|
// ---------------------------------------------------------------------------
|
|
52
53
|
// Helpers
|
|
53
54
|
// ---------------------------------------------------------------------------
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* Check and increment a rate-limit counter in Redis.
|
|
57
|
-
* Returns true if the request is allowed, false if rate-limited.
|
|
58
|
-
*
|
|
59
|
-
* Uses atomic INCR + EXPIRE to avoid TOCTOU races where two concurrent
|
|
60
|
-
* requests could both read the same counter value and both pass.
|
|
61
|
-
*/
|
|
62
|
-
const checkRateLimit = async (
|
|
63
|
-
key: string,
|
|
64
|
-
maxAttempts: number,
|
|
65
|
-
windowSeconds: number
|
|
66
|
-
): Promise<boolean> => {
|
|
67
|
-
const fullKey = `${RATE_PREFIX}${key}`;
|
|
68
|
-
const redis = cache.redis ?? cache.node;
|
|
69
|
-
|
|
70
|
-
// INCR is atomic — returns the new value after incrementing.
|
|
71
|
-
// If the key doesn't exist, Redis creates it with value 1.
|
|
72
|
-
const current = await redis.incr(fullKey);
|
|
73
|
-
|
|
74
|
-
// First request for this window — set the TTL
|
|
75
|
-
if (current === 1) {
|
|
76
|
-
await redis.expire(fullKey, windowSeconds);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (current > maxAttempts) return false;
|
|
80
|
-
|
|
81
|
-
return true;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
56
|
const generateSessionId = (): string => crypto.randomUUID();
|
|
85
57
|
|
|
86
58
|
const generateShortCode = (): string => {
|
|
@@ -149,7 +121,14 @@ export const qrLoginRouter = t.router({
|
|
|
149
121
|
// Rate limit by IP — max 10 sessions per 10 minutes
|
|
150
122
|
const clientIp = ctx.clientIp ?? 'unknown';
|
|
151
123
|
|
|
152
|
-
if (
|
|
124
|
+
if (
|
|
125
|
+
!(await checkRateLimit(
|
|
126
|
+
`create:${clientIp}`,
|
|
127
|
+
CREATE_RATE_LIMIT,
|
|
128
|
+
CREATE_RATE_WINDOW,
|
|
129
|
+
RATE_PREFIX
|
|
130
|
+
))
|
|
131
|
+
) {
|
|
153
132
|
throw new Error('Too many session requests. Please try again later.');
|
|
154
133
|
}
|
|
155
134
|
|
|
@@ -215,7 +194,14 @@ export const qrLoginRouter = t.router({
|
|
|
215
194
|
if (/^\d{8}$/.test(input.lookup)) {
|
|
216
195
|
const clientIp = ctx.clientIp ?? 'unknown';
|
|
217
196
|
|
|
218
|
-
if (
|
|
197
|
+
if (
|
|
198
|
+
!(await checkRateLimit(
|
|
199
|
+
`lookup:${clientIp}`,
|
|
200
|
+
LOOKUP_RATE_LIMIT,
|
|
201
|
+
LOOKUP_RATE_WINDOW,
|
|
202
|
+
RATE_PREFIX
|
|
203
|
+
))
|
|
204
|
+
) {
|
|
219
205
|
throw new Error('Too many lookup attempts. Please try again later.');
|
|
220
206
|
}
|
|
221
207
|
}
|
|
@@ -281,7 +267,14 @@ export const qrLoginRouter = t.router({
|
|
|
281
267
|
.output(z.object({ success: z.boolean() }))
|
|
282
268
|
.mutation(async ({ input, ctx: _ctx }) => {
|
|
283
269
|
// Rate limit approve attempts per session to prevent brute-force
|
|
284
|
-
if (
|
|
270
|
+
if (
|
|
271
|
+
!(await checkRateLimit(
|
|
272
|
+
`approve:${input.sessionId}`,
|
|
273
|
+
APPROVE_RATE_LIMIT,
|
|
274
|
+
SESSION_TTL_SECONDS,
|
|
275
|
+
RATE_PREFIX
|
|
276
|
+
))
|
|
277
|
+
) {
|
|
285
278
|
throw new Error('Too many approve attempts for this session.');
|
|
286
279
|
}
|
|
287
280
|
|
|
@@ -326,7 +319,7 @@ export const qrLoginRouter = t.router({
|
|
|
326
319
|
method: 'POST',
|
|
327
320
|
path: '/qr-login/notify',
|
|
328
321
|
tags: ['QR Login'],
|
|
329
|
-
summary:
|
|
322
|
+
summary: "Send a device link push notification to the user's other devices",
|
|
330
323
|
},
|
|
331
324
|
})
|
|
332
325
|
.input(
|
|
@@ -347,21 +340,26 @@ export const qrLoginRouter = t.router({
|
|
|
347
340
|
.output(z.object({ sent: z.boolean(), deviceCount: z.number() }))
|
|
348
341
|
.mutation(async ({ input, ctx: _ctx }) => {
|
|
349
342
|
// Rate limit notify calls per session
|
|
350
|
-
if (
|
|
343
|
+
if (
|
|
344
|
+
!(await checkRateLimit(
|
|
345
|
+
`notify:${input.sessionId}`,
|
|
346
|
+
NOTIFY_RATE_LIMIT,
|
|
347
|
+
NOTIFY_RATE_WINDOW,
|
|
348
|
+
RATE_PREFIX
|
|
349
|
+
))
|
|
350
|
+
) {
|
|
351
351
|
return { sent: false, deviceCount: 0 };
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
// Verify the auth token and get the user
|
|
355
|
-
const user = await verifyAuthToken(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// Look up the user's DID from their key record
|
|
364
|
-
const userKey = await findUserKeyByContactMethod(contactMethod);
|
|
355
|
+
const user = await verifyAuthToken(
|
|
356
|
+
input.authToken,
|
|
357
|
+
input.providerType as AuthProviderType
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
// Provider identity is immutable; a recycled contact method must
|
|
361
|
+
// never receive notifications for a previous owner's DID.
|
|
362
|
+
const userKey = await findUserKeyByAuthProvider(input.providerType, user.id);
|
|
365
363
|
|
|
366
364
|
if (!userKey?.primaryDid) {
|
|
367
365
|
return { sent: false, deviceCount: 0 };
|
|
@@ -34,6 +34,7 @@ const LOCAL_TEMPLATE_ALIASES: Record<string, TemplateId> = {
|
|
|
34
34
|
'guardian-approval': 'guardian-approval',
|
|
35
35
|
'account-approved': 'account-approved',
|
|
36
36
|
'contact-method-verification': 'contact-method-verification',
|
|
37
|
+
'account-sign-in-changed': 'account-sign-in-changed',
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
/** Whether the given alias is a sentinel we registered (vs. a real Postmark alias). */
|
|
@@ -166,9 +167,13 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
166
167
|
recipient: model.recipient as { name?: string } | undefined,
|
|
167
168
|
};
|
|
168
169
|
|
|
170
|
+
case 'account-sign-in-changed':
|
|
171
|
+
return {};
|
|
172
|
+
|
|
169
173
|
case 'recovery-key':
|
|
170
174
|
return {
|
|
171
175
|
recoveryKey: (model.recoveryKey as string) ?? '',
|
|
176
|
+
confirmationCode: (model.confirmationCode as string) ?? '',
|
|
172
177
|
};
|
|
173
178
|
|
|
174
179
|
case 'endorsement-request':
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Escrow release policies
|
|
2
|
+
|
|
3
|
+
The same sealed recovery material supports a delayed `hold` policy and an optional
|
|
4
|
+
immediate `pin` policy. PIN verification and release are a single enclave operation;
|
|
5
|
+
a PIN hold cannot release without a matching proof, even after its release time.
|
|
6
|
+
The verifier is never stored in plaintext in MongoDB, logged, or included in the
|
|
7
|
+
release payload. A leaked database alone does not expose an offline PIN oracle.
|
|
8
|
+
|
|
9
|
+
## Software-mode trust boundary
|
|
10
|
+
|
|
11
|
+
The software enclave runs in the host process: the host already controls its keys,
|
|
12
|
+
clock, and execution. A host-owned MongoDB attempt counter does not weaken that
|
|
13
|
+
existing host-trusted model, but does not protect against a malicious host either.
|
|
14
|
+
The API atomically reserves one of ten lifetime attempts before claiming the hold
|
|
15
|
+
and calling the enclave. Failed releases burn the single-use hold. Exhaustion
|
|
16
|
+
disables PIN recovery and cancels pending PIN holds; delayed recovery remains
|
|
17
|
+
available. Successful PIN recovery does not replenish the lifetime budget; authenticated enrollment
|
|
18
|
+
with a new PIN starts a fresh counter. Setting or changing a PIN requires client share
|
|
19
|
+
rotation. Inbox compromise plus a correct PIN guess can recover the account
|
|
20
|
+
(at most 10/1,000,000 for a uniformly random six-digit PIN).
|
|
21
|
+
|
|
22
|
+
## Nitro requirement
|
|
23
|
+
|
|
24
|
+
The real Nitro enclave application lives on a separate branch and **must implement
|
|
25
|
+
`carryPinVerifier`** with the same contract before PIN carry is available there:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
carryPinVerifier({ sourceEnvelope, targetEnvelope, expectedDid,
|
|
29
|
+
sourceShareVersion, targetShareVersion }): Promise<{ envelope }>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Decrypt both envelopes inside the enclave. Require both DIDs to equal `expectedDid`,
|
|
33
|
+
both versions to match their expected versions, a strictly increasing target version,
|
|
34
|
+
a source PIN verifier, and no target verifier. Reject mismatches with `EscrowBlobError`
|
|
35
|
+
(`Invalid escrow payload.`). Seal target plaintext plus the source verifier to the
|
|
36
|
+
current enclave key with fresh ephemeral key and IV, using the existing envelope
|
|
37
|
+
format. Never return or log plaintext or the verifier.
|
|
38
|
+
|
|
39
|
+
Enrollment attempts carry only for PIN-less requests with an unlocked PIN matching
|
|
40
|
+
the previous blob's version and current enclave key. Persistence compares the complete
|
|
41
|
+
old `escrowBlob` and `escrowPin` subdocuments, auth-provider identity, current share
|
|
42
|
+
version, and absent opt-out marker atomically. This guards salt, counters, lock state,
|
|
43
|
+
and ciphertext against concurrent changes. Carry preserves salt, enabledAt and both
|
|
44
|
+
attempt counters, changing only the PIN share version. Unavailable/failed carry or a
|
|
45
|
+
lost compare-and-swap falls back to normal PIN-less enrollment with a secret-free warning.
|
|
46
|
+
Explicit new PIN enrollment is unchanged; explicit removal sends `clearPin: true`
|
|
47
|
+
to bypass carry (it cannot be combined with `pinSalt`).
|
|
48
|
+
|
|
49
|
+
TODO(escrow-nitro): Move the counter into enclave-internal, replicated state with
|
|
50
|
+
decrement-before-decrypt semantics and rollback protection. Do not reuse the
|
|
51
|
+
host-owned counter as the security boundary for a Nitro implementation.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
environment,
|
|
3
|
+
parseLcaApiEnvironment,
|
|
4
|
+
parseEscrowPrivateKeys,
|
|
5
|
+
type LcaApiEnvironment,
|
|
6
|
+
} from '@environment';
|
|
7
|
+
import { SoftwareEnclave } from './softwareEnclave';
|
|
8
|
+
import { EscrowUnavailableError, type EscrowEnclave } from './types';
|
|
9
|
+
|
|
10
|
+
export * from './types';
|
|
11
|
+
export * from './softwareEnclave';
|
|
12
|
+
export * from './notifications';
|
|
13
|
+
|
|
14
|
+
let enclave: EscrowEnclave | undefined;
|
|
15
|
+
let config: LcaApiEnvironment = environment;
|
|
16
|
+
|
|
17
|
+
export const isEscrowEnabled = (): boolean => Boolean(config.ESCROW_ENCLAVE_MODE);
|
|
18
|
+
export const getEscrowHoldDurationMs = (): number => config.ESCROW_HOLD_DURATION_MS;
|
|
19
|
+
export const getEscrowHoldRestartMinAgeMs = (): number => config.ESCROW_HOLD_RESTART_MIN_AGE_MS;
|
|
20
|
+
export const getEscrowEnclave = (): EscrowEnclave => {
|
|
21
|
+
if (!isEscrowEnabled()) throw new EscrowUnavailableError();
|
|
22
|
+
if (enclave) return enclave;
|
|
23
|
+
if (config.ESCROW_ENCLAVE_MODE !== 'software') throw new EscrowUnavailableError();
|
|
24
|
+
enclave = new SoftwareEnclave({
|
|
25
|
+
privateKeys: parseEscrowPrivateKeys(config.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''),
|
|
26
|
+
activeKeyId: config.ESCROW_ENCLAVE_ACTIVE_KEY_ID ?? '',
|
|
27
|
+
});
|
|
28
|
+
return enclave;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Reset both singleton and environment snapshot after test environment changes. */
|
|
32
|
+
export const __setEscrowEnclaveForTests = (replacement: EscrowEnclave | undefined): void => {
|
|
33
|
+
if (environment.NODE_ENV !== 'test') throw new EscrowUnavailableError();
|
|
34
|
+
config = parseLcaApiEnvironment(process.env);
|
|
35
|
+
enclave = replacement;
|
|
36
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EscrowHold } from '../../models/EscrowHold';
|
|
2
|
+
import type { MongoUserKeyType } from '../../models/UserKey';
|
|
3
|
+
|
|
4
|
+
export interface EscrowHoldEvent {
|
|
5
|
+
kind: 'started' | 'cancelled' | 'completed';
|
|
6
|
+
hold: EscrowHold;
|
|
7
|
+
userKey: MongoUserKeyType;
|
|
8
|
+
reason?: 'superseded';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Placeholder for notification fan-out. Never log the supplied account or hold. */
|
|
12
|
+
export const notifyEscrowHoldEvent = async (event: EscrowHoldEvent): Promise<void> => {
|
|
13
|
+
console.info('[Escrow hold notification]', { kind: event.kind, reason: event.reason });
|
|
14
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
encryptEscrowBlob,
|
|
4
|
+
generateEscrowKeyPair,
|
|
5
|
+
openEscrowRelease,
|
|
6
|
+
} from '@learncard/sss-key-manager';
|
|
7
|
+
import { SoftwareEnclave } from './softwareEnclave';
|
|
8
|
+
import {
|
|
9
|
+
EscrowBlobError,
|
|
10
|
+
EscrowPolicyError,
|
|
11
|
+
EscrowPinMismatchError,
|
|
12
|
+
type EscrowHoldForEnclave,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
describe('software enclave', () => {
|
|
16
|
+
it.each([
|
|
17
|
+
'success',
|
|
18
|
+
'source DID',
|
|
19
|
+
'target DID',
|
|
20
|
+
'expected DID',
|
|
21
|
+
'equal version',
|
|
22
|
+
'lower version',
|
|
23
|
+
'source version',
|
|
24
|
+
'target version',
|
|
25
|
+
'missing PIN',
|
|
26
|
+
'target PIN',
|
|
27
|
+
])('carries the sealed verifier with strict binding: %s', async scenario => {
|
|
28
|
+
const keys = await generateEscrowKeyPair();
|
|
29
|
+
const client = await generateEscrowKeyPair();
|
|
30
|
+
const enclave = new SoftwareEnclave({
|
|
31
|
+
privateKeys: { test: keys.privateKey },
|
|
32
|
+
activeKeyId: 'test',
|
|
33
|
+
});
|
|
34
|
+
const did = 'did:key:test';
|
|
35
|
+
const pinVerifier = 'ab'.repeat(32);
|
|
36
|
+
const targetVersion =
|
|
37
|
+
scenario === 'equal version' ? 2 : scenario === 'lower version' ? 1 : 3;
|
|
38
|
+
const sourceEnvelope = await encryptEscrowBlob(
|
|
39
|
+
{
|
|
40
|
+
recoveryShare: 'ab'.repeat(33),
|
|
41
|
+
did: scenario === 'source DID' ? 'did:key:other' : did,
|
|
42
|
+
shareVersion: 2,
|
|
43
|
+
...(scenario === 'missing PIN' ? {} : { pinVerifier }),
|
|
44
|
+
},
|
|
45
|
+
keys.publicKey,
|
|
46
|
+
'test'
|
|
47
|
+
);
|
|
48
|
+
const target = {
|
|
49
|
+
recoveryShare: 'cd'.repeat(33),
|
|
50
|
+
did: scenario === 'target DID' ? 'did:key:other' : did,
|
|
51
|
+
shareVersion: targetVersion,
|
|
52
|
+
...(scenario === 'target PIN' ? { pinVerifier } : {}),
|
|
53
|
+
};
|
|
54
|
+
const targetEnvelope = await encryptEscrowBlob(target, keys.publicKey, 'test');
|
|
55
|
+
const result = enclave.carryPinVerifier({
|
|
56
|
+
sourceEnvelope,
|
|
57
|
+
targetEnvelope,
|
|
58
|
+
expectedDid: scenario === 'expected DID' ? 'did:key:other' : did,
|
|
59
|
+
sourceShareVersion: scenario === 'source version' ? 1 : 2,
|
|
60
|
+
targetShareVersion: scenario === 'target version' ? 4 : targetVersion,
|
|
61
|
+
});
|
|
62
|
+
if (scenario !== 'success') {
|
|
63
|
+
await expect(result).rejects.toBeInstanceOf(EscrowBlobError);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const { envelope } = await result;
|
|
67
|
+
expect(envelope.ciphertext).not.toBe(targetEnvelope.ciphertext);
|
|
68
|
+
expect(
|
|
69
|
+
await enclave.verifyEscrowBlob({ envelope, expectedDid: did, expectedShareVersion: 3 })
|
|
70
|
+
).toEqual({ ok: true, hasPin: true });
|
|
71
|
+
const { sealed } = await enclave.releaseEscrow({
|
|
72
|
+
envelope,
|
|
73
|
+
expectedDid: did,
|
|
74
|
+
pinProof: pinVerifier,
|
|
75
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
76
|
+
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,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(await openEscrowRelease(sealed, client.privateKey)).toEqual({
|
|
87
|
+
...target,
|
|
88
|
+
version: 1,
|
|
89
|
+
holdId: 'carry',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('derives the attestation key, verifies enrollment, and enforces release policy', async () => {
|
|
94
|
+
const keys = await generateEscrowKeyPair();
|
|
95
|
+
const client = await generateEscrowKeyPair();
|
|
96
|
+
const enclave = new SoftwareEnclave({
|
|
97
|
+
privateKeys: { test: keys.privateKey },
|
|
98
|
+
activeKeyId: 'test',
|
|
99
|
+
});
|
|
100
|
+
const attestation = await enclave.getAttestation();
|
|
101
|
+
expect(attestation.publicKey).toBe(keys.publicKey);
|
|
102
|
+
expect(JSON.parse(Buffer.from(attestation.document, 'base64').toString())).toEqual({
|
|
103
|
+
mode: 'software',
|
|
104
|
+
keyId: 'test',
|
|
105
|
+
publicKey: keys.publicKey,
|
|
106
|
+
issuedAt: attestation.issuedAt,
|
|
107
|
+
});
|
|
108
|
+
const plaintext = { recoveryShare: 'ab'.repeat(33), did: 'did:key:test', shareVersion: 1 };
|
|
109
|
+
const envelope = await encryptEscrowBlob(plaintext, attestation.publicKey, 'test');
|
|
110
|
+
expect(
|
|
111
|
+
await enclave.verifyEscrowBlob({
|
|
112
|
+
envelope,
|
|
113
|
+
expectedDid: plaintext.did,
|
|
114
|
+
expectedShareVersion: 1,
|
|
115
|
+
})
|
|
116
|
+
).toEqual({ ok: true, hasPin: false });
|
|
117
|
+
expect(
|
|
118
|
+
await enclave.verifyEscrowBlob({
|
|
119
|
+
envelope,
|
|
120
|
+
expectedDid: 'did:key:wrong',
|
|
121
|
+
expectedShareVersion: 1,
|
|
122
|
+
})
|
|
123
|
+
).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),
|
|
130
|
+
releasePolicy: 'hold',
|
|
131
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
132
|
+
};
|
|
133
|
+
const request = {
|
|
134
|
+
envelope,
|
|
135
|
+
hold,
|
|
136
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
137
|
+
expectedDid: plaintext.did,
|
|
138
|
+
};
|
|
139
|
+
await expect(
|
|
140
|
+
enclave.releaseEscrow({ ...request, now: new Date(999) })
|
|
141
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
142
|
+
for (const changed of [
|
|
143
|
+
{ status: 'cancelled' as const },
|
|
144
|
+
{ shareVersion: 2 },
|
|
145
|
+
{ primaryDid: 'did:key:wrong' },
|
|
146
|
+
]) {
|
|
147
|
+
await expect(
|
|
148
|
+
enclave.releaseEscrow({
|
|
149
|
+
...request,
|
|
150
|
+
hold: { ...hold, ...changed },
|
|
151
|
+
now: new Date(1000),
|
|
152
|
+
})
|
|
153
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
154
|
+
}
|
|
155
|
+
await expect(
|
|
156
|
+
enclave.releaseEscrow({ ...request, expectedDid: 'did:key:wrong', now: new Date(1000) })
|
|
157
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
158
|
+
await expect(
|
|
159
|
+
enclave.releaseEscrow({
|
|
160
|
+
...request,
|
|
161
|
+
clientEphemeralPublicKey: keys.publicKey,
|
|
162
|
+
now: new Date(1000),
|
|
163
|
+
})
|
|
164
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
165
|
+
const released = await enclave.releaseEscrow({ ...request, now: new Date(1000) });
|
|
166
|
+
expect(await openEscrowRelease(released.sealed, client.privateKey)).toEqual({
|
|
167
|
+
...plaintext,
|
|
168
|
+
version: 1,
|
|
169
|
+
holdId: hold._id,
|
|
170
|
+
});
|
|
171
|
+
await expect(
|
|
172
|
+
enclave.verifyEscrowBlob({
|
|
173
|
+
envelope: { ...envelope, keyId: 'unknown' },
|
|
174
|
+
expectedDid: plaintext.did,
|
|
175
|
+
expectedShareVersion: 1,
|
|
176
|
+
})
|
|
177
|
+
).rejects.toBeInstanceOf(EscrowBlobError);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it.each([
|
|
181
|
+
{ policy: 'pin' as const, verifier: true, proof: 'ab'.repeat(32), error: undefined },
|
|
182
|
+
{
|
|
183
|
+
policy: 'pin' as const,
|
|
184
|
+
verifier: true,
|
|
185
|
+
proof: 'cd'.repeat(32),
|
|
186
|
+
error: EscrowPinMismatchError,
|
|
187
|
+
},
|
|
188
|
+
{ policy: 'pin' as const, verifier: true, proof: 'ab', error: EscrowPinMismatchError },
|
|
189
|
+
{ policy: 'pin' as const, verifier: true, proof: undefined, error: EscrowPolicyError },
|
|
190
|
+
{
|
|
191
|
+
policy: 'pin' as const,
|
|
192
|
+
verifier: false,
|
|
193
|
+
proof: 'ab'.repeat(32),
|
|
194
|
+
error: EscrowPolicyError,
|
|
195
|
+
},
|
|
196
|
+
{ policy: 'hold' as const, verifier: true, proof: 'cd'.repeat(32), error: undefined },
|
|
197
|
+
])(
|
|
198
|
+
'checks policy/proof case %# without exposing the verifier',
|
|
199
|
+
async ({ policy, verifier, proof, error }) => {
|
|
200
|
+
const keys = await generateEscrowKeyPair();
|
|
201
|
+
const client = await generateEscrowKeyPair();
|
|
202
|
+
const enclave = new SoftwareEnclave({
|
|
203
|
+
privateKeys: { test: keys.privateKey },
|
|
204
|
+
activeKeyId: 'test',
|
|
205
|
+
});
|
|
206
|
+
const plaintext = {
|
|
207
|
+
recoveryShare: 'ab'.repeat(33),
|
|
208
|
+
did: 'did:key:test',
|
|
209
|
+
shareVersion: 1,
|
|
210
|
+
};
|
|
211
|
+
const envelope = await encryptEscrowBlob(
|
|
212
|
+
{ ...plaintext, ...(verifier ? { pinVerifier: 'ab'.repeat(32) } : {}) },
|
|
213
|
+
keys.publicKey,
|
|
214
|
+
'test'
|
|
215
|
+
);
|
|
216
|
+
expect(
|
|
217
|
+
await enclave.verifyEscrowBlob({
|
|
218
|
+
envelope,
|
|
219
|
+
expectedDid: plaintext.did,
|
|
220
|
+
expectedShareVersion: 1,
|
|
221
|
+
})
|
|
222
|
+
).toEqual({ ok: true, hasPin: verifier });
|
|
223
|
+
const request = {
|
|
224
|
+
envelope,
|
|
225
|
+
expectedDid: plaintext.did,
|
|
226
|
+
clientEphemeralPublicKey: client.publicKey,
|
|
227
|
+
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
|
+
},
|
|
237
|
+
};
|
|
238
|
+
if (error) await expect(enclave.releaseEscrow(request)).rejects.toBeInstanceOf(error);
|
|
239
|
+
else {
|
|
240
|
+
const result = await enclave.releaseEscrow(request);
|
|
241
|
+
expect(await openEscrowRelease(result.sealed, client.privateKey)).toEqual({
|
|
242
|
+
...plaintext,
|
|
243
|
+
version: 1,
|
|
244
|
+
holdId: 'pin-hold',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
});
|