@learncard/lca-api-service 1.3.2 → 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 +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -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/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- 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/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -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/learnCard.signingAuthority.test.d.ts +1 -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/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/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/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- 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 +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- 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 +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- 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/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -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 +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- 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 +13 -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/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/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/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/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/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- 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 +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- 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 +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -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 +2328 -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/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- 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-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,2328 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createHmac, randomUUID } from 'crypto';
|
|
3
|
+
import { redactSecretFields } from '../src/routes';
|
|
4
|
+
import cache from '@cache';
|
|
5
|
+
import * as models from '@models';
|
|
6
|
+
import {
|
|
7
|
+
ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
8
|
+
ESCROW_PIN_MISMATCH_PATTERN,
|
|
9
|
+
escrowPinMismatchMessage,
|
|
10
|
+
} from '@learncard/types';
|
|
11
|
+
import { generateOpenApiDocument } from 'trpc-to-openapi';
|
|
12
|
+
import {
|
|
13
|
+
encryptEscrowBlob,
|
|
14
|
+
generateEscrowKeyPair,
|
|
15
|
+
openEscrowRelease,
|
|
16
|
+
splitPrivateKey,
|
|
17
|
+
reconstructFromShares,
|
|
18
|
+
type EscrowEnvelope,
|
|
19
|
+
} from '@learncard/sss-key-manager';
|
|
20
|
+
import { client } from '@mongo';
|
|
21
|
+
import { environment } from '@environment';
|
|
22
|
+
import {
|
|
23
|
+
createRecoverySession,
|
|
24
|
+
consumeRecoverySession,
|
|
25
|
+
storeRecoveryOtp,
|
|
26
|
+
} from '@cache/recoverySessions';
|
|
27
|
+
import { encryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
28
|
+
import { generateEscrowCancelToken, hashEscrowCancelToken } from '@helpers/escrowCancelToken';
|
|
29
|
+
import {
|
|
30
|
+
createUserKeysIndexes,
|
|
31
|
+
getUserKeysCollection,
|
|
32
|
+
findUserKeyByAuthProvider,
|
|
33
|
+
upsertUserKeyByAuthProvider,
|
|
34
|
+
addRecoveryMethodToUserKeyByAuthProvider,
|
|
35
|
+
getEscrowHoldsCollection,
|
|
36
|
+
findEscrowHoldById,
|
|
37
|
+
hashEscrowResumeToken,
|
|
38
|
+
type AuthProviderMapping,
|
|
39
|
+
} from '@models';
|
|
40
|
+
import { appRouter } from '../src/app';
|
|
41
|
+
import {
|
|
42
|
+
__setEscrowEnclaveForTests,
|
|
43
|
+
__resetEscrowAttestationCacheForTests,
|
|
44
|
+
getEscrowEnclave,
|
|
45
|
+
getEscrowBlobStaleReason,
|
|
46
|
+
EscrowPolicyError,
|
|
47
|
+
EscrowBlobError,
|
|
48
|
+
EscrowPinMismatchError,
|
|
49
|
+
EscrowUnavailableError,
|
|
50
|
+
type EscrowEnclave,
|
|
51
|
+
type EnclaveAttestation,
|
|
52
|
+
} from '../src/services/escrow-enclave';
|
|
53
|
+
import { getClient, getUser } from './helpers/getClient';
|
|
54
|
+
import { runEscrowHoldReminders } from '../src/jobs/escrowHoldReminders';
|
|
55
|
+
|
|
56
|
+
const makeMockToken = (email: string, uid: string): string =>
|
|
57
|
+
`header.${Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url')}.signature`;
|
|
58
|
+
const keyId = 'escrow-route-test';
|
|
59
|
+
const pk = 'b'.repeat(64);
|
|
60
|
+
let enclaveKeys: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
|
|
61
|
+
let recipient: Awaited<ReturnType<typeof generateEscrowKeyPair>>;
|
|
62
|
+
let shares: Awaited<ReturnType<typeof splitPrivateKey>>;
|
|
63
|
+
let did: string;
|
|
64
|
+
let authProvider: AuthProviderMapping;
|
|
65
|
+
let auth: { authToken: string; providerType: 'firebase' };
|
|
66
|
+
let envelope: EscrowEnvelope;
|
|
67
|
+
const owner = () => getClient({ did, isChallengeValid: true });
|
|
68
|
+
const record = () => findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
69
|
+
const enroll = () =>
|
|
70
|
+
owner().escrow.enroll({ ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId });
|
|
71
|
+
const start = () =>
|
|
72
|
+
getClient().escrow.startRecovery({ ...auth, clientEphemeralPublicKey: recipient.publicKey });
|
|
73
|
+
const resume = (hold: { holdId: string; resumeToken: string | null }) => {
|
|
74
|
+
if (!hold.resumeToken) throw new Error('Expected a new recovery request');
|
|
75
|
+
return { holdId: hold.holdId, resumeToken: hold.resumeToken };
|
|
76
|
+
};
|
|
77
|
+
const setDuration = (ms: number): void => {
|
|
78
|
+
process.env.ESCROW_HOLD_DURATION_MS = String(ms);
|
|
79
|
+
__setEscrowEnclaveForTests(undefined);
|
|
80
|
+
};
|
|
81
|
+
const advanceToRelease = (hold: { releaseAfter: string }): void => {
|
|
82
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
83
|
+
vi.setSystemTime(new Date(hold.releaseAfter));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
beforeAll(async () => {
|
|
87
|
+
process.env.IS_E2E_TEST = 'true';
|
|
88
|
+
process.env.SEED ||= 'a'.repeat(64);
|
|
89
|
+
enclaveKeys = await generateEscrowKeyPair();
|
|
90
|
+
recipient = await generateEscrowKeyPair();
|
|
91
|
+
shares = await splitPrivateKey(pk);
|
|
92
|
+
did = (await getUser(pk)).learnCard.id.did();
|
|
93
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
94
|
+
process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
|
|
95
|
+
[keyId]: enclaveKeys.privateKey,
|
|
96
|
+
});
|
|
97
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
|
|
98
|
+
// The factory seam refreshes the import-time environment snapshot as well as
|
|
99
|
+
// the singleton; the real env-configured software backend is used throughout.
|
|
100
|
+
setDuration(60_000);
|
|
101
|
+
await client.connect();
|
|
102
|
+
await createUserKeysIndexes();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
beforeEach(async () => {
|
|
106
|
+
const redis = cache.redis ?? cache.node;
|
|
107
|
+
await redis.del('escrow:pin-complete:unknown');
|
|
108
|
+
await redis.del('escrow:pin-start:unknown');
|
|
109
|
+
setDuration(60_000);
|
|
110
|
+
authProvider = { type: 'firebase', id: `escrow-route-${randomUUID()}` };
|
|
111
|
+
const email = `${authProvider.id}@example.com`;
|
|
112
|
+
auth = { authToken: makeMockToken(email, authProvider.id), providerType: 'firebase' };
|
|
113
|
+
await upsertUserKeyByAuthProvider({ type: 'email', value: email }, authProvider, {
|
|
114
|
+
primaryDid: did,
|
|
115
|
+
authShare: encryptAuthShare(
|
|
116
|
+
{ encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
117
|
+
environment.SEED
|
|
118
|
+
),
|
|
119
|
+
});
|
|
120
|
+
envelope = await encryptEscrowBlob(
|
|
121
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
|
|
122
|
+
enclaveKeys.publicKey,
|
|
123
|
+
keyId
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
vi.useRealTimers();
|
|
129
|
+
vi.restoreAllMocks();
|
|
130
|
+
await getUserKeysCollection().deleteMany({ 'authProviders.id': authProvider.id });
|
|
131
|
+
await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': authProvider.id });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterAll(async () => {
|
|
135
|
+
delete process.env.ESCROW_ENCLAVE_MODE;
|
|
136
|
+
delete process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON;
|
|
137
|
+
delete process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
|
|
138
|
+
delete process.env.ESCROW_HOLD_DURATION_MS;
|
|
139
|
+
__setEscrowEnclaveForTests(undefined);
|
|
140
|
+
await client.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('A6 escrow recovery', () => {
|
|
144
|
+
it('preserves previous key IDs in the attestation route after rotation', async () => {
|
|
145
|
+
const enclave = getEscrowEnclave();
|
|
146
|
+
const attestation = await enclave.getAttestation();
|
|
147
|
+
vi.spyOn(enclave, 'getAttestation').mockResolvedValue({
|
|
148
|
+
...attestation,
|
|
149
|
+
previousKeyIds: ['retired-key-1', 'retired-key-2'],
|
|
150
|
+
});
|
|
151
|
+
expect(await getClient().escrow.getAttestation({})).toMatchObject({
|
|
152
|
+
attestation: { ...attestation, previousKeyIds: ['retired-key-1', 'retired-key-2'] },
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('P4.2 stores the full enclave record and releases exactly that record', async () => {
|
|
157
|
+
setDuration(1);
|
|
158
|
+
await enroll();
|
|
159
|
+
const enclave = getEscrowEnclave();
|
|
160
|
+
const create = vi.spyOn(enclave, 'createHold');
|
|
161
|
+
const release = vi.spyOn(enclave, 'releaseEscrow');
|
|
162
|
+
const started = await start();
|
|
163
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
164
|
+
const created = await create.mock.results[0]!.value;
|
|
165
|
+
expect(stored?.holdRecord).toEqual(created.holdRecord);
|
|
166
|
+
expect(stored?.holdRecord).toMatchObject({
|
|
167
|
+
hold: {
|
|
168
|
+
holdId: started.holdId,
|
|
169
|
+
did,
|
|
170
|
+
shareVersion: 1,
|
|
171
|
+
enrollmentEpoch: 1,
|
|
172
|
+
blobHash: (await record())?.escrowBlob?.blobHash,
|
|
173
|
+
releasePolicy: 'hold',
|
|
174
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
175
|
+
signature: 'software-mode-unsigned',
|
|
176
|
+
createdLo: expect.any(Number),
|
|
177
|
+
createdHi: expect.any(Number),
|
|
178
|
+
policyVersion: 1,
|
|
179
|
+
},
|
|
180
|
+
holdDurationMs: 1,
|
|
181
|
+
ledgerSeq: 0,
|
|
182
|
+
});
|
|
183
|
+
expect(create).toHaveBeenCalledWith({
|
|
184
|
+
envelope,
|
|
185
|
+
holdId: started.holdId,
|
|
186
|
+
expectedDid: did,
|
|
187
|
+
expectedShareVersion: 1,
|
|
188
|
+
enrollmentEpoch: 1,
|
|
189
|
+
releasePolicy: 'hold',
|
|
190
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
191
|
+
});
|
|
192
|
+
expect(Date.parse(started.releaseAfter)).toBe(
|
|
193
|
+
created.holdRecord.hold.createdHi + created.holdRecord.holdDurationMs + 60_000
|
|
194
|
+
);
|
|
195
|
+
advanceToRelease(started);
|
|
196
|
+
await getClient().escrow.completeRecovery(resume(started));
|
|
197
|
+
expect(release).toHaveBeenCalledWith(expect.objectContaining({ hold: stored?.holdRecord }));
|
|
198
|
+
expect(release.mock.calls[0]![0].hold).not.toHaveProperty('status');
|
|
199
|
+
});
|
|
200
|
+
it('P4.2 never inserts a Mongo hold when enclave creation fails', async () => {
|
|
201
|
+
await enroll();
|
|
202
|
+
vi.spyOn(getEscrowEnclave(), 'createHold').mockRejectedValueOnce(
|
|
203
|
+
new EscrowUnavailableError()
|
|
204
|
+
);
|
|
205
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
206
|
+
expect(
|
|
207
|
+
await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
|
|
208
|
+
).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
it('P4.2 increments enrollment epochs on authenticated re-enrollment', async () => {
|
|
211
|
+
await enroll();
|
|
212
|
+
expect((await record())?.escrowBlob?.enrollmentEpoch).toBe(1);
|
|
213
|
+
await enroll();
|
|
214
|
+
expect((await record())?.escrowBlob?.enrollmentEpoch).toBe(2);
|
|
215
|
+
});
|
|
216
|
+
it.each(['did', 'link', 'remove'] as const)(
|
|
217
|
+
'P4.2 calls enclave cancel after %s cancellation and tolerates failure',
|
|
218
|
+
async kind => {
|
|
219
|
+
await enroll();
|
|
220
|
+
const started = await start();
|
|
221
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
222
|
+
let statusAtEnclaveCall: string | undefined;
|
|
223
|
+
const cancel = vi
|
|
224
|
+
.spyOn(getEscrowEnclave(), 'cancelHold')
|
|
225
|
+
.mockImplementationOnce(async () => {
|
|
226
|
+
statusAtEnclaveCall = (await findEscrowHoldById(started.holdId))?.status;
|
|
227
|
+
throw new EscrowUnavailableError();
|
|
228
|
+
});
|
|
229
|
+
const expectedCancel = {
|
|
230
|
+
envelope,
|
|
231
|
+
hold: stored?.holdRecord,
|
|
232
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
233
|
+
expectedDid: did,
|
|
234
|
+
};
|
|
235
|
+
if (kind === 'did')
|
|
236
|
+
expect(await owner().escrow.cancelRecovery(auth)).toEqual({
|
|
237
|
+
success: true,
|
|
238
|
+
cancelled: true,
|
|
239
|
+
});
|
|
240
|
+
else if (kind === 'remove') await owner().escrow.remove({ ...auth, optOut: false });
|
|
241
|
+
else {
|
|
242
|
+
const token = generateEscrowCancelToken();
|
|
243
|
+
await getEscrowHoldsCollection().updateOne(
|
|
244
|
+
{ _id: started.holdId },
|
|
245
|
+
{ $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
|
|
246
|
+
);
|
|
247
|
+
expect(
|
|
248
|
+
await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
249
|
+
).toEqual({ cancelled: true });
|
|
250
|
+
}
|
|
251
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith(expectedCancel);
|
|
252
|
+
expect(statusAtEnclaveCall).toBe('cancelled');
|
|
253
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
254
|
+
status: 'cancelled',
|
|
255
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
256
|
+
enclaveCancelAttempts: 1,
|
|
257
|
+
});
|
|
258
|
+
expect(
|
|
259
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
|
|
260
|
+
).toBeUndefined();
|
|
261
|
+
if (kind === 'remove') {
|
|
262
|
+
envelope = await encryptEscrowBlob(
|
|
263
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
|
|
264
|
+
enclaveKeys.publicKey,
|
|
265
|
+
keyId
|
|
266
|
+
);
|
|
267
|
+
await enroll();
|
|
268
|
+
}
|
|
269
|
+
const create = vi.spyOn(getEscrowEnclave(), 'createHold');
|
|
270
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
271
|
+
expect(create).not.toHaveBeenCalled();
|
|
272
|
+
await runEscrowHoldReminders();
|
|
273
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
274
|
+
status: 'cancelled',
|
|
275
|
+
enclaveCancelConfirmedAt: expect.any(Date),
|
|
276
|
+
enclaveCancelAttempts: 2,
|
|
277
|
+
});
|
|
278
|
+
expect(
|
|
279
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelPendingAt
|
|
280
|
+
).toBeUndefined();
|
|
281
|
+
expect(cancel).toHaveBeenCalledTimes(2);
|
|
282
|
+
expect(cancel).toHaveBeenLastCalledWith(expectedCancel);
|
|
283
|
+
await runEscrowHoldReminders();
|
|
284
|
+
expect(cancel).toHaveBeenCalledTimes(2);
|
|
285
|
+
await expect(start()).resolves.toMatchObject({ status: 'pending' });
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
it.each([23, -1])(
|
|
289
|
+
'keeps unavailable cancellation pending and alarms at %s hours before release',
|
|
290
|
+
async hours => {
|
|
291
|
+
await enroll();
|
|
292
|
+
const started = await start();
|
|
293
|
+
const now = new Date();
|
|
294
|
+
await getEscrowHoldsCollection().updateOne(
|
|
295
|
+
{ _id: started.holdId },
|
|
296
|
+
{
|
|
297
|
+
$set: { releaseAfter: new Date(now.getTime() + hours * 60 * 60_000) },
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
301
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(
|
|
302
|
+
new EscrowUnavailableError()
|
|
303
|
+
);
|
|
304
|
+
await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
|
|
305
|
+
success: true,
|
|
306
|
+
cancelled: true,
|
|
307
|
+
});
|
|
308
|
+
log.mockClear();
|
|
309
|
+
await runEscrowHoldReminders({ now });
|
|
310
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
311
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
312
|
+
enclaveCancelAttempts: 2,
|
|
313
|
+
});
|
|
314
|
+
expect(
|
|
315
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
|
|
316
|
+
).toBeUndefined();
|
|
317
|
+
expect(log).toHaveBeenCalledWith(
|
|
318
|
+
'[escrow-cancellation]',
|
|
319
|
+
'Escrow enclave cancellation unconfirmed within 24h of release or overdue'
|
|
320
|
+
);
|
|
321
|
+
expect(JSON.stringify(log.mock.calls)).not.toContain(authProvider.id);
|
|
322
|
+
expect(JSON.stringify(log.mock.calls)).not.toContain(envelope.ciphertext);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
it('does not mistake a policy or terminal refusal for confirmed cancellation', async () => {
|
|
327
|
+
await enroll();
|
|
328
|
+
const started = await start();
|
|
329
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(new EscrowPolicyError());
|
|
330
|
+
await owner().escrow.cancelRecovery(auth);
|
|
331
|
+
await runEscrowHoldReminders();
|
|
332
|
+
expect(
|
|
333
|
+
(await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt
|
|
334
|
+
).toBeUndefined();
|
|
335
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('refuses a replacement enclave hold when superseded cancellation is unavailable', async () => {
|
|
339
|
+
await enroll();
|
|
340
|
+
const started = await start();
|
|
341
|
+
await getEscrowHoldsCollection().updateOne(
|
|
342
|
+
{ _id: started.holdId },
|
|
343
|
+
{
|
|
344
|
+
$set: { requestedAt: new Date(Date.now() - 2 * 24 * 60 * 60_000) },
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
const create = vi.spyOn(getEscrowEnclave(), 'createHold');
|
|
348
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValue(new EscrowUnavailableError());
|
|
349
|
+
await expect(
|
|
350
|
+
getClient().escrow.startRecovery({
|
|
351
|
+
...auth,
|
|
352
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
353
|
+
restart: true,
|
|
354
|
+
})
|
|
355
|
+
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
356
|
+
expect(create).not.toHaveBeenCalled();
|
|
357
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
358
|
+
status: 'cancelled',
|
|
359
|
+
cancelReason: 'superseded',
|
|
360
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('P4.2 rejects legacy release records but still permits cancelling them', async () => {
|
|
365
|
+
setDuration(1);
|
|
366
|
+
await enroll();
|
|
367
|
+
const started = await start();
|
|
368
|
+
await getEscrowHoldsCollection().updateOne(
|
|
369
|
+
{ _id: started.holdId },
|
|
370
|
+
{ $unset: { holdRecord: '' } }
|
|
371
|
+
);
|
|
372
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
373
|
+
const cancel = vi.spyOn(getEscrowEnclave(), 'cancelHold');
|
|
374
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
375
|
+
code: 'FORBIDDEN',
|
|
376
|
+
message: 'This recovery request is no longer valid.',
|
|
377
|
+
});
|
|
378
|
+
expect(release).not.toHaveBeenCalled();
|
|
379
|
+
await owner().escrow.cancelRecovery(auth);
|
|
380
|
+
expect(cancel).not.toHaveBeenCalled();
|
|
381
|
+
expect((await findEscrowHoldById(started.holdId))?.status).toBe('cancelled');
|
|
382
|
+
});
|
|
383
|
+
it('1: exposes the configured software attestation and exact OpenAPI routes', async () => {
|
|
384
|
+
const result = await getClient().escrow.getAttestation({});
|
|
385
|
+
expect(result.attestation.mode).toBe('software');
|
|
386
|
+
expect(result.attestation.publicKey === enclaveKeys.publicKey).toBe(true);
|
|
387
|
+
expect(result.holdDurationMs).toBe(60_000);
|
|
388
|
+
const document = generateOpenApiDocument(appRouter, {
|
|
389
|
+
title: 'Test',
|
|
390
|
+
version: '1',
|
|
391
|
+
baseUrl: 'https://example.com',
|
|
392
|
+
});
|
|
393
|
+
for (const [path, method] of [
|
|
394
|
+
['/keys/escrow/attestation', 'get'],
|
|
395
|
+
['/keys/escrow', 'post'],
|
|
396
|
+
['/keys/escrow', 'delete'],
|
|
397
|
+
['/keys/escrow/opt-in', 'post'],
|
|
398
|
+
['/keys/escrow/recover', 'post'],
|
|
399
|
+
['/keys/escrow/status', 'get'],
|
|
400
|
+
['/keys/escrow/cancel', 'post'],
|
|
401
|
+
['/keys/escrow/cancel-link', 'post'],
|
|
402
|
+
['/keys/escrow/complete', 'post'],
|
|
403
|
+
] as const)
|
|
404
|
+
expect(document.paths?.[path]?.[method]).toBeDefined();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('passes a hex-decoded nonce through to the enclave', async () => {
|
|
408
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
409
|
+
const nonceHex = 'ab'.repeat(32);
|
|
410
|
+
|
|
411
|
+
await getClient().escrow.getAttestation({ nonce: nonceHex });
|
|
412
|
+
|
|
413
|
+
expect(spy).toHaveBeenCalledWith(Uint8Array.from(Buffer.from(nonceHex, 'hex')));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('rejects attestation without a nonce when the mode is remote', async () => {
|
|
417
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
418
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
419
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
420
|
+
__setEscrowEnclaveForTests(undefined);
|
|
421
|
+
try {
|
|
422
|
+
await expect(getClient().escrow.getAttestation({})).rejects.toMatchObject({
|
|
423
|
+
code: 'BAD_REQUEST',
|
|
424
|
+
});
|
|
425
|
+
} finally {
|
|
426
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
427
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
428
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
429
|
+
__setEscrowEnclaveForTests(undefined);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('disables the escrow router when remote mode is missing its URL/token', async () => {
|
|
434
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
435
|
+
__setEscrowEnclaveForTests(undefined);
|
|
436
|
+
try {
|
|
437
|
+
await expect(getClient().escrow.getAttestation({})).rejects.toMatchObject({
|
|
438
|
+
code: 'PRECONDITION_FAILED',
|
|
439
|
+
message: 'Escrow recovery is not available.',
|
|
440
|
+
});
|
|
441
|
+
} finally {
|
|
442
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
443
|
+
__setEscrowEnclaveForTests(undefined);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('2: stores the verified blob and a confirmed version-matched descriptor atomically', async () => {
|
|
448
|
+
await expect(enroll()).resolves.toEqual({ success: true, shareVersion: 1 });
|
|
449
|
+
const stored = await record();
|
|
450
|
+
expect(stored?.recoveryMethods).toEqual([
|
|
451
|
+
expect.objectContaining({
|
|
452
|
+
type: 'escrow',
|
|
453
|
+
confirmationStatus: 'confirmed',
|
|
454
|
+
confirmedAt: expect.any(Date),
|
|
455
|
+
shareVersion: 1,
|
|
456
|
+
}),
|
|
457
|
+
]);
|
|
458
|
+
expect(JSON.stringify(stored?.escrowBlob?.envelope) === JSON.stringify(envelope)).toBe(
|
|
459
|
+
true
|
|
460
|
+
);
|
|
461
|
+
await expect(owner().keys.getRecoveryShare({ ...auth, type: 'escrow' })).resolves.toEqual({
|
|
462
|
+
shareVersion: 1,
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('3: rejects wrong owners, stale versions, embedded mismatches, key IDs, tampering and opt-out', async () => {
|
|
467
|
+
const input = { ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId };
|
|
468
|
+
await expect(
|
|
469
|
+
getClient({ did: 'did:key:other', isChallengeValid: true }).escrow.enroll(input)
|
|
470
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
471
|
+
await expect(owner().escrow.enroll({ ...input, shareVersion: 2 })).rejects.toMatchObject({
|
|
472
|
+
code: 'CONFLICT',
|
|
473
|
+
});
|
|
474
|
+
for (const overrides of [{ did: 'did:key:other' }, { shareVersion: 2 }]) {
|
|
475
|
+
const bad = await encryptEscrowBlob(
|
|
476
|
+
{ did, shareVersion: 1, recoveryShare: shares.recoveryShare, ...overrides },
|
|
477
|
+
enclaveKeys.publicKey,
|
|
478
|
+
keyId
|
|
479
|
+
);
|
|
480
|
+
await expect(owner().escrow.enroll({ ...input, envelope: bad })).rejects.toMatchObject({
|
|
481
|
+
code: 'BAD_REQUEST',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
await expect(
|
|
485
|
+
owner().escrow.enroll({ ...input, enclaveKeyId: 'unknown' })
|
|
486
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
487
|
+
await expect(
|
|
488
|
+
owner().escrow.enroll({ ...input, envelope: { ...envelope, keyId: 'unknown' } })
|
|
489
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
490
|
+
await expect(
|
|
491
|
+
owner().escrow.enroll({ ...input, envelope: { ...envelope, ciphertext: 'invalid' } })
|
|
492
|
+
).rejects.toMatchObject({
|
|
493
|
+
code: 'BAD_REQUEST',
|
|
494
|
+
message: 'Invalid automatic recovery material.',
|
|
495
|
+
});
|
|
496
|
+
await getUserKeysCollection().updateOne(
|
|
497
|
+
{ 'authProviders.id': authProvider.id },
|
|
498
|
+
{ $set: { escrowOptedOutAt: new Date() } }
|
|
499
|
+
);
|
|
500
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowOptedOut).toBe(true);
|
|
501
|
+
await expect(enroll()).rejects.toMatchObject({
|
|
502
|
+
code: 'CONFLICT',
|
|
503
|
+
message: 'Automatic recovery is turned off for this account.',
|
|
504
|
+
});
|
|
505
|
+
expect((await record())?.escrowBlob).toBeUndefined();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('4: creates one pending hold and never resets its clock or replaces its recipient key', async () => {
|
|
509
|
+
await enroll();
|
|
510
|
+
const first = await start();
|
|
511
|
+
const another = await generateEscrowKeyPair();
|
|
512
|
+
const second = await getClient().escrow.startRecovery({
|
|
513
|
+
...auth,
|
|
514
|
+
clientEphemeralPublicKey: another.publicKey,
|
|
515
|
+
});
|
|
516
|
+
expect(second).toEqual({ ...first, resumeToken: null });
|
|
517
|
+
const stored = await findEscrowHoldById(first.holdId);
|
|
518
|
+
expect(stored?.clientEphemeralPublicKey === recipient.publicKey).toBe(true);
|
|
519
|
+
expect(stored?.resumeTokenHash === hashEscrowResumeToken(resume(first).resumeToken)).toBe(
|
|
520
|
+
true
|
|
521
|
+
);
|
|
522
|
+
expect(stored?.cancelTokenHash).toMatch(/^[0-9a-f]{64}$/);
|
|
523
|
+
expect(stored?.identityProofType).toBe('auth-token');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('persists the caller-resolved tenant id on the hold startRecovery creates', async () => {
|
|
527
|
+
await enroll();
|
|
528
|
+
const vetpassClient = appRouter.createCaller({
|
|
529
|
+
domain: 'example.com',
|
|
530
|
+
tenant: { id: 'vetpass', emailBranding: {}, resolvedVia: 'header' as const },
|
|
531
|
+
});
|
|
532
|
+
const started = await vetpassClient.escrow.startRecovery({
|
|
533
|
+
...auth,
|
|
534
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
535
|
+
});
|
|
536
|
+
expect((await findEscrowHoldById(started.holdId))?.tenantId).toBe('vetpass');
|
|
537
|
+
// The default caller (no X-Tenant-Id) resolves to 'learncard' (getClient's own default).
|
|
538
|
+
await owner().escrow.cancelRecovery(auth);
|
|
539
|
+
const second = await start();
|
|
540
|
+
expect((await findEscrowHoldById(second.holdId))?.tenantId).toBe('learncard');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('throttles a restart within 24 hours without changing the pending hold', async () => {
|
|
544
|
+
await enroll();
|
|
545
|
+
const first = await start();
|
|
546
|
+
// The 'started' notification write is fire-and-forget; wait for it to
|
|
547
|
+
// land so this snapshot is stable before comparing against it below.
|
|
548
|
+
await vi.waitFor(async () => {
|
|
549
|
+
expect((await findEscrowHoldById(first.holdId))?.notifications).toHaveLength(1);
|
|
550
|
+
});
|
|
551
|
+
const before = await findEscrowHoldById(first.holdId);
|
|
552
|
+
await expect(
|
|
553
|
+
getClient().escrow.startRecovery({
|
|
554
|
+
...auth,
|
|
555
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
556
|
+
restart: true,
|
|
557
|
+
})
|
|
558
|
+
).rejects.toMatchObject({
|
|
559
|
+
code: 'TOO_MANY_REQUESTS',
|
|
560
|
+
message: `A recovery request was started recently. Try again after ${new Date(Date.parse(first.requestedAt) + 86_400_000).toISOString()}.`,
|
|
561
|
+
});
|
|
562
|
+
expect(await findEscrowHoldById(first.holdId)).toEqual(before);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('restarts after 24 hours with fresh secrets and a full waiting period', async () => {
|
|
566
|
+
await enroll();
|
|
567
|
+
const first = await start();
|
|
568
|
+
const another = await generateEscrowKeyPair();
|
|
569
|
+
const now = new Date(Date.parse(first.requestedAt) + 86_400_000);
|
|
570
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
571
|
+
vi.setSystemTime(now);
|
|
572
|
+
const second = await getClient().escrow.startRecovery({
|
|
573
|
+
...auth,
|
|
574
|
+
clientEphemeralPublicKey: another.publicKey,
|
|
575
|
+
restart: true,
|
|
576
|
+
});
|
|
577
|
+
expect(second.holdId).not.toBe(first.holdId);
|
|
578
|
+
expect(second.resumeToken).toBeTruthy();
|
|
579
|
+
expect(second.resumeToken).not.toBe(first.resumeToken);
|
|
580
|
+
expect(second.requestedAt).toBe(now.toISOString());
|
|
581
|
+
expect(second.releaseAfter).toBe(new Date(now.getTime() + 120_000).toISOString());
|
|
582
|
+
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
583
|
+
status: 'cancelled',
|
|
584
|
+
cancelReason: 'superseded',
|
|
585
|
+
cancelledBy: 'system',
|
|
586
|
+
});
|
|
587
|
+
expect(await findEscrowHoldById(second.holdId)).toMatchObject({
|
|
588
|
+
status: 'pending',
|
|
589
|
+
clientEphemeralPublicKey: another.publicKey,
|
|
590
|
+
resumeTokenHash: hashEscrowResumeToken(resume(second).resumeToken),
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('returns conflict rather than unusable resume secrets when a restart races', async () => {
|
|
595
|
+
await enroll();
|
|
596
|
+
vi.spyOn(models, 'createEscrowHold').mockRejectedValueOnce({ code: 11000 });
|
|
597
|
+
await expect(
|
|
598
|
+
getClient().escrow.startRecovery({
|
|
599
|
+
...auth,
|
|
600
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
601
|
+
restart: true,
|
|
602
|
+
})
|
|
603
|
+
).rejects.toMatchObject({
|
|
604
|
+
code: 'CONFLICT',
|
|
605
|
+
message: 'Recovery state changed; please retry.',
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('4: handles simultaneous starts through the partial unique index', async () => {
|
|
610
|
+
await enroll();
|
|
611
|
+
const results = await Promise.all([start(), start(), start()]);
|
|
612
|
+
expect(new Set(results.map(result => result.holdId)).size).toBe(1);
|
|
613
|
+
expect(results.filter(result => result.resumeToken !== null)).toHaveLength(1);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('supersedes a waiting hold after share rotation and escrow re-enrollment', async () => {
|
|
617
|
+
await enroll();
|
|
618
|
+
const first = await start();
|
|
619
|
+
await owner().keys.storeAuthShare({
|
|
620
|
+
...auth,
|
|
621
|
+
primaryDid: did,
|
|
622
|
+
authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
623
|
+
});
|
|
624
|
+
const replacement = await encryptEscrowBlob(
|
|
625
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
|
|
626
|
+
enclaveKeys.publicKey,
|
|
627
|
+
keyId
|
|
628
|
+
);
|
|
629
|
+
await owner().escrow.enroll({
|
|
630
|
+
...auth,
|
|
631
|
+
envelope: replacement,
|
|
632
|
+
shareVersion: 2,
|
|
633
|
+
enclaveKeyId: keyId,
|
|
634
|
+
});
|
|
635
|
+
const second = await start();
|
|
636
|
+
expect(second.holdId).not.toBe(first.holdId);
|
|
637
|
+
expect(second.resumeToken).toBeTruthy();
|
|
638
|
+
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
639
|
+
status: 'cancelled',
|
|
640
|
+
cancelReason: 'superseded',
|
|
641
|
+
});
|
|
642
|
+
expect(await findEscrowHoldById(second.holdId)).toMatchObject({
|
|
643
|
+
status: 'pending',
|
|
644
|
+
shareVersion: 2,
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it.each(['version', 'rotation', 'ciphertext', 'removed'] as const)(
|
|
649
|
+
'burns the claim without releasing material changed during claim: %s',
|
|
650
|
+
async change => {
|
|
651
|
+
setDuration(1);
|
|
652
|
+
await enroll();
|
|
653
|
+
const hold = await start();
|
|
654
|
+
advanceToRelease(hold);
|
|
655
|
+
const claim = models.completeEscrowHold;
|
|
656
|
+
vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
|
|
657
|
+
await getUserKeysCollection().updateOne(
|
|
658
|
+
{ 'authProviders.id': authProvider.id },
|
|
659
|
+
change === 'removed'
|
|
660
|
+
? { $unset: { escrowBlob: '' } }
|
|
661
|
+
: change === 'version'
|
|
662
|
+
? { $set: { 'escrowBlob.shareVersion': 2 } }
|
|
663
|
+
: change === 'rotation'
|
|
664
|
+
? { $set: { shareVersion: 2 } }
|
|
665
|
+
: { $set: { 'escrowBlob.envelope.ciphertext': 'replacement' } }
|
|
666
|
+
);
|
|
667
|
+
return claim(id);
|
|
668
|
+
});
|
|
669
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
670
|
+
await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
|
|
671
|
+
code: 'FORBIDDEN',
|
|
672
|
+
message: 'This recovery request is no longer valid.',
|
|
673
|
+
});
|
|
674
|
+
expect(release).not.toHaveBeenCalled();
|
|
675
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
676
|
+
status: 'cancelled',
|
|
677
|
+
cancelReason: 'release-failed',
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
it('5: accepts one-use recover sessions and hides invalid or unavailable identities', async () => {
|
|
683
|
+
await enroll();
|
|
684
|
+
const recoverySessionToken = await createRecoverySession({
|
|
685
|
+
scope: 'recover',
|
|
686
|
+
authProvider,
|
|
687
|
+
});
|
|
688
|
+
const started = await getClient().escrow.startRecovery({
|
|
689
|
+
recoverySessionToken,
|
|
690
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
691
|
+
});
|
|
692
|
+
expect((await findEscrowHoldById(started.holdId))?.identityProofType).toBe(
|
|
693
|
+
'recovery-session'
|
|
694
|
+
);
|
|
695
|
+
const unavailable = {
|
|
696
|
+
code: 'NOT_FOUND',
|
|
697
|
+
message: 'Automatic recovery is not available for this account.',
|
|
698
|
+
};
|
|
699
|
+
await expect(
|
|
700
|
+
getClient().escrow.startRecovery({
|
|
701
|
+
recoverySessionToken,
|
|
702
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
703
|
+
})
|
|
704
|
+
).rejects.toMatchObject(unavailable);
|
|
705
|
+
const missingSession = await createRecoverySession({
|
|
706
|
+
scope: 'recover',
|
|
707
|
+
authProvider: { type: 'firebase', id: randomUUID() },
|
|
708
|
+
});
|
|
709
|
+
await expect(
|
|
710
|
+
getClient().escrow.startRecovery({
|
|
711
|
+
recoverySessionToken: missingSession,
|
|
712
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
713
|
+
})
|
|
714
|
+
).rejects.toMatchObject(unavailable);
|
|
715
|
+
await owner().escrow.remove({ ...auth, optOut: false });
|
|
716
|
+
await expect(start()).rejects.toMatchObject(unavailable);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('6: supports header-based active-device status, token status, cancellation and idempotency', async () => {
|
|
720
|
+
await enroll();
|
|
721
|
+
const started = await start();
|
|
722
|
+
const active = appRouter.createCaller({
|
|
723
|
+
domain: 'example.com',
|
|
724
|
+
providerToken: auth.authToken,
|
|
725
|
+
tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
|
|
726
|
+
});
|
|
727
|
+
const status = await active.escrow.getStatus({ providerType: 'firebase' });
|
|
728
|
+
expect(status.hold).toEqual({
|
|
729
|
+
holdId: started.holdId,
|
|
730
|
+
status: 'pending',
|
|
731
|
+
requestedAt: started.requestedAt,
|
|
732
|
+
releaseAfter: started.releaseAfter,
|
|
733
|
+
releasePolicy: 'hold',
|
|
734
|
+
});
|
|
735
|
+
await expect(getClient().escrow.getStatus(resume(started))).resolves.toEqual(status);
|
|
736
|
+
const viaHeader = appRouter.createCaller({
|
|
737
|
+
domain: 'example.com',
|
|
738
|
+
providerToken: resume(started).resumeToken,
|
|
739
|
+
tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
|
|
740
|
+
});
|
|
741
|
+
await expect(viaHeader.escrow.getStatus({ holdId: started.holdId })).resolves.toEqual(
|
|
742
|
+
status
|
|
743
|
+
);
|
|
744
|
+
await expect(getClient().escrow.getStatus({ holdId: started.holdId })).resolves.toEqual({
|
|
745
|
+
hold: null,
|
|
746
|
+
});
|
|
747
|
+
await expect(
|
|
748
|
+
getClient().escrow.getStatus({ holdId: started.holdId, resumeToken: 'wrong' })
|
|
749
|
+
).resolves.toEqual({ hold: null });
|
|
750
|
+
await expect(
|
|
751
|
+
getClient().escrow.getStatus({ holdId: randomUUID(), resumeToken: 'wrong' })
|
|
752
|
+
).resolves.toEqual({ hold: null });
|
|
753
|
+
await expect(
|
|
754
|
+
getClient({ did: 'did:key:other', isChallengeValid: true }).escrow.cancelRecovery(auth)
|
|
755
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
756
|
+
await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
|
|
757
|
+
success: true,
|
|
758
|
+
cancelled: true,
|
|
759
|
+
});
|
|
760
|
+
await expect(owner().escrow.cancelRecovery(auth)).resolves.toEqual({
|
|
761
|
+
success: true,
|
|
762
|
+
cancelled: false,
|
|
763
|
+
});
|
|
764
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
765
|
+
code: 'FORBIDDEN',
|
|
766
|
+
message: 'This recovery request was cancelled.',
|
|
767
|
+
});
|
|
768
|
+
expect(
|
|
769
|
+
(await getClient().escrow.getStatus(resume(started))).hold?.cancelledAt
|
|
770
|
+
).toBeDefined();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
describe('escrow.cancelRecoveryByLink', () => {
|
|
774
|
+
// startRecovery never returns the plaintext cancelToken (it only ever
|
|
775
|
+
// reaches the user via the P5.4 email), so these tests install a
|
|
776
|
+
// known token's hash directly, exactly like a real cancelTokenHash
|
|
777
|
+
// written by createEscrowHold — everything else about the hold comes
|
|
778
|
+
// from the real startRecovery flow via start().
|
|
779
|
+
const setCancelToken = async (holdId: string, token: string): Promise<void> => {
|
|
780
|
+
await getEscrowHoldsCollection().updateOne(
|
|
781
|
+
{ _id: holdId },
|
|
782
|
+
{ $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
|
|
783
|
+
);
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
it('cancels a pending hold, burns the token, and leaves cancelReason unset', async () => {
|
|
787
|
+
await enroll();
|
|
788
|
+
const started = await start();
|
|
789
|
+
const token = generateEscrowCancelToken();
|
|
790
|
+
await setCancelToken(started.holdId, token);
|
|
791
|
+
await expect(
|
|
792
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
793
|
+
).resolves.toEqual({ cancelled: true });
|
|
794
|
+
const hold = await findEscrowHoldById(started.holdId);
|
|
795
|
+
expect(hold).toMatchObject({ status: 'cancelled', cancelledBy: 'link' });
|
|
796
|
+
expect(hold?.cancelTokenUsedAt).toBeInstanceOf(Date);
|
|
797
|
+
expect(hold?.cancelReason).toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('rejects reusing an already-used link', async () => {
|
|
801
|
+
await enroll();
|
|
802
|
+
const started = await start();
|
|
803
|
+
const token = generateEscrowCancelToken();
|
|
804
|
+
await setCancelToken(started.holdId, token);
|
|
805
|
+
await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token });
|
|
806
|
+
await expect(
|
|
807
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
808
|
+
).resolves.toEqual({ cancelled: false });
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it('rejects a wrong token without leaking which condition failed, and leaves the hold pending', async () => {
|
|
812
|
+
await enroll();
|
|
813
|
+
const started = await start();
|
|
814
|
+
await setCancelToken(started.holdId, generateEscrowCancelToken());
|
|
815
|
+
await expect(
|
|
816
|
+
getClient().escrow.cancelRecoveryByLink({
|
|
817
|
+
holdId: started.holdId,
|
|
818
|
+
token: generateEscrowCancelToken(),
|
|
819
|
+
})
|
|
820
|
+
).resolves.toEqual({ cancelled: false });
|
|
821
|
+
expect((await findEscrowHoldById(started.holdId))?.status).toBe('pending');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('rejects a non-pending (already completed) hold', async () => {
|
|
825
|
+
setDuration(1);
|
|
826
|
+
await enroll();
|
|
827
|
+
const started = await start();
|
|
828
|
+
const token = generateEscrowCancelToken();
|
|
829
|
+
await setCancelToken(started.holdId, token);
|
|
830
|
+
advanceToRelease(started);
|
|
831
|
+
await getClient().escrow.completeRecovery(resume(started));
|
|
832
|
+
await expect(
|
|
833
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
834
|
+
).resolves.toEqual({ cancelled: false });
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('rejects an unknown holdId', async () => {
|
|
838
|
+
await expect(
|
|
839
|
+
getClient().escrow.cancelRecoveryByLink({
|
|
840
|
+
holdId: randomUUID(),
|
|
841
|
+
token: generateEscrowCancelToken(),
|
|
842
|
+
})
|
|
843
|
+
).resolves.toEqual({ cancelled: false });
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('allows exactly one of two concurrent calls with the same link to succeed', async () => {
|
|
847
|
+
await enroll();
|
|
848
|
+
const started = await start();
|
|
849
|
+
const token = generateEscrowCancelToken();
|
|
850
|
+
await setCancelToken(started.holdId, token);
|
|
851
|
+
const results = await Promise.all([
|
|
852
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token }),
|
|
853
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token }),
|
|
854
|
+
]);
|
|
855
|
+
expect(results.filter(result => result.cancelled)).toHaveLength(1);
|
|
856
|
+
expect(results.filter(result => !result.cancelled)).toHaveLength(1);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('refuses completeRecovery for a hold cancelled via link', async () => {
|
|
860
|
+
await enroll();
|
|
861
|
+
const started = await start();
|
|
862
|
+
const token = generateEscrowCancelToken();
|
|
863
|
+
await setCancelToken(started.holdId, token);
|
|
864
|
+
await getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token });
|
|
865
|
+
await expect(
|
|
866
|
+
getClient().escrow.completeRecovery(resume(started))
|
|
867
|
+
).rejects.toMatchObject({
|
|
868
|
+
code: 'FORBIDDEN',
|
|
869
|
+
message: 'This recovery request was cancelled.',
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('7: independently enforces waiting periods in the route and the software enclave', async () => {
|
|
875
|
+
await enroll();
|
|
876
|
+
const started = await start();
|
|
877
|
+
const enclave = getEscrowEnclave();
|
|
878
|
+
const release = vi.spyOn(enclave, 'releaseEscrow');
|
|
879
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
880
|
+
code: 'FORBIDDEN',
|
|
881
|
+
message: 'This recovery request is still in its waiting period.',
|
|
882
|
+
});
|
|
883
|
+
expect(release).not.toHaveBeenCalled();
|
|
884
|
+
const hold = await findEscrowHoldById(started.holdId);
|
|
885
|
+
if (!hold) throw new Error('Missing test hold');
|
|
886
|
+
await expect(
|
|
887
|
+
enclave.releaseEscrow({
|
|
888
|
+
envelope,
|
|
889
|
+
hold: hold.holdRecord,
|
|
890
|
+
expectedDid: did,
|
|
891
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
892
|
+
now: new Date(hold.holdRecord.hold.createdHi + hold.holdRecord.holdDurationMs - 1),
|
|
893
|
+
})
|
|
894
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
895
|
+
await expect(
|
|
896
|
+
enclave.releaseEscrow({
|
|
897
|
+
envelope,
|
|
898
|
+
hold: hold.holdRecord,
|
|
899
|
+
expectedDid: did,
|
|
900
|
+
clientEphemeralPublicKey: enclaveKeys.publicKey,
|
|
901
|
+
now: hold.releaseAfter,
|
|
902
|
+
})
|
|
903
|
+
).rejects.toBeInstanceOf(EscrowPolicyError);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('8: releases once after the hold, reconstructs the original private key and issues a rebind session', async () => {
|
|
907
|
+
setDuration(1);
|
|
908
|
+
await enroll();
|
|
909
|
+
const started = await start();
|
|
910
|
+
advanceToRelease(started);
|
|
911
|
+
const recovered = await getClient().escrow.completeRecovery(resume(started));
|
|
912
|
+
const opened = await openEscrowRelease(recovered.sealedShare, recipient.privateKey);
|
|
913
|
+
expect(opened.did).toBe(did);
|
|
914
|
+
expect(opened.holdId).toBe(started.holdId);
|
|
915
|
+
expect(opened.shareVersion).toBe(1);
|
|
916
|
+
expect(
|
|
917
|
+
(await reconstructFromShares([
|
|
918
|
+
opened.recoveryShare,
|
|
919
|
+
recovered.authShare.encryptedData,
|
|
920
|
+
])) === pk
|
|
921
|
+
).toBe(true);
|
|
922
|
+
expect((await findEscrowHoldById(started.holdId))?.status).toBe('completed');
|
|
923
|
+
expect(await consumeRecoverySession(recovered.rebindSessionToken, 'rebind')).toMatchObject({
|
|
924
|
+
authProvider,
|
|
925
|
+
});
|
|
926
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
927
|
+
code: 'FORBIDDEN',
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('8: concurrent completions invoke the enclave once and only terminal failures burn the hold', async () => {
|
|
932
|
+
setDuration(1);
|
|
933
|
+
await enroll();
|
|
934
|
+
const started = await start();
|
|
935
|
+
advanceToRelease(started);
|
|
936
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
937
|
+
const results = await Promise.allSettled([
|
|
938
|
+
getClient().escrow.completeRecovery(resume(started)),
|
|
939
|
+
getClient().escrow.completeRecovery(resume(started)),
|
|
940
|
+
]);
|
|
941
|
+
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
|
|
942
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
943
|
+
for (const error of [
|
|
944
|
+
new EscrowBlobError(),
|
|
945
|
+
new EscrowPolicyError(),
|
|
946
|
+
new EscrowUnavailableError(),
|
|
947
|
+
]) {
|
|
948
|
+
const next = await start();
|
|
949
|
+
advanceToRelease(next);
|
|
950
|
+
release.mockRejectedValueOnce(error);
|
|
951
|
+
await expect(getClient().escrow.completeRecovery(resume(next))).rejects.toMatchObject({
|
|
952
|
+
code:
|
|
953
|
+
error instanceof EscrowBlobError
|
|
954
|
+
? 'INTERNAL_SERVER_ERROR'
|
|
955
|
+
: error instanceof EscrowPolicyError
|
|
956
|
+
? 'FORBIDDEN'
|
|
957
|
+
: 'PRECONDITION_FAILED',
|
|
958
|
+
});
|
|
959
|
+
if (error instanceof EscrowBlobError) {
|
|
960
|
+
expect(await findEscrowHoldById(next.holdId)).toMatchObject({
|
|
961
|
+
status: 'cancelled',
|
|
962
|
+
cancelReason: 'release-failed',
|
|
963
|
+
});
|
|
964
|
+
} else {
|
|
965
|
+
expect(await findEscrowHoldById(next.holdId)).toMatchObject({ status: 'pending' });
|
|
966
|
+
await expect(
|
|
967
|
+
getClient().escrow.completeRecovery(resume(next))
|
|
968
|
+
).resolves.toHaveProperty('sealedShare');
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('9: requires another current confirmed method to opt out, cancels holds, and allows explicit opt-in', async () => {
|
|
974
|
+
await enroll();
|
|
975
|
+
const started = await start();
|
|
976
|
+
for (const method of [
|
|
977
|
+
undefined,
|
|
978
|
+
{ shareVersion: 1, confirmationStatus: 'pending' as const },
|
|
979
|
+
{ shareVersion: 2, confirmedAt: new Date() },
|
|
980
|
+
]) {
|
|
981
|
+
if (method)
|
|
982
|
+
await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
|
|
983
|
+
type: 'backup',
|
|
984
|
+
createdAt: new Date(),
|
|
985
|
+
...method,
|
|
986
|
+
});
|
|
987
|
+
await expect(owner().escrow.remove({ ...auth, optOut: true })).rejects.toMatchObject({
|
|
988
|
+
code: 'PRECONDITION_FAILED',
|
|
989
|
+
message: 'Set up another recovery method before turning off automatic recovery.',
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
await addRecoveryMethodToUserKeyByAuthProvider(authProvider, {
|
|
993
|
+
type: 'backup',
|
|
994
|
+
createdAt: new Date(),
|
|
995
|
+
confirmedAt: new Date(),
|
|
996
|
+
shareVersion: 1,
|
|
997
|
+
});
|
|
998
|
+
await expect(owner().escrow.remove({ ...auth, optOut: true })).resolves.toEqual({
|
|
999
|
+
success: true,
|
|
1000
|
+
});
|
|
1001
|
+
expect((await record())?.escrowOptedOutAt).toBeInstanceOf(Date);
|
|
1002
|
+
expect((await record())?.escrowBlob).toBeUndefined();
|
|
1003
|
+
expect((await findEscrowHoldById(started.holdId))?.status).toBe('cancelled');
|
|
1004
|
+
await expect(enroll()).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
1005
|
+
await expect(start()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
1006
|
+
await owner().escrow.optIn(auth);
|
|
1007
|
+
await expect(enroll()).resolves.toEqual({ success: true, shareVersion: 1 });
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('10: blocks bypassing escrow enrollment, confirmation, deletion and recovery', async () => {
|
|
1011
|
+
await expect(
|
|
1012
|
+
owner().keys.addRecoveryMethod({ ...auth, type: 'escrow' })
|
|
1013
|
+
).rejects.toMatchObject({
|
|
1014
|
+
code: 'BAD_REQUEST',
|
|
1015
|
+
message: 'Use the automatic recovery enrollment endpoint.',
|
|
1016
|
+
});
|
|
1017
|
+
await expect(
|
|
1018
|
+
owner().keys.confirmRecoveryMethod({ ...auth, type: 'escrow' })
|
|
1019
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
1020
|
+
await expect(
|
|
1021
|
+
owner().keys.deleteRecoveryMethod({ ...auth, type: 'escrow' })
|
|
1022
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
1023
|
+
const recoverySessionToken = await createRecoverySession({
|
|
1024
|
+
scope: 'recover',
|
|
1025
|
+
authProvider,
|
|
1026
|
+
});
|
|
1027
|
+
await expect(
|
|
1028
|
+
getClient().keys.useRecoverySession({ recoverySessionToken, type: 'escrow' })
|
|
1029
|
+
).rejects.toMatchObject({
|
|
1030
|
+
code: 'BAD_REQUEST',
|
|
1031
|
+
message: 'Use /keys/escrow/recover for automatic recovery.',
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it('11: disables every procedure before DID authorization when the mode is unset', async () => {
|
|
1036
|
+
delete process.env.ESCROW_ENCLAVE_MODE;
|
|
1037
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1038
|
+
try {
|
|
1039
|
+
const caller = getClient().escrow;
|
|
1040
|
+
const actions = [
|
|
1041
|
+
() => caller.getAttestation({}),
|
|
1042
|
+
() => caller.enroll({ ...auth, envelope, shareVersion: 1, enclaveKeyId: keyId }),
|
|
1043
|
+
() => caller.remove({ ...auth, optOut: false }),
|
|
1044
|
+
() => caller.optIn(auth),
|
|
1045
|
+
() =>
|
|
1046
|
+
caller.startRecovery({
|
|
1047
|
+
...auth,
|
|
1048
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1049
|
+
}),
|
|
1050
|
+
() => caller.getStatus(auth),
|
|
1051
|
+
() => caller.cancelRecovery(auth),
|
|
1052
|
+
() => caller.completeRecovery({ holdId: randomUUID(), resumeToken: 'invalid' }),
|
|
1053
|
+
];
|
|
1054
|
+
for (const action of actions)
|
|
1055
|
+
await expect(action()).rejects.toMatchObject({
|
|
1056
|
+
code: 'PRECONDITION_FAILED',
|
|
1057
|
+
message: 'Escrow recovery is not available.',
|
|
1058
|
+
});
|
|
1059
|
+
} finally {
|
|
1060
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
1061
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('rejects invalid tokens and removed, unconfirmed, replaced or expired recovery material', async () => {
|
|
1066
|
+
setDuration(1);
|
|
1067
|
+
await enroll();
|
|
1068
|
+
const started = await start();
|
|
1069
|
+
advanceToRelease(started);
|
|
1070
|
+
for (const holdId of [started.holdId, randomUUID()]) {
|
|
1071
|
+
await expect(
|
|
1072
|
+
getClient().escrow.completeRecovery({ holdId, resumeToken: 'wrong' })
|
|
1073
|
+
).rejects.toMatchObject({
|
|
1074
|
+
code: 'FORBIDDEN',
|
|
1075
|
+
message: 'This recovery request is invalid.',
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
await getUserKeysCollection().updateOne(
|
|
1079
|
+
{ 'authProviders.id': authProvider.id },
|
|
1080
|
+
{
|
|
1081
|
+
$set: { 'escrowBlob.shareVersion': 2 },
|
|
1082
|
+
}
|
|
1083
|
+
);
|
|
1084
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
1085
|
+
code: 'FORBIDDEN',
|
|
1086
|
+
});
|
|
1087
|
+
await enroll();
|
|
1088
|
+
await getUserKeysCollection().updateOne(
|
|
1089
|
+
{ 'authProviders.id': authProvider.id },
|
|
1090
|
+
{ $set: { recoveryMethods: [] } }
|
|
1091
|
+
);
|
|
1092
|
+
await expect(start()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
1093
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
1094
|
+
code: 'FORBIDDEN',
|
|
1095
|
+
});
|
|
1096
|
+
await enroll();
|
|
1097
|
+
await getEscrowHoldsCollection().updateOne(
|
|
1098
|
+
{ _id: started.holdId },
|
|
1099
|
+
{
|
|
1100
|
+
$set: { releaseAfter: new Date(Date.now() - 31 * 86_400_000) },
|
|
1101
|
+
}
|
|
1102
|
+
);
|
|
1103
|
+
expect((await getClient().escrow.getStatus(resume(started))).hold?.status).toBe('expired');
|
|
1104
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
1105
|
+
code: 'FORBIDDEN',
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
describe('escrow PIN release', () => {
|
|
1111
|
+
const pinProof = 'ab'.repeat(32);
|
|
1112
|
+
const wrongProof = 'cd'.repeat(32);
|
|
1113
|
+
const pinSalt = Buffer.alloc(16, 7).toString('base64');
|
|
1114
|
+
const enrollPin = async () => {
|
|
1115
|
+
envelope = await encryptEscrowBlob(
|
|
1116
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 1, pinVerifier: pinProof },
|
|
1117
|
+
enclaveKeys.publicKey,
|
|
1118
|
+
keyId
|
|
1119
|
+
);
|
|
1120
|
+
return owner().escrow.enroll({
|
|
1121
|
+
...auth,
|
|
1122
|
+
envelope,
|
|
1123
|
+
shareVersion: 1,
|
|
1124
|
+
enclaveKeyId: keyId,
|
|
1125
|
+
pinSalt,
|
|
1126
|
+
});
|
|
1127
|
+
};
|
|
1128
|
+
const startPin = () =>
|
|
1129
|
+
getClient().escrow.startRecovery({
|
|
1130
|
+
...auth,
|
|
1131
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1132
|
+
releasePolicy: 'pin',
|
|
1133
|
+
});
|
|
1134
|
+
const completePin = (hold: Parameters<typeof resume>[0], proof = pinProof) =>
|
|
1135
|
+
getClient().escrow.completeRecovery({ ...resume(hold), pinProof: proof });
|
|
1136
|
+
|
|
1137
|
+
it('durably revokes a PIN-locked hold before allowing another recovery hold', async () => {
|
|
1138
|
+
await enrollPin();
|
|
1139
|
+
const started = await startPin();
|
|
1140
|
+
await getUserKeysCollection().updateOne(
|
|
1141
|
+
{ 'authProviders.id': authProvider.id },
|
|
1142
|
+
{
|
|
1143
|
+
$set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 },
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
vi.spyOn(getEscrowEnclave(), 'cancelHold').mockRejectedValueOnce(
|
|
1147
|
+
new EscrowUnavailableError()
|
|
1148
|
+
);
|
|
1149
|
+
await expect(completePin(started, wrongProof)).rejects.toMatchObject({
|
|
1150
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1151
|
+
});
|
|
1152
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({
|
|
1153
|
+
status: 'cancelled',
|
|
1154
|
+
cancelReason: 'pin-locked',
|
|
1155
|
+
enclaveCancelPendingAt: expect.any(Date),
|
|
1156
|
+
enclaveCancelAttempts: 1,
|
|
1157
|
+
});
|
|
1158
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
1159
|
+
await runEscrowHoldReminders();
|
|
1160
|
+
expect((await findEscrowHoldById(started.holdId))?.enclaveCancelConfirmedAt).toBeInstanceOf(
|
|
1161
|
+
Date
|
|
1162
|
+
);
|
|
1163
|
+
await expect(start()).resolves.toMatchObject({ status: 'pending' });
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it.each([
|
|
1167
|
+
'success',
|
|
1168
|
+
'locked',
|
|
1169
|
+
'key mismatch',
|
|
1170
|
+
'carry failure',
|
|
1171
|
+
'concurrent counter',
|
|
1172
|
+
'concurrent lock',
|
|
1173
|
+
'concurrent blob',
|
|
1174
|
+
'clear',
|
|
1175
|
+
'released',
|
|
1176
|
+
])('enrolls after rotation with safe PIN carry: %s', async scenario => {
|
|
1177
|
+
await enrollPin();
|
|
1178
|
+
if (scenario === 'released') await completePin(await startPin());
|
|
1179
|
+
await getUserKeysCollection().updateOne(
|
|
1180
|
+
{ 'authProviders.id': authProvider.id },
|
|
1181
|
+
{
|
|
1182
|
+
$set: {
|
|
1183
|
+
'escrowPin.failedAttempts': 3,
|
|
1184
|
+
'escrowPin.verifiedFailedAttempts': 2,
|
|
1185
|
+
...(scenario === 'locked' ? { 'escrowPin.disabledAt': new Date() } : {}),
|
|
1186
|
+
...(scenario === 'key mismatch'
|
|
1187
|
+
? { 'escrowBlob.enclaveKeyId': 'old-key' }
|
|
1188
|
+
: {}),
|
|
1189
|
+
},
|
|
1190
|
+
}
|
|
1191
|
+
);
|
|
1192
|
+
await owner().keys.storeAuthShare({
|
|
1193
|
+
...auth,
|
|
1194
|
+
primaryDid: did,
|
|
1195
|
+
authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
1196
|
+
});
|
|
1197
|
+
const replacement = await encryptEscrowBlob(
|
|
1198
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
|
|
1199
|
+
enclaveKeys.publicKey,
|
|
1200
|
+
keyId
|
|
1201
|
+
);
|
|
1202
|
+
const enclave = getEscrowEnclave();
|
|
1203
|
+
const carry = enclave.carryPinVerifier.bind(enclave);
|
|
1204
|
+
const spy = vi.spyOn(enclave, 'carryPinVerifier');
|
|
1205
|
+
if (scenario === 'carry failure') spy.mockRejectedValueOnce(new EscrowBlobError());
|
|
1206
|
+
if (scenario.startsWith('concurrent'))
|
|
1207
|
+
spy.mockImplementationOnce(async input => {
|
|
1208
|
+
await getUserKeysCollection().updateOne(
|
|
1209
|
+
{ 'authProviders.id': authProvider.id },
|
|
1210
|
+
{
|
|
1211
|
+
$set:
|
|
1212
|
+
scenario === 'concurrent counter'
|
|
1213
|
+
? { 'escrowPin.failedAttempts': 4 }
|
|
1214
|
+
: scenario === 'concurrent lock'
|
|
1215
|
+
? { 'escrowPin.disabledAt': new Date() }
|
|
1216
|
+
: { 'escrowBlob.envelope.ciphertext': 'changed' },
|
|
1217
|
+
}
|
|
1218
|
+
);
|
|
1219
|
+
return carry(input);
|
|
1220
|
+
});
|
|
1221
|
+
await expect(
|
|
1222
|
+
owner().escrow.enroll({
|
|
1223
|
+
...auth,
|
|
1224
|
+
envelope: replacement,
|
|
1225
|
+
shareVersion: 2,
|
|
1226
|
+
enclaveKeyId: keyId,
|
|
1227
|
+
...(scenario === 'clear' ? { clearPin: true } : {}),
|
|
1228
|
+
})
|
|
1229
|
+
).resolves.toEqual({ success: true, shareVersion: 2 });
|
|
1230
|
+
const stored = await record();
|
|
1231
|
+
if (scenario === 'success') {
|
|
1232
|
+
expect(stored?.escrowPin).toMatchObject({
|
|
1233
|
+
salt: pinSalt,
|
|
1234
|
+
shareVersion: 2,
|
|
1235
|
+
failedAttempts: 3,
|
|
1236
|
+
verifiedFailedAttempts: 2,
|
|
1237
|
+
});
|
|
1238
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowPin?.enabled).toBe(true);
|
|
1239
|
+
const hold = await startPin();
|
|
1240
|
+
expect(hold.pinSalt).toBe(pinSalt);
|
|
1241
|
+
const released = await completePin(hold);
|
|
1242
|
+
expect(
|
|
1243
|
+
await openEscrowRelease(released.sealedShare, recipient.privateKey)
|
|
1244
|
+
).toMatchObject({ recoveryShare: shares.recoveryShare, shareVersion: 2 });
|
|
1245
|
+
} else {
|
|
1246
|
+
expect(stored?.escrowPin).toBeUndefined();
|
|
1247
|
+
expect(
|
|
1248
|
+
await enclave.verifyEscrowBlob({
|
|
1249
|
+
envelope: stored!.escrowBlob!.envelope,
|
|
1250
|
+
expectedDid: did,
|
|
1251
|
+
expectedShareVersion: 2,
|
|
1252
|
+
})
|
|
1253
|
+
).toEqual({ ok: true, hasPin: false });
|
|
1254
|
+
if (['locked', 'key mismatch', 'clear', 'released'].includes(scenario))
|
|
1255
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
// P9.2: the enroll route's carry gate accepts the CURRENT keyId OR any of
|
|
1260
|
+
// the attested previousKeyIds (deliberate rotation / lost-sealed-key
|
|
1261
|
+
// recovery) — "key mismatch" above already proves a truly UNRECOGNISED
|
|
1262
|
+
// keyId still drops the PIN; this proves a RECOGNISED previous keyId
|
|
1263
|
+
// carries it, preserving the exact host-owned attempt counters, and that
|
|
1264
|
+
// the carried PIN still releases with the ORIGINAL proof afterward.
|
|
1265
|
+
it('carries the PIN from a still-accepted previous key and preserves its attempt counters', async () => {
|
|
1266
|
+
const previousKeyId = 'retired-key';
|
|
1267
|
+
const previousKeys = await generateEscrowKeyPair();
|
|
1268
|
+
const bothKeys = JSON.stringify({
|
|
1269
|
+
[keyId]: enclaveKeys.privateKey,
|
|
1270
|
+
[previousKeyId]: previousKeys.privateKey,
|
|
1271
|
+
});
|
|
1272
|
+
// Enroll while `previousKeyId` is still the ACTIVE key (as it genuinely
|
|
1273
|
+
// would have been before a rotation) — the route always requires a
|
|
1274
|
+
// freshly-enrolled envelope to match the CURRENT attestation key; only
|
|
1275
|
+
// an already-stored blob may later be carried from a previous one.
|
|
1276
|
+
process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = bothKeys;
|
|
1277
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = previousKeyId;
|
|
1278
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1279
|
+
try {
|
|
1280
|
+
const oldEnvelope = await encryptEscrowBlob(
|
|
1281
|
+
{
|
|
1282
|
+
recoveryShare: shares.recoveryShare,
|
|
1283
|
+
did,
|
|
1284
|
+
shareVersion: 1,
|
|
1285
|
+
pinVerifier: pinProof,
|
|
1286
|
+
},
|
|
1287
|
+
previousKeys.publicKey,
|
|
1288
|
+
previousKeyId
|
|
1289
|
+
);
|
|
1290
|
+
await owner().escrow.enroll({
|
|
1291
|
+
...auth,
|
|
1292
|
+
envelope: oldEnvelope,
|
|
1293
|
+
shareVersion: 1,
|
|
1294
|
+
enclaveKeyId: previousKeyId,
|
|
1295
|
+
pinSalt,
|
|
1296
|
+
});
|
|
1297
|
+
// Rotate: `keyId` becomes the current key, `previousKeyId` becomes a
|
|
1298
|
+
// recognised-but-retired previous key (P9.1).
|
|
1299
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
|
|
1300
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1301
|
+
await getUserKeysCollection().updateOne(
|
|
1302
|
+
{ 'authProviders.id': authProvider.id },
|
|
1303
|
+
{ $set: { 'escrowPin.failedAttempts': 3, 'escrowPin.verifiedFailedAttempts': 2 } }
|
|
1304
|
+
);
|
|
1305
|
+
await owner().keys.storeAuthShare({
|
|
1306
|
+
...auth,
|
|
1307
|
+
primaryDid: did,
|
|
1308
|
+
authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
1309
|
+
});
|
|
1310
|
+
const replacement = await encryptEscrowBlob(
|
|
1311
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 2 },
|
|
1312
|
+
enclaveKeys.publicKey,
|
|
1313
|
+
keyId
|
|
1314
|
+
);
|
|
1315
|
+
await expect(
|
|
1316
|
+
owner().escrow.enroll({
|
|
1317
|
+
...auth,
|
|
1318
|
+
envelope: replacement,
|
|
1319
|
+
shareVersion: 2,
|
|
1320
|
+
enclaveKeyId: keyId,
|
|
1321
|
+
})
|
|
1322
|
+
).resolves.toEqual({ success: true, shareVersion: 2 });
|
|
1323
|
+
const stored = await record();
|
|
1324
|
+
expect(stored?.escrowPin).toMatchObject({
|
|
1325
|
+
shareVersion: 2,
|
|
1326
|
+
failedAttempts: 3,
|
|
1327
|
+
verifiedFailedAttempts: 2,
|
|
1328
|
+
});
|
|
1329
|
+
const hold = await startPin();
|
|
1330
|
+
expect(hold.pinSalt).toBe(pinSalt);
|
|
1331
|
+
const released = await completePin(hold);
|
|
1332
|
+
expect(
|
|
1333
|
+
await openEscrowRelease(released.sealedShare, recipient.privateKey)
|
|
1334
|
+
).toMatchObject({ recoveryShare: shares.recoveryShare, shareVersion: 2 });
|
|
1335
|
+
} finally {
|
|
1336
|
+
process.env.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON = JSON.stringify({
|
|
1337
|
+
[keyId]: enclaveKeys.privateKey,
|
|
1338
|
+
});
|
|
1339
|
+
process.env.ESCROW_ENCLAVE_ACTIVE_KEY_ID = keyId;
|
|
1340
|
+
__setEscrowEnclaveForTests(undefined);
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it('rejects mismatched verifier/salt enrollment and invalid salts', async () => {
|
|
1345
|
+
await expect(
|
|
1346
|
+
owner().escrow.enroll({
|
|
1347
|
+
...auth,
|
|
1348
|
+
envelope,
|
|
1349
|
+
shareVersion: 1,
|
|
1350
|
+
enclaveKeyId: keyId,
|
|
1351
|
+
pinSalt,
|
|
1352
|
+
})
|
|
1353
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
1354
|
+
await enrollPin();
|
|
1355
|
+
await expect(enroll()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
1356
|
+
for (const salt of ['invalid', Buffer.alloc(15).toString('base64'), '!'.repeat(24)]) {
|
|
1357
|
+
await expect(
|
|
1358
|
+
owner().escrow.enroll({
|
|
1359
|
+
...auth,
|
|
1360
|
+
envelope,
|
|
1361
|
+
shareVersion: 1,
|
|
1362
|
+
enclaveKeyId: keyId,
|
|
1363
|
+
pinSalt: salt,
|
|
1364
|
+
})
|
|
1365
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
1366
|
+
}
|
|
1367
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
1368
|
+
salt: pinSalt,
|
|
1369
|
+
failedAttempts: 0,
|
|
1370
|
+
shareVersion: 1,
|
|
1371
|
+
});
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('releases immediately with the correct PIN without replenishing the lifetime budget', async () => {
|
|
1375
|
+
await enrollPin();
|
|
1376
|
+
await getUserKeysCollection().updateOne(
|
|
1377
|
+
{ 'authProviders.id': authProvider.id },
|
|
1378
|
+
{ $set: { 'escrowPin.failedAttempts': 3 } }
|
|
1379
|
+
);
|
|
1380
|
+
const before = Date.now();
|
|
1381
|
+
const hold = await startPin();
|
|
1382
|
+
expect(hold.releasePolicy).toBe('pin');
|
|
1383
|
+
expect(hold.pinSalt).toBe(pinSalt);
|
|
1384
|
+
expect(Date.parse(hold.releaseAfter)).toBeGreaterThanOrEqual(before);
|
|
1385
|
+
expect(Date.parse(hold.releaseAfter)).toBeLessThanOrEqual(Date.now());
|
|
1386
|
+
expect((await getClient().escrow.getStatus(resume(hold))).hold?.releasePolicy).toBe('pin');
|
|
1387
|
+
const result = await completePin(hold);
|
|
1388
|
+
const opened = await openEscrowRelease(result.sealedShare, recipient.privateKey);
|
|
1389
|
+
expect(opened.holdId).toBe(hold.holdId);
|
|
1390
|
+
expect(opened.pinVerifier).toBeUndefined();
|
|
1391
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(4);
|
|
1392
|
+
expect((await findEscrowHoldById(hold.holdId))?.status).toBe('completed');
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
it('burns a mismatched hold; a new hold with the correct PIN succeeds', async () => {
|
|
1396
|
+
await enrollPin();
|
|
1397
|
+
const first = await startPin();
|
|
1398
|
+
await expect(completePin(first, wrongProof)).rejects.toMatchObject({
|
|
1399
|
+
code: 'FORBIDDEN',
|
|
1400
|
+
message: 'Incorrect PIN. 9 attempts left.',
|
|
1401
|
+
});
|
|
1402
|
+
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
1403
|
+
status: 'cancelled',
|
|
1404
|
+
cancelledBy: 'system',
|
|
1405
|
+
cancelReason: 'pin-mismatch',
|
|
1406
|
+
});
|
|
1407
|
+
await expect(completePin(first)).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
1408
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(1);
|
|
1409
|
+
const second = await startPin();
|
|
1410
|
+
expect(second.holdId).not.toBe(first.holdId);
|
|
1411
|
+
await completePin(second);
|
|
1412
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(2);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('locks after ten mismatches and still permits delayed recovery', async () => {
|
|
1416
|
+
await enrollPin();
|
|
1417
|
+
let lockingHoldId = '';
|
|
1418
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
1419
|
+
const hold = await startPin();
|
|
1420
|
+
lockingHoldId = hold.holdId;
|
|
1421
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
1422
|
+
code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
|
|
1423
|
+
message:
|
|
1424
|
+
attempt === 10
|
|
1425
|
+
? 'Too many incorrect PIN attempts. You can still recover by waiting.'
|
|
1426
|
+
: `Incorrect PIN. ${10 - attempt} attempts left.`,
|
|
1427
|
+
});
|
|
1428
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1429
|
+
status: 'cancelled',
|
|
1430
|
+
cancelledBy: 'system',
|
|
1431
|
+
cancelReason: attempt === 10 ? 'pin-locked' : 'pin-mismatch',
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
1435
|
+
failedAttempts: 10,
|
|
1436
|
+
disabledAt: expect.any(Date),
|
|
1437
|
+
});
|
|
1438
|
+
// notifyEscrowHoldEvent is fired-and-forgotten (`void`) so the lockout's
|
|
1439
|
+
// own request can't be blocked/failed by notification delivery; poll
|
|
1440
|
+
// until its background Mongo write of the 'pin-locked' record lands.
|
|
1441
|
+
await vi.waitFor(async () => {
|
|
1442
|
+
expect(await findEscrowHoldById(lockingHoldId)).toMatchObject({
|
|
1443
|
+
notifications: expect.arrayContaining([
|
|
1444
|
+
expect.objectContaining({ kind: 'pin-locked' }),
|
|
1445
|
+
]),
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
1449
|
+
const fallback = await start();
|
|
1450
|
+
expect(fallback.releasePolicy).toBe('hold');
|
|
1451
|
+
expect(fallback).not.toHaveProperty('pinSalt');
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it('links a PIN lockout to a concurrent waiting-period hold, and its rotated link actually cancels that hold', async () => {
|
|
1455
|
+
await enrollPin();
|
|
1456
|
+
// A delayed-recovery hold started alongside the PIN one — the path
|
|
1457
|
+
// that's still available once the PIN gets locked.
|
|
1458
|
+
const activeHold = await start();
|
|
1459
|
+
let capturedToken: string | null = null;
|
|
1460
|
+
const originalRotate = models.rotateEscrowCancelToken;
|
|
1461
|
+
vi.spyOn(models, 'rotateEscrowCancelToken').mockImplementation(async holdId => {
|
|
1462
|
+
const token = await originalRotate(holdId);
|
|
1463
|
+
if (holdId === activeHold.holdId) capturedToken = token;
|
|
1464
|
+
return token;
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
let lockingHoldId = '';
|
|
1468
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
1469
|
+
const hold = await startPin();
|
|
1470
|
+
lockingHoldId = hold.holdId;
|
|
1471
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
1472
|
+
code: attempt === 10 ? 'TOO_MANY_REQUESTS' : 'FORBIDDEN',
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
await vi.waitFor(async () => {
|
|
1477
|
+
expect(capturedToken).toMatch(/^[0-9a-f]{64}$/);
|
|
1478
|
+
// The lockout notice is still recorded on the (now dead) PIN hold, not activeHold.
|
|
1479
|
+
expect(await findEscrowHoldById(lockingHoldId)).toMatchObject({
|
|
1480
|
+
notifications: expect.arrayContaining([
|
|
1481
|
+
expect.objectContaining({ kind: 'pin-locked' }),
|
|
1482
|
+
]),
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
expect((await findEscrowHoldById(activeHold.holdId))?.notifications ?? []).not.toEqual(
|
|
1486
|
+
expect.arrayContaining([expect.objectContaining({ kind: 'pin-locked' })])
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
// The token rotated for the pin-locked email is the one that actually
|
|
1490
|
+
// cancels the waiting-period hold via the public cancel-link route.
|
|
1491
|
+
expect(
|
|
1492
|
+
await getClient().escrow.cancelRecoveryByLink({
|
|
1493
|
+
holdId: activeHold.holdId,
|
|
1494
|
+
token: capturedToken!,
|
|
1495
|
+
})
|
|
1496
|
+
).toEqual({ cancelled: true });
|
|
1497
|
+
expect((await findEscrowHoldById(activeHold.holdId))?.status).toBe('cancelled');
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
it('uses a distinct IP throttle message without consuming a PIN attempt', async () => {
|
|
1501
|
+
await enrollPin();
|
|
1502
|
+
const hold = await startPin();
|
|
1503
|
+
const redis = cache.redis ?? cache.node;
|
|
1504
|
+
await redis.set('escrow:pin-complete:unknown', '20');
|
|
1505
|
+
await expect(completePin(hold)).rejects.toMatchObject({
|
|
1506
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1507
|
+
message: 'Please wait before trying again.',
|
|
1508
|
+
});
|
|
1509
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(0);
|
|
1510
|
+
expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('refunds the tenth reservation when proof is missing without locking the PIN', async () => {
|
|
1514
|
+
await enrollPin();
|
|
1515
|
+
await getUserKeysCollection().updateOne(
|
|
1516
|
+
{ 'authProviders.id': authProvider.id },
|
|
1517
|
+
{ $set: { 'escrowPin.failedAttempts': 9 } }
|
|
1518
|
+
);
|
|
1519
|
+
const hold = await startPin();
|
|
1520
|
+
await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
|
|
1521
|
+
code: 'FORBIDDEN',
|
|
1522
|
+
});
|
|
1523
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1524
|
+
status: 'pending',
|
|
1525
|
+
});
|
|
1526
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1527
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(9);
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
it('throttles without disabling or cancelling when no reservation remains', async () => {
|
|
1531
|
+
await enrollPin();
|
|
1532
|
+
const hold = await startPin();
|
|
1533
|
+
await getUserKeysCollection().updateOne(
|
|
1534
|
+
{ 'authProviders.id': authProvider.id },
|
|
1535
|
+
{ $set: { 'escrowPin.failedAttempts': 10 } }
|
|
1536
|
+
);
|
|
1537
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
1538
|
+
await expect(completePin(hold)).rejects.toMatchObject({
|
|
1539
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1540
|
+
message: 'Please wait before trying again.',
|
|
1541
|
+
});
|
|
1542
|
+
expect(release).not.toHaveBeenCalled();
|
|
1543
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1544
|
+
status: 'pending',
|
|
1545
|
+
});
|
|
1546
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it('lets a correct tenth reservation finish while a competing completion is throttled', async () => {
|
|
1550
|
+
await enrollPin();
|
|
1551
|
+
await getUserKeysCollection().updateOne(
|
|
1552
|
+
{ 'authProviders.id': authProvider.id },
|
|
1553
|
+
{ $set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 } }
|
|
1554
|
+
);
|
|
1555
|
+
const waiting = await start();
|
|
1556
|
+
const hold = await startPin();
|
|
1557
|
+
const reserve = models.reserveEscrowPinAttempt;
|
|
1558
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
1559
|
+
vi.spyOn(models, 'reserveEscrowPinAttempt').mockImplementationOnce(async (...args) => {
|
|
1560
|
+
const reserved = await reserve(...args);
|
|
1561
|
+
expect(reserved?.escrowPin?.failedAttempts).toBe(10);
|
|
1562
|
+
await expect(completePin(hold)).rejects.toMatchObject({
|
|
1563
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1564
|
+
message: 'Please wait before trying again.',
|
|
1565
|
+
});
|
|
1566
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1567
|
+
expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
|
|
1568
|
+
return reserved;
|
|
1569
|
+
});
|
|
1570
|
+
await expect(completePin(hold)).resolves.toHaveProperty('sealedShare');
|
|
1571
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
1572
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
1573
|
+
failedAttempts: 10,
|
|
1574
|
+
verifiedFailedAttempts: 9,
|
|
1575
|
+
});
|
|
1576
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1577
|
+
expect((await findEscrowHoldById(waiting.holdId))?.status).toBe('pending');
|
|
1578
|
+
await expect(completePin(await startPin())).rejects.toMatchObject({
|
|
1579
|
+
message: 'Please wait before trying again.',
|
|
1580
|
+
});
|
|
1581
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it('does not lock on the tenth reservation mismatch while an earlier reservation is unresolved', async () => {
|
|
1585
|
+
await enrollPin();
|
|
1586
|
+
await getUserKeysCollection().updateOne(
|
|
1587
|
+
{ 'authProviders.id': authProvider.id },
|
|
1588
|
+
{ $set: { 'escrowPin.failedAttempts': 8, 'escrowPin.verifiedFailedAttempts': 8 } }
|
|
1589
|
+
);
|
|
1590
|
+
const hold = await startPin();
|
|
1591
|
+
const ciphertext = (await record())!.escrowBlob!.envelope.ciphertext;
|
|
1592
|
+
await models.reserveEscrowPinAttempt(authProvider, 1, ciphertext);
|
|
1593
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
1594
|
+
message: 'Incorrect PIN. 0 attempts left.',
|
|
1595
|
+
});
|
|
1596
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1597
|
+
await models.refundEscrowPinAttempt(authProvider, 1, ciphertext);
|
|
1598
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(9);
|
|
1599
|
+
await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
|
|
1600
|
+
message: 'Too many incorrect PIN attempts. You can still recover by waiting.',
|
|
1601
|
+
});
|
|
1602
|
+
expect((await record())?.escrowPin?.verifiedFailedAttempts).toBe(10);
|
|
1603
|
+
expect((await record())?.escrowPin?.disabledAt).toBeInstanceOf(Date);
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
it('keeps legacy charged history without treating unresolved attempts as verified failures', async () => {
|
|
1607
|
+
await enrollPin();
|
|
1608
|
+
await getUserKeysCollection().updateOne(
|
|
1609
|
+
{ 'authProviders.id': authProvider.id },
|
|
1610
|
+
{ $set: { 'escrowPin.failedAttempts': 9 } }
|
|
1611
|
+
);
|
|
1612
|
+
const hold = await startPin();
|
|
1613
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
1614
|
+
message: 'Incorrect PIN. 0 attempts left.',
|
|
1615
|
+
});
|
|
1616
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
1617
|
+
failedAttempts: 10,
|
|
1618
|
+
verifiedFailedAttempts: 1,
|
|
1619
|
+
});
|
|
1620
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1621
|
+
await expect(completePin(await startPin())).rejects.toMatchObject({
|
|
1622
|
+
message: 'Please wait before trying again.',
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it('reports the charged budget after successful evaluations followed by a mismatch', async () => {
|
|
1627
|
+
await enrollPin();
|
|
1628
|
+
for (let attempt = 0; attempt < 9; attempt++) await completePin(await startPin());
|
|
1629
|
+
await expect(completePin(await startPin(), wrongProof)).rejects.toMatchObject({
|
|
1630
|
+
message: 'Incorrect PIN. 0 attempts left.',
|
|
1631
|
+
});
|
|
1632
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1633
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowPin?.attemptsRemaining).toBe(0);
|
|
1634
|
+
await expect(completePin(await startPin())).rejects.toMatchObject({
|
|
1635
|
+
message: 'Please wait before trying again.',
|
|
1636
|
+
});
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
it('P4.2 cancels the old signed record when superseding a PIN hold', async () => {
|
|
1640
|
+
await enrollPin();
|
|
1641
|
+
const first = await startPin();
|
|
1642
|
+
const stored = await findEscrowHoldById(first.holdId);
|
|
1643
|
+
const cancel = vi.spyOn(getEscrowEnclave(), 'cancelHold');
|
|
1644
|
+
await startPin();
|
|
1645
|
+
expect(cancel).toHaveBeenCalledExactlyOnceWith({
|
|
1646
|
+
envelope,
|
|
1647
|
+
hold: stored?.holdRecord,
|
|
1648
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1649
|
+
expectedDid: did,
|
|
1650
|
+
});
|
|
1651
|
+
expect((await findEscrowHoldById(first.holdId))?.status).toBe('cancelled');
|
|
1652
|
+
});
|
|
1653
|
+
it('P4.2 lockout cancels pending linked-identity holds in the enclave', async () => {
|
|
1654
|
+
await enrollPin();
|
|
1655
|
+
const alias = { type: 'firebase' as const, id: randomUUID() };
|
|
1656
|
+
await getUserKeysCollection().updateOne(
|
|
1657
|
+
{ 'authProviders.id': authProvider.id },
|
|
1658
|
+
{
|
|
1659
|
+
$push: { authProviders: alias },
|
|
1660
|
+
$set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 },
|
|
1661
|
+
}
|
|
1662
|
+
);
|
|
1663
|
+
try {
|
|
1664
|
+
const sibling = await getClient().escrow.startRecovery({
|
|
1665
|
+
authToken: makeMockToken(`${alias.id}@example.com`, alias.id),
|
|
1666
|
+
providerType: 'firebase',
|
|
1667
|
+
releasePolicy: 'pin',
|
|
1668
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1669
|
+
});
|
|
1670
|
+
const siblingDoc = await findEscrowHoldById(sibling.holdId);
|
|
1671
|
+
const current = await startPin();
|
|
1672
|
+
const cancel = vi
|
|
1673
|
+
.spyOn(getEscrowEnclave(), 'cancelHold')
|
|
1674
|
+
.mockRejectedValueOnce(new EscrowUnavailableError());
|
|
1675
|
+
await expect(completePin(current, wrongProof)).rejects.toMatchObject({
|
|
1676
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1677
|
+
});
|
|
1678
|
+
expect(cancel).toHaveBeenCalledWith({
|
|
1679
|
+
envelope,
|
|
1680
|
+
hold: siblingDoc?.holdRecord,
|
|
1681
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1682
|
+
expectedDid: did,
|
|
1683
|
+
});
|
|
1684
|
+
expect(await findEscrowHoldById(sibling.holdId)).toMatchObject({
|
|
1685
|
+
status: 'cancelled',
|
|
1686
|
+
cancelReason: 'pin-locked',
|
|
1687
|
+
});
|
|
1688
|
+
} finally {
|
|
1689
|
+
await getEscrowHoldsCollection().deleteMany({ 'authProvider.id': alias.id });
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
it('ignores the restart flag for PIN policy and supersedes immediately', async () => {
|
|
1693
|
+
await enrollPin();
|
|
1694
|
+
const first = await startPin();
|
|
1695
|
+
const second = await getClient().escrow.startRecovery({
|
|
1696
|
+
...auth,
|
|
1697
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1698
|
+
releasePolicy: 'pin',
|
|
1699
|
+
restart: true,
|
|
1700
|
+
});
|
|
1701
|
+
expect(second.holdId).not.toBe(first.holdId);
|
|
1702
|
+
expect(second.resumeToken).toBeTruthy();
|
|
1703
|
+
expect(second.releaseAfter).toBe(second.requestedAt);
|
|
1704
|
+
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
1705
|
+
status: 'cancelled',
|
|
1706
|
+
cancelReason: 'superseded',
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
it('preserves the waiting hold while superseding only pending PIN requests', async () => {
|
|
1711
|
+
await enrollPin();
|
|
1712
|
+
const first = await start();
|
|
1713
|
+
const second = await startPin();
|
|
1714
|
+
const third = await startPin();
|
|
1715
|
+
const fourth = await start();
|
|
1716
|
+
expect(fourth).toEqual({ ...first, resumeToken: null });
|
|
1717
|
+
expect(await findEscrowHoldById(second.holdId)).toMatchObject({
|
|
1718
|
+
status: 'cancelled',
|
|
1719
|
+
cancelReason: 'superseded',
|
|
1720
|
+
cancelledBy: 'system',
|
|
1721
|
+
});
|
|
1722
|
+
expect(second.resumeToken).toBeTruthy();
|
|
1723
|
+
expect(third.resumeToken).toBeTruthy();
|
|
1724
|
+
expect(await findEscrowHoldById(first.holdId)).toMatchObject({
|
|
1725
|
+
status: 'pending',
|
|
1726
|
+
releaseAfter: new Date(first.releaseAfter),
|
|
1727
|
+
});
|
|
1728
|
+
expect(await findEscrowHoldById(third.holdId)).toMatchObject({ status: 'pending' });
|
|
1729
|
+
expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(first.holdId);
|
|
1730
|
+
await expect(getClient().escrow.cancelRecovery(auth)).rejects.toMatchObject({
|
|
1731
|
+
code: 'UNAUTHORIZED',
|
|
1732
|
+
});
|
|
1733
|
+
expect(await start()).toEqual({ ...fourth, resumeToken: null });
|
|
1734
|
+
await owner().escrow.cancelRecovery(auth);
|
|
1735
|
+
expect((await findEscrowHoldById(first.holdId))?.status).toBe('cancelled');
|
|
1736
|
+
expect((await findEscrowHoldById(third.holdId))?.status).toBe('pending');
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it('starts a waiting hold alongside a stale pending PIN hold', async () => {
|
|
1740
|
+
await enrollPin();
|
|
1741
|
+
const pin = await startPin();
|
|
1742
|
+
const hold = await start();
|
|
1743
|
+
expect(hold.releasePolicy).toBe('hold');
|
|
1744
|
+
expect(hold.resumeToken).toBeTruthy();
|
|
1745
|
+
expect((await findEscrowHoldById(pin.holdId))?.status).toBe('pending');
|
|
1746
|
+
expect((await getClient().escrow.getStatus(auth)).hold?.holdId).toBe(hold.holdId);
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
it.each([new EscrowUnavailableError(), new EscrowBlobError()])(
|
|
1750
|
+
'refunds non-mismatch enclave failures: %s',
|
|
1751
|
+
async error => {
|
|
1752
|
+
await enrollPin();
|
|
1753
|
+
await getUserKeysCollection().updateOne(
|
|
1754
|
+
{ 'authProviders.id': authProvider.id },
|
|
1755
|
+
{ $set: { 'escrowPin.failedAttempts': 9 } }
|
|
1756
|
+
);
|
|
1757
|
+
const hold = await startPin();
|
|
1758
|
+
vi.spyOn(getEscrowEnclave(), 'releaseEscrow').mockRejectedValueOnce(error);
|
|
1759
|
+
await expect(completePin(hold)).rejects.toMatchObject({
|
|
1760
|
+
code:
|
|
1761
|
+
error instanceof EscrowUnavailableError
|
|
1762
|
+
? 'PRECONDITION_FAILED'
|
|
1763
|
+
: 'INTERNAL_SERVER_ERROR',
|
|
1764
|
+
});
|
|
1765
|
+
expect((await record())?.escrowPin).toMatchObject({ failedAttempts: 9 });
|
|
1766
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1767
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1768
|
+
...(error instanceof EscrowBlobError
|
|
1769
|
+
? { status: 'cancelled', cancelReason: 'release-failed' }
|
|
1770
|
+
: { status: 'pending' }),
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
);
|
|
1774
|
+
|
|
1775
|
+
it('refunds a reservation when the hold claim loses a race', async () => {
|
|
1776
|
+
await enrollPin();
|
|
1777
|
+
await getUserKeysCollection().updateOne(
|
|
1778
|
+
{ 'authProviders.id': authProvider.id },
|
|
1779
|
+
{ $set: { 'escrowPin.failedAttempts': 9 } }
|
|
1780
|
+
);
|
|
1781
|
+
const hold = await startPin();
|
|
1782
|
+
vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
|
|
1783
|
+
await models.cancelEscrowHold(id, 'did');
|
|
1784
|
+
return null;
|
|
1785
|
+
});
|
|
1786
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
1787
|
+
await expect(completePin(hold)).rejects.toMatchObject({ code: 'CONFLICT' });
|
|
1788
|
+
expect(release).not.toHaveBeenCalled();
|
|
1789
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(9);
|
|
1790
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it.each(['claim', 'revalidation', 'cleanup'] as const)(
|
|
1794
|
+
'refunds pre-enclave reservations when %s throws',
|
|
1795
|
+
async failure => {
|
|
1796
|
+
await enrollPin();
|
|
1797
|
+
await getUserKeysCollection().updateOne(
|
|
1798
|
+
{ 'authProviders.id': authProvider.id },
|
|
1799
|
+
{ $set: { 'escrowPin.failedAttempts': 9, 'escrowPin.verifiedFailedAttempts': 9 } }
|
|
1800
|
+
);
|
|
1801
|
+
const hold = await startPin();
|
|
1802
|
+
const claim = models.completeEscrowHold;
|
|
1803
|
+
vi.spyOn(models, 'completeEscrowHold').mockImplementationOnce(async id => {
|
|
1804
|
+
if (failure === 'claim') throw new Error('claim unavailable');
|
|
1805
|
+
const completed = await claim(id);
|
|
1806
|
+
if (failure === 'revalidation') {
|
|
1807
|
+
vi.spyOn(models, 'findUserKeyByAuthProvider').mockRejectedValueOnce(
|
|
1808
|
+
new Error('lookup unavailable')
|
|
1809
|
+
);
|
|
1810
|
+
} else {
|
|
1811
|
+
vi.spyOn(models, 'findUserKeyByAuthProvider').mockResolvedValueOnce(null);
|
|
1812
|
+
vi.spyOn(models, 'markClaimedEscrowHoldFailed').mockRejectedValueOnce(
|
|
1813
|
+
new Error('cleanup unavailable')
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
return completed;
|
|
1817
|
+
});
|
|
1818
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
1819
|
+
await expect(completePin(hold)).rejects.toBeDefined();
|
|
1820
|
+
expect(release).not.toHaveBeenCalled();
|
|
1821
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
1822
|
+
failedAttempts: 9,
|
|
1823
|
+
verifiedFailedAttempts: 9,
|
|
1824
|
+
});
|
|
1825
|
+
expect((await record())?.escrowPin?.disabledAt).toBeUndefined();
|
|
1826
|
+
}
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
it.each(['disabled', 'cleared', 'version', 'ciphertext'] as const)(
|
|
1830
|
+
'returns unavailable rather than lockout for concurrent PIN changes: %s',
|
|
1831
|
+
async change => {
|
|
1832
|
+
await enrollPin();
|
|
1833
|
+
const hold = await startPin();
|
|
1834
|
+
const reserve = models.reserveEscrowPinAttempt;
|
|
1835
|
+
vi.spyOn(models, 'reserveEscrowPinAttempt').mockImplementationOnce(async (...args) => {
|
|
1836
|
+
await getUserKeysCollection().updateOne(
|
|
1837
|
+
{ 'authProviders.id': authProvider.id },
|
|
1838
|
+
change === 'cleared'
|
|
1839
|
+
? { $unset: { escrowPin: '' } }
|
|
1840
|
+
: change === 'disabled'
|
|
1841
|
+
? { $set: { 'escrowPin.disabledAt': new Date() } }
|
|
1842
|
+
: change === 'version'
|
|
1843
|
+
? { $set: { shareVersion: 2 } }
|
|
1844
|
+
: { $set: { 'escrowBlob.envelope.ciphertext': 'replacement' } }
|
|
1845
|
+
);
|
|
1846
|
+
return reserve(...args);
|
|
1847
|
+
});
|
|
1848
|
+
const disable = vi.spyOn(models, 'disableEscrowPin');
|
|
1849
|
+
await expect(completePin(hold)).rejects.toMatchObject({
|
|
1850
|
+
code: 'FORBIDDEN',
|
|
1851
|
+
message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
1852
|
+
});
|
|
1853
|
+
expect(disable).not.toHaveBeenCalled();
|
|
1854
|
+
expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
|
|
1855
|
+
}
|
|
1856
|
+
);
|
|
1857
|
+
|
|
1858
|
+
it('throttles PIN starts by IP without superseding holds or consuming attempts', async () => {
|
|
1859
|
+
await enrollPin();
|
|
1860
|
+
const hold = await startPin();
|
|
1861
|
+
const redis = cache.redis ?? cache.node;
|
|
1862
|
+
await redis.set('escrow:pin-start:unknown', '20', 'EX', 60);
|
|
1863
|
+
await expect(startPin()).rejects.toMatchObject({
|
|
1864
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1865
|
+
message: 'Please wait before trying again.',
|
|
1866
|
+
});
|
|
1867
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(0);
|
|
1868
|
+
expect((await findEscrowHoldById(hold.holdId))?.status).toBe('pending');
|
|
1869
|
+
await expect(start()).resolves.toMatchObject({ releasePolicy: 'hold' });
|
|
1870
|
+
const otherIp = appRouter.createCaller({
|
|
1871
|
+
domain: 'example.com',
|
|
1872
|
+
clientIp: '192.0.2.1',
|
|
1873
|
+
tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
|
|
1874
|
+
});
|
|
1875
|
+
await redis.del('escrow:pin-start:192.0.2.1');
|
|
1876
|
+
await expect(
|
|
1877
|
+
otherIp.escrow.startRecovery({
|
|
1878
|
+
...auth,
|
|
1879
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1880
|
+
releasePolicy: 'pin',
|
|
1881
|
+
})
|
|
1882
|
+
).resolves.toMatchObject({ releasePolicy: 'pin' });
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
it('keeps the shared mismatch formatter compatible with the client pattern', () => {
|
|
1886
|
+
expect(ESCROW_PIN_MISMATCH_PATTERN.exec(escrowPinMismatchMessage(9))?.[1]).toBe('9');
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
it('clears PIN metadata when re-enrolled without a PIN or rotated', async () => {
|
|
1890
|
+
await enrollPin();
|
|
1891
|
+
envelope = await encryptEscrowBlob(
|
|
1892
|
+
{ recoveryShare: shares.recoveryShare, did, shareVersion: 1 },
|
|
1893
|
+
enclaveKeys.publicKey,
|
|
1894
|
+
keyId
|
|
1895
|
+
);
|
|
1896
|
+
await enroll();
|
|
1897
|
+
expect((await record())?.escrowPin).toBeUndefined();
|
|
1898
|
+
await enrollPin();
|
|
1899
|
+
// Rotation retains recovery methods while their auth share is in the
|
|
1900
|
+
// five-entry history. Exercise the rotation that actually prunes escrow.
|
|
1901
|
+
for (let rotation = 0; rotation < 6; rotation++) {
|
|
1902
|
+
await owner().keys.storeAuthShare({
|
|
1903
|
+
...auth,
|
|
1904
|
+
primaryDid: did,
|
|
1905
|
+
authShare: { encryptedData: shares.authShare, encryptedDek: '', iv: '' },
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
expect((await record())?.escrowBlob).toBeUndefined();
|
|
1909
|
+
expect((await record())?.escrowPin).toBeUndefined();
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
it('reports enabled, disabled, absent and stale PIN metadata without leaking salt when disabled', async () => {
|
|
1913
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
|
|
1914
|
+
state: 'none',
|
|
1915
|
+
enabled: false,
|
|
1916
|
+
attemptsRemaining: 0,
|
|
1917
|
+
});
|
|
1918
|
+
await enrollPin();
|
|
1919
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
|
|
1920
|
+
state: 'enabled',
|
|
1921
|
+
enabled: true,
|
|
1922
|
+
attemptsRemaining: 10,
|
|
1923
|
+
salt: pinSalt,
|
|
1924
|
+
});
|
|
1925
|
+
await getUserKeysCollection().updateOne(
|
|
1926
|
+
{ 'authProviders.id': authProvider.id },
|
|
1927
|
+
{ $set: { 'escrowPin.failedAttempts': 10, 'escrowPin.disabledAt': new Date() } }
|
|
1928
|
+
);
|
|
1929
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
|
|
1930
|
+
state: 'locked',
|
|
1931
|
+
enabled: false,
|
|
1932
|
+
attemptsRemaining: 0,
|
|
1933
|
+
});
|
|
1934
|
+
await enrollPin();
|
|
1935
|
+
await getUserKeysCollection().updateOne(
|
|
1936
|
+
{ 'authProviders.id': authProvider.id },
|
|
1937
|
+
{ $set: { 'escrowPin.shareVersion': 2 } }
|
|
1938
|
+
);
|
|
1939
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
|
|
1940
|
+
state: 'stale',
|
|
1941
|
+
enabled: false,
|
|
1942
|
+
attemptsRemaining: 10,
|
|
1943
|
+
});
|
|
1944
|
+
await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
it('atomically admits at most ten of fifteen concurrent wrong-PIN completions', async () => {
|
|
1948
|
+
await enrollPin();
|
|
1949
|
+
// Distinct linked identities allow 15 fresh pending holds while preserving the
|
|
1950
|
+
// unique pending-per-identity index. All aliases share ONE UserKey/counter.
|
|
1951
|
+
const aliases = Array.from({ length: 14 }, () => ({
|
|
1952
|
+
type: 'firebase' as const,
|
|
1953
|
+
id: randomUUID(),
|
|
1954
|
+
}));
|
|
1955
|
+
await getUserKeysCollection().updateOne(
|
|
1956
|
+
{ 'authProviders.id': authProvider.id },
|
|
1957
|
+
{ $push: { authProviders: { $each: aliases } } }
|
|
1958
|
+
);
|
|
1959
|
+
try {
|
|
1960
|
+
const holds = await Promise.all(
|
|
1961
|
+
[authProvider, ...aliases].map(provider =>
|
|
1962
|
+
getClient().escrow.startRecovery({
|
|
1963
|
+
authToken: makeMockToken(`${provider.id}@example.com`, provider.id),
|
|
1964
|
+
providerType: 'firebase',
|
|
1965
|
+
releasePolicy: 'pin',
|
|
1966
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
1967
|
+
})
|
|
1968
|
+
)
|
|
1969
|
+
);
|
|
1970
|
+
const release = vi
|
|
1971
|
+
.spyOn(getEscrowEnclave(), 'releaseEscrow')
|
|
1972
|
+
.mockRejectedValue(new EscrowPinMismatchError());
|
|
1973
|
+
const results = await Promise.allSettled(
|
|
1974
|
+
holds.map(hold => completePin(hold, wrongProof))
|
|
1975
|
+
);
|
|
1976
|
+
expect(results.every(result => result.status === 'rejected')).toBe(true);
|
|
1977
|
+
// Lockout may cancel other pending holds before their CAS; never more
|
|
1978
|
+
// than ten reservations can reach the enclave across linked identities.
|
|
1979
|
+
expect(release.mock.calls.length).toBeGreaterThan(0);
|
|
1980
|
+
expect(release.mock.calls.length).toBeLessThanOrEqual(10);
|
|
1981
|
+
expect((await record())?.escrowPin).toMatchObject({
|
|
1982
|
+
failedAttempts: 10,
|
|
1983
|
+
disabledAt: expect.any(Date),
|
|
1984
|
+
});
|
|
1985
|
+
} finally {
|
|
1986
|
+
await getEscrowHoldsCollection().deleteMany({
|
|
1987
|
+
'authProvider.id': { $in: aliases.map(alias => alias.id) },
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
it('returns PIN status with recovery-session verification', async () => {
|
|
1993
|
+
await enrollPin();
|
|
1994
|
+
const email = `${authProvider.id}@example.com`;
|
|
1995
|
+
await storeRecoveryOtp(email, {
|
|
1996
|
+
authProvider,
|
|
1997
|
+
codeHash: createHmac('sha256', environment.SEED).update('123456').digest('hex'),
|
|
1998
|
+
});
|
|
1999
|
+
const result = await getClient().keys.verifyRecoverySession({ email, code: '123456' });
|
|
2000
|
+
expect(result.escrowPin).toEqual({
|
|
2001
|
+
state: 'enabled',
|
|
2002
|
+
enabled: true,
|
|
2003
|
+
attemptsRemaining: 10,
|
|
2004
|
+
salt: pinSalt,
|
|
2005
|
+
});
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
it('does not reserve on invalid tokens, and permits retrying missing-proof refusals', async () => {
|
|
2009
|
+
await enrollPin();
|
|
2010
|
+
const hold = await startPin();
|
|
2011
|
+
await expect(
|
|
2012
|
+
getClient().escrow.completeRecovery({
|
|
2013
|
+
holdId: hold.holdId,
|
|
2014
|
+
resumeToken: 'wrong',
|
|
2015
|
+
pinProof,
|
|
2016
|
+
})
|
|
2017
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
2018
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(0);
|
|
2019
|
+
await expect(getClient().escrow.completeRecovery(resume(hold))).rejects.toMatchObject({
|
|
2020
|
+
code: 'FORBIDDEN',
|
|
2021
|
+
});
|
|
2022
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(0);
|
|
2023
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
2024
|
+
status: 'pending',
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it('limits PIN completions by IP before reserving or releasing and redacts PIN secrets', async () => {
|
|
2029
|
+
await enrollPin();
|
|
2030
|
+
const hold = await startPin();
|
|
2031
|
+
const redis = cache.redis ?? cache.node;
|
|
2032
|
+
await redis.set('escrow:pin-complete:unknown', '20', 'EX', 60);
|
|
2033
|
+
const release = vi.spyOn(getEscrowEnclave(), 'releaseEscrow');
|
|
2034
|
+
await expect(completePin(hold)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
|
2035
|
+
expect(release).not.toHaveBeenCalled();
|
|
2036
|
+
expect((await record())?.escrowPin?.failedAttempts).toBe(0);
|
|
2037
|
+
expect(redactSecretFields({ pinProof, nested: { pinVerifier: pinProof } })).toEqual({
|
|
2038
|
+
pinProof: '[Redacted]',
|
|
2039
|
+
nested: { pinVerifier: '[Redacted]' },
|
|
2040
|
+
});
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
describe('P7.1 ESCROW_RELEASE_KILL_SWITCH', () => {
|
|
2045
|
+
afterEach(() => {
|
|
2046
|
+
delete process.env.ESCROW_RELEASE_KILL_SWITCH;
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
it('refuses startRecovery with a friendly message and creates no hold', async () => {
|
|
2050
|
+
await enroll();
|
|
2051
|
+
process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
|
|
2052
|
+
await expect(start()).rejects.toMatchObject({
|
|
2053
|
+
code: 'PRECONDITION_FAILED',
|
|
2054
|
+
message: 'Automatic recovery is temporarily unavailable. Please try again later.',
|
|
2055
|
+
});
|
|
2056
|
+
expect(
|
|
2057
|
+
await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
|
|
2058
|
+
).toBe(0);
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
it('refuses completeRecovery on an existing pending hold, which stays pending', async () => {
|
|
2062
|
+
setDuration(1);
|
|
2063
|
+
await enroll();
|
|
2064
|
+
const started = await start();
|
|
2065
|
+
advanceToRelease(started);
|
|
2066
|
+
process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
|
|
2067
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
2068
|
+
code: 'PRECONDITION_FAILED',
|
|
2069
|
+
message: 'Automatic recovery is temporarily unavailable. Please try again later.',
|
|
2070
|
+
});
|
|
2071
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({ status: 'pending' });
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
it('still allows cancelRecoveryByLink while the kill switch is on', async () => {
|
|
2075
|
+
await enroll();
|
|
2076
|
+
const started = await start();
|
|
2077
|
+
const stored = await findEscrowHoldById(started.holdId);
|
|
2078
|
+
const token = generateEscrowCancelToken();
|
|
2079
|
+
await getEscrowHoldsCollection().updateOne(
|
|
2080
|
+
{ _id: started.holdId },
|
|
2081
|
+
{ $set: { cancelTokenHash: hashEscrowCancelToken(token) } }
|
|
2082
|
+
);
|
|
2083
|
+
process.env.ESCROW_RELEASE_KILL_SWITCH = 'true';
|
|
2084
|
+
await expect(
|
|
2085
|
+
getClient().escrow.cancelRecoveryByLink({ holdId: started.holdId, token })
|
|
2086
|
+
).resolves.toEqual({ cancelled: true });
|
|
2087
|
+
expect(await findEscrowHoldById(stored!._id)).toMatchObject({ status: 'cancelled' });
|
|
2088
|
+
});
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
describe('P6.1 escrow enclave-mode staleness', () => {
|
|
2092
|
+
const staleMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
|
|
2093
|
+
|
|
2094
|
+
// Delegates every real crypto operation to the currently-active (real)
|
|
2095
|
+
// enclave, only relabeling the attestation fields under test — lets these
|
|
2096
|
+
// tests exercise the mode/keyId comparison without a real remote backend.
|
|
2097
|
+
const withAttestationOverrides = (
|
|
2098
|
+
base: EscrowEnclave,
|
|
2099
|
+
overrides: Partial<EnclaveAttestation>
|
|
2100
|
+
): EscrowEnclave => ({
|
|
2101
|
+
getAttestation: async nonce => ({ ...(await base.getAttestation(nonce)), ...overrides }),
|
|
2102
|
+
verifyEscrowBlob: input => base.verifyEscrowBlob(input),
|
|
2103
|
+
releaseEscrow: input => base.releaseEscrow(input),
|
|
2104
|
+
createHold: input => base.createHold(input),
|
|
2105
|
+
cancelHold: input => base.cancelHold(input),
|
|
2106
|
+
carryPinVerifier: input => base.carryPinVerifier(input),
|
|
2107
|
+
rewrapEscrowBlob: input => base.rewrapEscrowBlob(input),
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
afterEach(() => {
|
|
2111
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
2112
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
2113
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
2114
|
+
__setEscrowEnclaveForTests(undefined);
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
it('startRecovery refuses a software blob once the server is in remote (nitro) mode, and status reports it', async () => {
|
|
2118
|
+
await enroll();
|
|
2119
|
+
const base = getEscrowEnclave();
|
|
2120
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
2121
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
2122
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
2123
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
|
|
2124
|
+
await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
|
|
2125
|
+
expect(
|
|
2126
|
+
await getEscrowHoldsCollection().countDocuments({ 'authProvider.id': authProvider.id })
|
|
2127
|
+
).toBe(0);
|
|
2128
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
it('completeRecovery refuses a software blob once the server is in remote (nitro) mode', async () => {
|
|
2132
|
+
setDuration(1);
|
|
2133
|
+
await enroll();
|
|
2134
|
+
const started = await start();
|
|
2135
|
+
advanceToRelease(started);
|
|
2136
|
+
const base = getEscrowEnclave();
|
|
2137
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
2138
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
2139
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
2140
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
|
|
2141
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
2142
|
+
code: 'FORBIDDEN',
|
|
2143
|
+
message: staleMessage,
|
|
2144
|
+
});
|
|
2145
|
+
expect(await findEscrowHoldById(started.holdId)).toMatchObject({ status: 'pending' });
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
it('refuses recovery for a nitro blob if the server rolls back to software mode', async () => {
|
|
2149
|
+
const base = getEscrowEnclave();
|
|
2150
|
+
process.env.ESCROW_ENCLAVE_MODE = 'remote';
|
|
2151
|
+
process.env.ESCROW_ENCLAVE_REMOTE_URL = 'http://localhost:5999';
|
|
2152
|
+
process.env.ESCROW_ENCLAVE_REMOTE_TOKEN = 'r'.repeat(32);
|
|
2153
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { mode: 'nitro' }));
|
|
2154
|
+
await enroll(); // stores enclaveMode: 'nitro'
|
|
2155
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_URL;
|
|
2156
|
+
delete process.env.ESCROW_ENCLAVE_REMOTE_TOKEN;
|
|
2157
|
+
process.env.ESCROW_ENCLAVE_MODE = 'software';
|
|
2158
|
+
__setEscrowEnclaveForTests(undefined); // roll back to the real software backend
|
|
2159
|
+
await expect(start()).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
|
|
2160
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('mode-mismatch');
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
it('reports key-rotated and refuses recovery when the enclave key id changes within the same mode', async () => {
|
|
2164
|
+
setDuration(1);
|
|
2165
|
+
await enroll();
|
|
2166
|
+
const started = await start();
|
|
2167
|
+
advanceToRelease(started);
|
|
2168
|
+
const base = getEscrowEnclave();
|
|
2169
|
+
__setEscrowEnclaveForTests(
|
|
2170
|
+
withAttestationOverrides(base, { keyId: 'rotated-software-key' })
|
|
2171
|
+
);
|
|
2172
|
+
// First check after the swap is the only one that should ever reach the
|
|
2173
|
+
// enclave — startRecovery's fetch warms the cache; completeRecovery and
|
|
2174
|
+
// getAuthShare below must reuse it (see the getAttestation call-count
|
|
2175
|
+
// assertion at the end).
|
|
2176
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
2177
|
+
await expect(
|
|
2178
|
+
getClient().escrow.startRecovery({
|
|
2179
|
+
...auth,
|
|
2180
|
+
clientEphemeralPublicKey: recipient.publicKey,
|
|
2181
|
+
})
|
|
2182
|
+
).rejects.toMatchObject({ code: 'FORBIDDEN', message: staleMessage });
|
|
2183
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).rejects.toMatchObject({
|
|
2184
|
+
code: 'FORBIDDEN',
|
|
2185
|
+
message: staleMessage,
|
|
2186
|
+
});
|
|
2187
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('key-rotated');
|
|
2188
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
// P9.2: unlike a fully unknown/retired key (above), a blob sealed under a
|
|
2192
|
+
// keyId the attestation still lists in `previousKeyIds` stays STALE
|
|
2193
|
+
// (prompts re-enrollment) but remains USABLE — completeRecovery must not
|
|
2194
|
+
// refuse it, since the enclave itself can still decrypt/release it (P9.1).
|
|
2195
|
+
it('reports key-rotated but still permits releasing a blob sealed under a recognised previous key', async () => {
|
|
2196
|
+
setDuration(1);
|
|
2197
|
+
await enroll();
|
|
2198
|
+
const started = await start();
|
|
2199
|
+
advanceToRelease(started);
|
|
2200
|
+
const base = getEscrowEnclave();
|
|
2201
|
+
__setEscrowEnclaveForTests(
|
|
2202
|
+
withAttestationOverrides(base, {
|
|
2203
|
+
keyId: 'rotated-software-key',
|
|
2204
|
+
previousKeyIds: [keyId],
|
|
2205
|
+
})
|
|
2206
|
+
);
|
|
2207
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBe('key-rotated');
|
|
2208
|
+
const released = await getClient().escrow.completeRecovery(resume(started));
|
|
2209
|
+
expect(await openEscrowRelease(released.sealedShare, recipient.privateKey)).toMatchObject({
|
|
2210
|
+
recoveryShare: shares.recoveryShare,
|
|
2211
|
+
});
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
it('reports no staleness and completes recovery normally for a blob matching the active enclave', async () => {
|
|
2215
|
+
setDuration(1);
|
|
2216
|
+
await enroll();
|
|
2217
|
+
expect((await owner().keys.getAuthShare(auth))?.escrowStale).toBeUndefined();
|
|
2218
|
+
const started = await start();
|
|
2219
|
+
advanceToRelease(started);
|
|
2220
|
+
await expect(getClient().escrow.completeRecovery(resume(started))).resolves.toBeDefined();
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
describe('attestation cache (P6.1 fix round 1 — login must never wait on the enclave)', () => {
|
|
2224
|
+
const hangingEnclave: EscrowEnclave = {
|
|
2225
|
+
getAttestation: () => new Promise(() => {}),
|
|
2226
|
+
carryPinVerifier: () => {
|
|
2227
|
+
throw new Error('not used in this test');
|
|
2228
|
+
},
|
|
2229
|
+
rewrapEscrowBlob: () => {
|
|
2230
|
+
throw new Error('not used in this test');
|
|
2231
|
+
},
|
|
2232
|
+
verifyEscrowBlob: () => {
|
|
2233
|
+
throw new Error('not used in this test');
|
|
2234
|
+
},
|
|
2235
|
+
releaseEscrow: () => {
|
|
2236
|
+
throw new Error('not used in this test');
|
|
2237
|
+
},
|
|
2238
|
+
createHold: () => {
|
|
2239
|
+
throw new Error('not used in this test');
|
|
2240
|
+
},
|
|
2241
|
+
cancelHold: () => {
|
|
2242
|
+
throw new Error('not used in this test');
|
|
2243
|
+
},
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
it('mode-mismatch never touches the enclave, even with a permanently-hanging attestation call', async () => {
|
|
2247
|
+
__setEscrowEnclaveForTests(hangingEnclave); // clears the cache too
|
|
2248
|
+
const startedAt = Date.now();
|
|
2249
|
+
const reason = await getEscrowBlobStaleReason({
|
|
2250
|
+
enclaveMode: 'nitro',
|
|
2251
|
+
enclaveKeyId: 'whatever',
|
|
2252
|
+
});
|
|
2253
|
+
expect(Date.now() - startedAt).toBeLessThan(100);
|
|
2254
|
+
expect(reason).toBe('mode-mismatch');
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
it('returns within the status budget instead of waiting on a hanging enclave when the mode matches', async () => {
|
|
2258
|
+
__setEscrowEnclaveForTests(hangingEnclave); // clears the cache too
|
|
2259
|
+
const startedAt = Date.now();
|
|
2260
|
+
const reason = await getEscrowBlobStaleReason({
|
|
2261
|
+
enclaveMode: 'software',
|
|
2262
|
+
enclaveKeyId: 'whatever',
|
|
2263
|
+
});
|
|
2264
|
+
// Comfortably under ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS's 10s default —
|
|
2265
|
+
// proves the status path never awaits the hanging call directly.
|
|
2266
|
+
expect(Date.now() - startedAt).toBeLessThan(1000);
|
|
2267
|
+
expect(reason).toBeUndefined();
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
it('detects key-rotated purely from a warm cache, with no second attestation fetch', async () => {
|
|
2271
|
+
await enroll();
|
|
2272
|
+
const base = getEscrowEnclave();
|
|
2273
|
+
__setEscrowEnclaveForTests(withAttestationOverrides(base, { keyId: 'rotated' })); // clears the cache too
|
|
2274
|
+
const blob = { enclaveMode: 'software' as const, enclaveKeyId: keyId };
|
|
2275
|
+
// Cold cache: fetches once and warms it with the rotated identity.
|
|
2276
|
+
expect(await getEscrowBlobStaleReason(blob)).toBe('key-rotated');
|
|
2277
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
2278
|
+
// Warm cache: must answer from memory, no second fetch.
|
|
2279
|
+
expect(await getEscrowBlobStaleReason(blob)).toBe('key-rotated');
|
|
2280
|
+
expect(spy).not.toHaveBeenCalled();
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
it('single-flights concurrent cold-cache status checks into exactly one attestation fetch', async () => {
|
|
2284
|
+
await enroll();
|
|
2285
|
+
__resetEscrowAttestationCacheForTests();
|
|
2286
|
+
const spy = vi.spyOn(getEscrowEnclave(), 'getAttestation');
|
|
2287
|
+
const blob = { enclaveMode: 'software' as const, enclaveKeyId: keyId };
|
|
2288
|
+
const results = await Promise.all(
|
|
2289
|
+
Array.from({ length: 5 }, () => getEscrowBlobStaleReason(blob))
|
|
2290
|
+
);
|
|
2291
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2292
|
+
expect(results.every(reason => reason === undefined)).toBe(true);
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
it('enforcement still refuses when the enclave is unreachable against a cold cache (never treats failure as fresh)', async () => {
|
|
2296
|
+
await enroll();
|
|
2297
|
+
__setEscrowEnclaveForTests({
|
|
2298
|
+
carryPinVerifier: () => {
|
|
2299
|
+
throw new Error('not used in this test');
|
|
2300
|
+
},
|
|
2301
|
+
rewrapEscrowBlob: () => {
|
|
2302
|
+
throw new Error('not used in this test');
|
|
2303
|
+
},
|
|
2304
|
+
getAttestation: async () => {
|
|
2305
|
+
throw new EscrowUnavailableError();
|
|
2306
|
+
},
|
|
2307
|
+
verifyEscrowBlob: () => {
|
|
2308
|
+
throw new Error('not used in this test');
|
|
2309
|
+
},
|
|
2310
|
+
releaseEscrow: () => {
|
|
2311
|
+
throw new Error('not used in this test');
|
|
2312
|
+
},
|
|
2313
|
+
createHold: () => {
|
|
2314
|
+
throw new Error('not used in this test');
|
|
2315
|
+
},
|
|
2316
|
+
cancelHold: () => {
|
|
2317
|
+
throw new Error('not used in this test');
|
|
2318
|
+
},
|
|
2319
|
+
}); // clears the cache too
|
|
2320
|
+
await expect(start()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
2321
|
+
expect(
|
|
2322
|
+
await getEscrowHoldsCollection().countDocuments({
|
|
2323
|
+
'authProvider.id': authProvider.id,
|
|
2324
|
+
})
|
|
2325
|
+
).toBe(0);
|
|
2326
|
+
});
|
|
2327
|
+
});
|
|
2328
|
+
});
|