@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,123 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
import cache from '@cache';
|
|
4
|
+
import { client } from '@mongo';
|
|
5
|
+
import { getUserKeysCollection, type MongoUserKeyType } from '@models';
|
|
6
|
+
import { checkRateLimit, clearRateLimit } from '@helpers/rateLimit.helpers';
|
|
7
|
+
|
|
8
|
+
import { getClient } from './helpers/getClient';
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
process.env.IS_E2E_TEST = 'true';
|
|
12
|
+
await client.connect();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await client.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('LC-2207 recovery email rate limiting by auth provider', () => {
|
|
20
|
+
const uid = randomUUID();
|
|
21
|
+
const email = `login-${uid}@example.com`;
|
|
22
|
+
const recoveryEmail = `recovery-${uid}@example.com`;
|
|
23
|
+
const did = `did:key:z${uid}`;
|
|
24
|
+
const codeKey = `recovery_email_code:firebase:${uid}`;
|
|
25
|
+
const attemptKey = `recovery-verify-attempts:firebase:${uid}`;
|
|
26
|
+
const contactAttemptKey = `recovery-verify-attempts:email:${email}`;
|
|
27
|
+
const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
|
|
28
|
+
const input = { authToken: `header.${payload}.signature`, providerType: 'firebase' as const };
|
|
29
|
+
const caller = getClient({ did, isChallengeValid: true });
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const userKey: MongoUserKeyType = {
|
|
34
|
+
contactMethod: { type: 'email', value: email },
|
|
35
|
+
authProviders: [{ type: 'firebase', id: uid }],
|
|
36
|
+
primaryDid: did,
|
|
37
|
+
linkedDids: [],
|
|
38
|
+
keyProvider: 'sss',
|
|
39
|
+
authShare: { encryptedData: 'auth-share', encryptedDek: 'dek', iv: 'iv' },
|
|
40
|
+
shareVersion: 1,
|
|
41
|
+
shareUpdatedAt: now,
|
|
42
|
+
previousAuthShares: [],
|
|
43
|
+
securityLevel: 'basic',
|
|
44
|
+
recoveryMethods: [],
|
|
45
|
+
migratedFromWeb3Auth: false,
|
|
46
|
+
sssActivationState: 'active',
|
|
47
|
+
createdAt: now,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
};
|
|
50
|
+
await getUserKeysCollection().insertOne(userKey);
|
|
51
|
+
await cache.set(codeKey, JSON.stringify({ code: '123456', email: recoveryEmail }), 900);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await getUserKeysCollection().deleteMany({ 'authProviders.id': uid });
|
|
56
|
+
await cache.delete([codeKey]);
|
|
57
|
+
await clearRateLimit(attemptKey);
|
|
58
|
+
await clearRateLimit(contactAttemptKey);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('locks out after five failures and deletes the pending code', async () => {
|
|
62
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
63
|
+
await expect(
|
|
64
|
+
caller.keys.verifyRecoveryEmail({ ...input, code: '000000' })
|
|
65
|
+
).rejects.toMatchObject({
|
|
66
|
+
code: 'BAD_REQUEST',
|
|
67
|
+
message: 'Incorrect code. Please try again.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await expect(
|
|
72
|
+
caller.keys.verifyRecoveryEmail({ ...input, code: '123456' })
|
|
73
|
+
).rejects.toMatchObject({
|
|
74
|
+
code: 'TOO_MANY_REQUESTS',
|
|
75
|
+
message: 'Too many attempts. Please resend code.',
|
|
76
|
+
});
|
|
77
|
+
expect(await cache.get(codeKey)).toBeFalsy();
|
|
78
|
+
await clearRateLimit(attemptKey);
|
|
79
|
+
await expect(
|
|
80
|
+
caller.keys.verifyRecoveryEmail({ ...input, code: '123456' })
|
|
81
|
+
).rejects.toMatchObject({ message: 'No pending verification. Please request a new code.' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('clears the provider counter on success without using a recycled contact counter', async () => {
|
|
85
|
+
await cache.set(`rate-limit:${contactAttemptKey}`, '99', 900);
|
|
86
|
+
await expect(
|
|
87
|
+
caller.keys.verifyRecoveryEmail({ ...input, code: '000000' })
|
|
88
|
+
).rejects.toThrow();
|
|
89
|
+
expect(await cache.get(`rate-limit:${attemptKey}`)).toBe('1');
|
|
90
|
+
await expect(
|
|
91
|
+
caller.keys.verifyRecoveryEmail({ ...input, code: '123456' })
|
|
92
|
+
).resolves.toMatchObject({ success: true });
|
|
93
|
+
expect(await cache.get(`rate-limit:${attemptKey}`)).toBeFalsy();
|
|
94
|
+
expect(await cache.get(`rate-limit:${contactAttemptKey}`)).toBe('99');
|
|
95
|
+
expect(await cache.get(codeKey)).toBeFalsy();
|
|
96
|
+
expect(await getUserKeysCollection().findOne({ 'authProviders.id': uid })).toMatchObject({
|
|
97
|
+
recoveryEmail,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('clears the provider counter when resending and accepts the new code', async () => {
|
|
102
|
+
await cache.set(`rate-limit:${attemptKey}`, '6', 900);
|
|
103
|
+
await caller.keys.addRecoveryEmail({ ...input, email: recoveryEmail });
|
|
104
|
+
expect(await cache.get(`rate-limit:${attemptKey}`)).toBeFalsy();
|
|
105
|
+
const pending = JSON.parse((await cache.get(codeKey))!);
|
|
106
|
+
expect(pending.code).toMatch(/^\d{6}$/);
|
|
107
|
+
await expect(
|
|
108
|
+
caller.keys.verifyRecoveryEmail({ ...input, code: pending.code })
|
|
109
|
+
).resolves.toMatchObject({ success: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('preserves the QR Redis prefix in the shared helper', async () => {
|
|
113
|
+
const key = `create:${uid}`;
|
|
114
|
+
try {
|
|
115
|
+
expect(await checkRateLimit(key, 1, 60, 'qr-login:rate:')).toBe(true);
|
|
116
|
+
expect(await checkRateLimit(key, 1, 60, 'qr-login:rate:')).toBe(false);
|
|
117
|
+
expect(await cache.get(`qr-login:rate:${key}`)).toBe('2');
|
|
118
|
+
expect(await cache.get(`rate-limit:${key}`)).toBeFalsy();
|
|
119
|
+
} finally {
|
|
120
|
+
await clearRateLimit(key, 'qr-login:rate:');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0-4: Secret leakage — telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that /keys/* routes never let share/token/recovery-key material
|
|
5
|
+
* reach Sentry: (1) the exported redaction helper strips every secret-shaped
|
|
6
|
+
* field from a realistic payload, and (2) exercising real /keys/* route
|
|
7
|
+
* handlers with sentinel values registers a Sentry scope event processor
|
|
8
|
+
* that redacts those sentinels from anything Sentry would capture.
|
|
9
|
+
*/
|
|
10
|
+
import * as Sentry from '@sentry/serverless';
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { redactSecretFields } from '@routes';
|
|
14
|
+
import { getClient, getUser } from './helpers/getClient';
|
|
15
|
+
|
|
16
|
+
import { client } from '@mongo';
|
|
17
|
+
|
|
18
|
+
const SENTINEL_SHARE = 'SENTINEL_SHARE_XYZ';
|
|
19
|
+
const SENTINEL_TOKEN = 'SENTINEL_TOKEN_XYZ';
|
|
20
|
+
|
|
21
|
+
const makeMockToken = (email: string, uid: string): string => {
|
|
22
|
+
const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
|
|
23
|
+
return `header.${payload}.signature`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
process.env.IS_E2E_TEST = 'true';
|
|
28
|
+
process.env.SEED ||= 'a'.repeat(64);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await client.connect();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(error);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
try {
|
|
39
|
+
await client.close();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('redactSecretFields', () => {
|
|
46
|
+
it('redacts every secret-shaped field from a realistic /keys/* payload', () => {
|
|
47
|
+
const payload = {
|
|
48
|
+
authToken: SENTINEL_TOKEN,
|
|
49
|
+
authShare: { encryptedData: SENTINEL_SHARE, encryptedDek: 'x', iv: 'iv' },
|
|
50
|
+
emailShare: SENTINEL_SHARE,
|
|
51
|
+
encryptedShare: { encryptedData: SENTINEL_SHARE, iv: 'iv' },
|
|
52
|
+
recoveryKey: SENTINEL_SHARE,
|
|
53
|
+
seed: SENTINEL_SHARE,
|
|
54
|
+
blob: SENTINEL_SHARE,
|
|
55
|
+
envelope: { ciphertext: SENTINEL_SHARE },
|
|
56
|
+
sealedShare: { ciphertext: SENTINEL_SHARE },
|
|
57
|
+
resumeToken: SENTINEL_TOKEN,
|
|
58
|
+
clientEphemeralPublicKey: SENTINEL_SHARE,
|
|
59
|
+
credentialId: 'not-a-secret-cred-id',
|
|
60
|
+
providerType: 'firebase',
|
|
61
|
+
type: 'passkey',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const redacted = redactSecretFields(payload) as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
expect(redacted.authToken).toBe('[Redacted]');
|
|
67
|
+
expect(redacted.authShare).toBe('[Redacted]');
|
|
68
|
+
expect(redacted.emailShare).toBe('[Redacted]');
|
|
69
|
+
expect(redacted.encryptedShare).toBe('[Redacted]');
|
|
70
|
+
expect(redacted.recoveryKey).toBe('[Redacted]');
|
|
71
|
+
expect(redacted.seed).toBe('[Redacted]');
|
|
72
|
+
expect(redacted.blob).toBe('[Redacted]');
|
|
73
|
+
expect(redacted.envelope).toBe('[Redacted]');
|
|
74
|
+
expect(redacted.sealedShare).toBe('[Redacted]');
|
|
75
|
+
expect(redacted.resumeToken).toBe('[Redacted]');
|
|
76
|
+
expect(redacted.clientEphemeralPublicKey).toBe('[Redacted]');
|
|
77
|
+
// Non-secret fields survive untouched
|
|
78
|
+
expect(redacted.credentialId).toBe('not-a-secret-cred-id');
|
|
79
|
+
expect(redacted.providerType).toBe('firebase');
|
|
80
|
+
expect(redacted.type).toBe('passkey');
|
|
81
|
+
|
|
82
|
+
const serialized = JSON.stringify(redacted);
|
|
83
|
+
expect(serialized).not.toContain(SENTINEL_SHARE);
|
|
84
|
+
expect(serialized).not.toContain(SENTINEL_TOKEN);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('redacts nested secret-shaped fields recursively', () => {
|
|
88
|
+
const payload = {
|
|
89
|
+
recoveryMethods: [
|
|
90
|
+
{ type: 'backup', encryptedShare: { encryptedData: SENTINEL_SHARE, iv: 'iv' } },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const redacted = redactSecretFields(payload) as {
|
|
95
|
+
recoveryMethods: { encryptedShare: unknown }[];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
expect(redacted.recoveryMethods[0]!.encryptedShare).toBe('[Redacted]');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('leaves keys that do not match the secret pattern alone', () => {
|
|
102
|
+
const payload = { credentialId: SENTINEL_TOKEN, providerType: 'firebase' };
|
|
103
|
+
|
|
104
|
+
const redacted = redactSecretFields(payload) as Record<string, unknown>;
|
|
105
|
+
|
|
106
|
+
expect(redacted.credentialId).toBe(SENTINEL_TOKEN);
|
|
107
|
+
expect(redacted.providerType).toBe('firebase');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Use a real isolated scope while capturing processors, without touching the hub.
|
|
112
|
+
const captureScope = (
|
|
113
|
+
processors: Array<(event: Record<string, unknown>) => unknown>
|
|
114
|
+
): Sentry.Scope => {
|
|
115
|
+
const scope = new Sentry.Scope();
|
|
116
|
+
vi.spyOn(scope, 'addEventProcessor').mockImplementation(processor => {
|
|
117
|
+
processors.push(event => processor(event, {}));
|
|
118
|
+
return scope;
|
|
119
|
+
});
|
|
120
|
+
return scope;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
describe('Sentry event processor for /keys/* routes', () => {
|
|
124
|
+
it('redacts escrow inputs and outputs on the separate escrow router namespace', async () => {
|
|
125
|
+
const processors: Array<(event: Record<string, unknown>) => unknown> = [];
|
|
126
|
+
const spy = vi.spyOn(Sentry, 'configureScope').mockImplementation(callback => {
|
|
127
|
+
callback(captureScope(processors));
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
// Even a disabled or rejected request must register the defense-in-depth processor.
|
|
131
|
+
await getClient()
|
|
132
|
+
.escrow.startRecovery({
|
|
133
|
+
authToken: SENTINEL_TOKEN,
|
|
134
|
+
providerType: 'firebase',
|
|
135
|
+
clientEphemeralPublicKey: SENTINEL_SHARE,
|
|
136
|
+
})
|
|
137
|
+
.catch(() => undefined);
|
|
138
|
+
} finally {
|
|
139
|
+
spy.mockRestore();
|
|
140
|
+
}
|
|
141
|
+
expect(processors).toHaveLength(1);
|
|
142
|
+
const payload = {
|
|
143
|
+
envelope: { ciphertext: SENTINEL_SHARE },
|
|
144
|
+
sealedShare: { ciphertext: SENTINEL_SHARE },
|
|
145
|
+
resumeToken: SENTINEL_TOKEN,
|
|
146
|
+
clientEphemeralPublicKey: SENTINEL_SHARE,
|
|
147
|
+
};
|
|
148
|
+
const event = {
|
|
149
|
+
contexts: { trpc: { input: payload } },
|
|
150
|
+
extra: { output: payload },
|
|
151
|
+
request: { data: payload },
|
|
152
|
+
};
|
|
153
|
+
const serialized = JSON.stringify(processors[0]!(event));
|
|
154
|
+
expect(serialized).not.toContain(SENTINEL_SHARE);
|
|
155
|
+
expect(serialized).not.toContain(SENTINEL_TOKEN);
|
|
156
|
+
});
|
|
157
|
+
it('registers a redacting event processor for every /keys/* call, using sentinel inputs', async () => {
|
|
158
|
+
const email = `p04-sentry-${Date.now()}@example.com`;
|
|
159
|
+
const token = makeMockToken(email, `p04-uid-${Date.now()}`);
|
|
160
|
+
const { learnCard } = await getUser(`b${'1'.repeat(63)}`);
|
|
161
|
+
const authedCaller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
|
|
162
|
+
|
|
163
|
+
const registeredProcessors: Array<(event: Record<string, unknown>) => unknown> = [];
|
|
164
|
+
|
|
165
|
+
const configureScopeSpy = vi
|
|
166
|
+
.spyOn(Sentry, 'configureScope')
|
|
167
|
+
.mockImplementation(callback => {
|
|
168
|
+
callback(captureScope(registeredProcessors));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Exercise every /keys/* route that accepts share/token-shaped
|
|
173
|
+
// input, with sentinel values, exactly as a real client would.
|
|
174
|
+
await authedCaller.keys.storeAuthShare({
|
|
175
|
+
authToken: token,
|
|
176
|
+
providerType: 'firebase',
|
|
177
|
+
authShare: { encryptedData: SENTINEL_SHARE, encryptedDek: 'x', iv: 'iv' },
|
|
178
|
+
primaryDid: learnCard.id.did(),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await authedCaller.keys.addRecoveryMethod({
|
|
182
|
+
authToken: token,
|
|
183
|
+
providerType: 'firebase',
|
|
184
|
+
type: 'backup',
|
|
185
|
+
encryptedShare: { encryptedData: SENTINEL_SHARE, iv: 'iv' },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await getClient().keys.getAuthShare({ authToken: token, providerType: 'firebase' });
|
|
189
|
+
await getClient().keys.getRecoveryShare({
|
|
190
|
+
authToken: token,
|
|
191
|
+
providerType: 'firebase',
|
|
192
|
+
type: 'backup',
|
|
193
|
+
});
|
|
194
|
+
await expect(
|
|
195
|
+
authedCaller.keys.sendEmailBackup({
|
|
196
|
+
authToken: token,
|
|
197
|
+
providerType: 'firebase',
|
|
198
|
+
relayPayload: {
|
|
199
|
+
version: 1,
|
|
200
|
+
algorithm: 'P-256-HKDF-SHA256-AES-256-GCM',
|
|
201
|
+
keyId: 'sentry-test-key',
|
|
202
|
+
ephemeralPublicKey: 'TESTEPHEMERAL',
|
|
203
|
+
salt: 'TESTSALT',
|
|
204
|
+
iv: 'TESTIV',
|
|
205
|
+
ciphertext: SENTINEL_SHARE,
|
|
206
|
+
},
|
|
207
|
+
confirmationCode: '123456',
|
|
208
|
+
email: 'recovery-target@example.com',
|
|
209
|
+
})
|
|
210
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
211
|
+
} finally {
|
|
212
|
+
configureScopeSpy.mockRestore();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// attachRpcInput is globally disabled (see routes/index.ts), so
|
|
216
|
+
// Sentry never received the raw input above in the first place.
|
|
217
|
+
// This proves the defense-in-depth layer independently: every one
|
|
218
|
+
// of the five /keys/* calls above registered a redacting processor.
|
|
219
|
+
expect(registeredProcessors.length).toBe(5);
|
|
220
|
+
|
|
221
|
+
const syntheticEvent = {
|
|
222
|
+
contexts: {
|
|
223
|
+
trpc: {
|
|
224
|
+
procedure_type: 'mutation',
|
|
225
|
+
input: {
|
|
226
|
+
authToken: SENTINEL_TOKEN,
|
|
227
|
+
authShare: { encryptedData: SENTINEL_SHARE, encryptedDek: 'x', iv: 'iv' },
|
|
228
|
+
emailShare: SENTINEL_SHARE,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
extra: { recoveryKey: SENTINEL_SHARE, credentialId: 'safe-value' },
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Apply every registered processor, as the real Sentry client would.
|
|
236
|
+
let result: Record<string, unknown> = syntheticEvent;
|
|
237
|
+
for (const processor of registeredProcessors) {
|
|
238
|
+
result = (processor(result) as Record<string, unknown>) ?? result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const serialized = JSON.stringify(result);
|
|
242
|
+
expect(serialized).not.toContain(SENTINEL_TOKEN);
|
|
243
|
+
expect(serialized).not.toContain(SENTINEL_SHARE);
|
|
244
|
+
// Confirms redaction is targeted, not a blanket event wipe
|
|
245
|
+
expect(serialized).toContain('safe-value');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('does not register the redacting processor for non-/keys/* routes', async () => {
|
|
249
|
+
const registeredProcessors: Array<(event: Record<string, unknown>) => unknown> = [];
|
|
250
|
+
|
|
251
|
+
const configureScopeSpy = vi
|
|
252
|
+
.spyOn(Sentry, 'configureScope')
|
|
253
|
+
.mockImplementation(callback => {
|
|
254
|
+
callback(captureScope(registeredProcessors));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await getClient().utilities.healthCheck();
|
|
259
|
+
} finally {
|
|
260
|
+
configureScopeSpy.mockRestore();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
expect(registeredProcessors).toHaveLength(0);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const collection = vi.hoisted(() => ({
|
|
4
|
+
indexes: vi.fn(),
|
|
5
|
+
dropIndex: vi.fn(async () => undefined),
|
|
6
|
+
createIndex: vi.fn(async () => 'index'),
|
|
7
|
+
findOneAndUpdate: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('@mongo', () => ({ default: { collection: () => collection } }));
|
|
11
|
+
|
|
12
|
+
const createAuthSubjectIndexes = async (): Promise<void> => {
|
|
13
|
+
const model = await import('../../src/models/AuthSubject');
|
|
14
|
+
await model.createAuthSubjectIndexes();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.resetModules();
|
|
19
|
+
collection.indexes.mockReset().mockResolvedValue([{ name: '_id_', key: { _id: 1 } }]);
|
|
20
|
+
collection.dropIndex.mockReset().mockResolvedValue(undefined);
|
|
21
|
+
collection.createIndex.mockReset().mockResolvedValue('index');
|
|
22
|
+
collection.findOneAndUpdate.mockReset().mockResolvedValue({ subject: 'subject' });
|
|
23
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => vi.restoreAllMocks());
|
|
26
|
+
|
|
27
|
+
describe('createAuthSubjectIndexes', () => {
|
|
28
|
+
it('indexes subject without uniqueness so linked sign-in methods can share it', async () => {
|
|
29
|
+
await createAuthSubjectIndexes();
|
|
30
|
+
|
|
31
|
+
expect(collection.createIndex).toHaveBeenCalledWith({ subject: 1 });
|
|
32
|
+
expect(collection.createIndex).not.toHaveBeenCalledWith(
|
|
33
|
+
{ subject: 1 },
|
|
34
|
+
expect.objectContaining({ unique: true })
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('keeps identityKey unique', async () => {
|
|
39
|
+
await createAuthSubjectIndexes();
|
|
40
|
+
|
|
41
|
+
expect(collection.createIndex).toHaveBeenCalledWith({ identityKey: 1 }, { unique: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('drops a pre-existing unique subject index before recreating it', async () => {
|
|
45
|
+
collection.indexes.mockResolvedValue([
|
|
46
|
+
{ name: '_id_', key: { _id: 1 } },
|
|
47
|
+
{ name: 'subject_1', key: { subject: 1 }, unique: true },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
await createAuthSubjectIndexes();
|
|
51
|
+
|
|
52
|
+
expect(collection.dropIndex).toHaveBeenCalledWith('subject_1');
|
|
53
|
+
expect(collection.dropIndex.mock.invocationCallOrder[0]).toBeLessThan(
|
|
54
|
+
collection.createIndex.mock.invocationCallOrder[0] ?? Infinity
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('leaves a non-unique subject index alone', async () => {
|
|
59
|
+
collection.indexes.mockResolvedValue([{ name: 'subject_1', key: { subject: 1 } }]);
|
|
60
|
+
|
|
61
|
+
await createAuthSubjectIndexes();
|
|
62
|
+
|
|
63
|
+
expect(collection.dropIndex).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('treats a missing collection as having no indexes', async () => {
|
|
67
|
+
collection.indexes.mockRejectedValue(Object.assign(new Error('ns'), { code: 26 }));
|
|
68
|
+
|
|
69
|
+
await createAuthSubjectIndexes();
|
|
70
|
+
|
|
71
|
+
expect(collection.createIndex).toHaveBeenCalledTimes(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('tolerates the legacy index disappearing concurrently', async () => {
|
|
75
|
+
collection.indexes.mockResolvedValue([
|
|
76
|
+
{ name: 'subject_1', key: { subject: 1 }, unique: true },
|
|
77
|
+
]);
|
|
78
|
+
collection.dropIndex.mockRejectedValueOnce(Object.assign(new Error('gone'), { code: 27 }));
|
|
79
|
+
|
|
80
|
+
await expect(createAuthSubjectIndexes()).resolves.toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('AuthSubject index readiness', () => {
|
|
85
|
+
it.each(['subject', 'identityKey'])(
|
|
86
|
+
'rejects all waiters when %s indexing fails, then retries',
|
|
87
|
+
async failedIndex => {
|
|
88
|
+
const { createIndex, findOneAndUpdate } = collection;
|
|
89
|
+
const { ensureAuthSubjectIndexes, getOrCreateAuthSubject } =
|
|
90
|
+
await import('../../src/models/AuthSubject');
|
|
91
|
+
const failure = new Error('index unavailable');
|
|
92
|
+
let rejectIndex!: (error: Error) => void;
|
|
93
|
+
const pendingIndex = new Promise<string>((_resolve, reject) => {
|
|
94
|
+
rejectIndex = reject;
|
|
95
|
+
});
|
|
96
|
+
if (failedIndex === 'identityKey') createIndex.mockResolvedValueOnce('subject');
|
|
97
|
+
createIndex.mockImplementationOnce(() => pendingIndex);
|
|
98
|
+
|
|
99
|
+
const ready = ensureAuthSubjectIndexes();
|
|
100
|
+
expect(ensureAuthSubjectIndexes()).toBe(ready);
|
|
101
|
+
const waiters = Promise.allSettled([
|
|
102
|
+
ready,
|
|
103
|
+
getOrCreateAuthSubject('email:test@example.com', { emailVerified: true }),
|
|
104
|
+
getOrCreateAuthSubject('email:test@example.com', { emailVerified: true }),
|
|
105
|
+
]);
|
|
106
|
+
rejectIndex(failure);
|
|
107
|
+
expect(await waiters).toEqual(
|
|
108
|
+
Array.from({ length: 3 }, () => ({ status: 'rejected', reason: failure }))
|
|
109
|
+
);
|
|
110
|
+
expect(findOneAndUpdate).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
createIndex.mockClear();
|
|
113
|
+
const completed: string[] = [];
|
|
114
|
+
createIndex.mockImplementation(async (key: Record<string, number>) => {
|
|
115
|
+
completed.push(Object.keys(key)[0]!);
|
|
116
|
+
return 'index';
|
|
117
|
+
});
|
|
118
|
+
findOneAndUpdate.mockImplementation(async () => {
|
|
119
|
+
expect(completed).toEqual(['subject', 'identityKey']);
|
|
120
|
+
return { subject: 'subject' };
|
|
121
|
+
});
|
|
122
|
+
await getOrCreateAuthSubject('email:test@example.com', { emailVerified: true });
|
|
123
|
+
expect(createIndex.mock.calls).toEqual([
|
|
124
|
+
[{ subject: 1 }],
|
|
125
|
+
[{ identityKey: 1 }, { unique: true }],
|
|
126
|
+
]);
|
|
127
|
+
expect(findOneAndUpdate).toHaveBeenCalledOnce();
|
|
128
|
+
await ensureAuthSubjectIndexes();
|
|
129
|
+
expect(createIndex).toHaveBeenCalledTimes(2);
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
});
|