@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
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
import { environment } from '@environment';
|
|
6
6
|
import { TRPCError } from '@trpc/server';
|
|
7
7
|
import admin from 'firebase-admin';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { getKeycloakVerifyOptionsFromEnv, verifyKeycloakJwt } from './keycloak.helpers';
|
|
10
|
+
|
|
11
|
+
const offlineFirebaseClaimsSchema = z.object({
|
|
12
|
+
sub: z.string().optional(),
|
|
13
|
+
uid: z.string().optional(),
|
|
14
|
+
user_id: z.string().optional(),
|
|
15
|
+
email: z.string().nullish(),
|
|
16
|
+
phone_number: z.string().nullish(),
|
|
17
|
+
});
|
|
8
18
|
|
|
9
19
|
export interface VerifiedUser {
|
|
10
20
|
id: string;
|
|
@@ -15,31 +25,35 @@ export interface VerifiedUser {
|
|
|
15
25
|
|
|
16
26
|
export type AuthProviderType = 'firebase' | 'supertokens' | 'keycloak' | 'oidc';
|
|
17
27
|
|
|
28
|
+
const parseOfflineFirebaseClaims = (
|
|
29
|
+
token: string
|
|
30
|
+
): z.infer<typeof offlineFirebaseClaimsSchema> | undefined => {
|
|
31
|
+
const payloadPart = token.split('.')[1];
|
|
32
|
+
if (!payloadPart) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const decoded: unknown = JSON.parse(Buffer.from(payloadPart, 'base64url').toString());
|
|
35
|
+
return offlineFirebaseClaimsSchema.parse(decoded);
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
18
41
|
export async function verifyFirebaseToken(token: string): Promise<VerifiedUser> {
|
|
19
42
|
// E2E test or offline bypass - parse JWT without Firebase Admin verification
|
|
20
43
|
if (environment.IS_E2E_TEST || environment.IS_OFFLINE) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} catch {
|
|
30
|
-
payload = JSON.parse(Buffer.from(payloadPart, 'base64').toString());
|
|
31
|
-
}
|
|
32
|
-
return {
|
|
33
|
-
id: payload.sub || payload.uid || payload.user_id || 'offline-user',
|
|
34
|
-
email: payload.email,
|
|
35
|
-
phone: payload.phone_number,
|
|
36
|
-
providerType: 'firebase',
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
} catch (e) {
|
|
40
|
-
console.warn('Failed to parse JWT in offline mode:', e);
|
|
41
|
-
// Fall through to normal verification
|
|
44
|
+
const payload = parseOfflineFirebaseClaims(token);
|
|
45
|
+
if (payload) {
|
|
46
|
+
return {
|
|
47
|
+
id: payload.sub || payload.uid || payload.user_id || 'offline-user',
|
|
48
|
+
email: payload.email ?? undefined,
|
|
49
|
+
phone: payload.phone_number ?? undefined,
|
|
50
|
+
providerType: 'firebase',
|
|
51
|
+
};
|
|
42
52
|
}
|
|
53
|
+
throw new TRPCError({
|
|
54
|
+
code: 'UNAUTHORIZED',
|
|
55
|
+
message: 'Could not decode Firebase token in offline/E2E mode',
|
|
56
|
+
});
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
try {
|
|
@@ -52,7 +66,7 @@ export async function verifyFirebaseToken(token: string): Promise<VerifiedUser>
|
|
|
52
66
|
providerType: 'firebase',
|
|
53
67
|
};
|
|
54
68
|
} catch (error) {
|
|
55
|
-
console.
|
|
69
|
+
console.warn('Firebase token verification failed:', error);
|
|
56
70
|
throw new TRPCError({
|
|
57
71
|
code: 'UNAUTHORIZED',
|
|
58
72
|
message: 'Invalid Firebase token',
|
|
@@ -70,14 +84,38 @@ export async function verifySuperTokensToken(_token: string): Promise<VerifiedUs
|
|
|
70
84
|
});
|
|
71
85
|
}
|
|
72
86
|
|
|
73
|
-
export async function verifyKeycloakToken(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
87
|
+
export async function verifyKeycloakToken(token: string): Promise<VerifiedUser> {
|
|
88
|
+
const options = getKeycloakVerifyOptionsFromEnv();
|
|
89
|
+
if (!options) {
|
|
90
|
+
throw new TRPCError({
|
|
91
|
+
code: 'UNAUTHORIZED',
|
|
92
|
+
message: 'Keycloak verification is not configured',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
let claims;
|
|
96
|
+
try {
|
|
97
|
+
claims = await verifyKeycloakJwt(token, options);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
|
|
100
|
+
}
|
|
101
|
+
if (claims.email !== undefined && claims.email_verified !== true) {
|
|
102
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Email address is not verified' });
|
|
103
|
+
}
|
|
104
|
+
if (claims.phone_number !== undefined && claims.phone_number_verified !== true) {
|
|
105
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Phone number is not verified' });
|
|
106
|
+
}
|
|
107
|
+
if (!claims.email && !claims.phone_number) {
|
|
108
|
+
throw new TRPCError({
|
|
109
|
+
code: 'UNAUTHORIZED',
|
|
110
|
+
message: 'Token has no verified contact method',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
id: claims.sub,
|
|
115
|
+
email: claims.email,
|
|
116
|
+
phone: claims.phone_number,
|
|
117
|
+
providerType: 'keycloak',
|
|
118
|
+
};
|
|
81
119
|
}
|
|
82
120
|
|
|
83
121
|
export async function verifyOidcToken(_token: string): Promise<VerifiedUser> {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildEscrowCancelUrl, resolveEscrowAppBaseUrl } from './escrowCancelLink';
|
|
4
|
+
|
|
5
|
+
describe('buildEscrowCancelUrl', () => {
|
|
6
|
+
const holdId = '5b1f6e2e-6e2a-4e8a-9e2a-6e2a4e8a9e2a';
|
|
7
|
+
const token = 'a'.repeat(64);
|
|
8
|
+
|
|
9
|
+
it('builds the expected path and query', () => {
|
|
10
|
+
expect(buildEscrowCancelUrl({ baseUrl: 'https://learncard.app', holdId, token })).toBe(
|
|
11
|
+
`https://learncard.app/recovery/cancel?holdId=${holdId}&token=${token}`
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('never doubles the slash when baseUrl has a trailing slash', () => {
|
|
16
|
+
expect(buildEscrowCancelUrl({ baseUrl: 'https://learncard.app/', holdId, token })).toBe(
|
|
17
|
+
`https://learncard.app/recovery/cancel?holdId=${holdId}&token=${token}`
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('percent-encodes query values that need it', () => {
|
|
22
|
+
const url = buildEscrowCancelUrl({
|
|
23
|
+
baseUrl: 'https://learncard.app',
|
|
24
|
+
holdId: 'weird id/with&chars',
|
|
25
|
+
token,
|
|
26
|
+
});
|
|
27
|
+
expect(url).toBe(
|
|
28
|
+
`https://learncard.app/recovery/cancel?holdId=weird+id%2Fwith%26chars&token=${token}`
|
|
29
|
+
);
|
|
30
|
+
expect(new URL(url).searchParams.get('holdId')).toBe('weird id/with&chars');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('ignores any path segment already present on baseUrl (host + protocol only, matching every tenant appUrl in the registry)', () => {
|
|
34
|
+
expect(buildEscrowCancelUrl({ baseUrl: 'https://vetpass.app', holdId, token })).toBe(
|
|
35
|
+
`https://vetpass.app/recovery/cancel?holdId=${holdId}&token=${token}`
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('resolveEscrowAppBaseUrl', () => {
|
|
41
|
+
it('falls back to the LearnCard default when the tenant has no appUrl override', () => {
|
|
42
|
+
expect(
|
|
43
|
+
resolveEscrowAppBaseUrl({
|
|
44
|
+
tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
|
|
45
|
+
})
|
|
46
|
+
).toBe('https://learncard.app');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('prefers the resolved tenant appUrl', () => {
|
|
50
|
+
expect(
|
|
51
|
+
resolveEscrowAppBaseUrl({
|
|
52
|
+
tenant: {
|
|
53
|
+
id: 'vetpass',
|
|
54
|
+
emailBranding: { appUrl: 'https://vetpass.app' },
|
|
55
|
+
resolvedVia: 'origin',
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
).toBe('https://vetpass.app');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escrow recovery cancel-link deep-link builder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the existing tenant-branded app-link pattern used elsewhere in the
|
|
5
|
+
* monorepo (brain-service's `generateClaimUrl` / `generateGuardianApprovalUrl`,
|
|
6
|
+
* `inbox.helpers.ts`'s `new URL('/notifications', appUrl)`): prefer the
|
|
7
|
+
* resolved tenant's `TenantBranding.appUrl`, which already falls back to the
|
|
8
|
+
* LearnCard default (`https://learncard.app`) via `resolveBranding()` when a
|
|
9
|
+
* tenant has no override.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { resolveBranding } from '@learncard/email-templates';
|
|
13
|
+
import type { Context } from '@routes';
|
|
14
|
+
|
|
15
|
+
/** Base app URL (e.g. `https://vetpass.app`) for constructing escrow deep links. */
|
|
16
|
+
export const resolveEscrowAppBaseUrl = (ctx: Pick<Context, 'tenant'>): string =>
|
|
17
|
+
resolveBranding(ctx.tenant?.emailBranding).appUrl;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds the single-use "cancel this recovery" link sent in the hold-started
|
|
21
|
+
* email. Uses `URL`/`URLSearchParams` rather than string concatenation so
|
|
22
|
+
* `holdId`/`token` are correctly percent-encoded and a trailing slash on
|
|
23
|
+
* `baseUrl` never produces a doubled `//recovery/cancel`.
|
|
24
|
+
*/
|
|
25
|
+
export const buildEscrowCancelUrl = ({
|
|
26
|
+
baseUrl,
|
|
27
|
+
holdId,
|
|
28
|
+
token,
|
|
29
|
+
}: {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
holdId: string;
|
|
32
|
+
token: string;
|
|
33
|
+
}): string => {
|
|
34
|
+
const url = new URL('/recovery/cancel', baseUrl);
|
|
35
|
+
url.searchParams.set('holdId', holdId);
|
|
36
|
+
url.searchParams.set('token', token);
|
|
37
|
+
return url.toString();
|
|
38
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
generateEscrowCancelToken,
|
|
5
|
+
hashEscrowCancelToken,
|
|
6
|
+
escrowCancelTokenMatches,
|
|
7
|
+
} from './escrowCancelToken';
|
|
8
|
+
|
|
9
|
+
import type { EscrowHold } from '@models';
|
|
10
|
+
|
|
11
|
+
const baseHold = (overrides: Partial<EscrowHold> = {}): EscrowHold => {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
return {
|
|
14
|
+
holdRecord: {
|
|
15
|
+
hold: {
|
|
16
|
+
holdId: 'hold-id',
|
|
17
|
+
did: 'did:key:test',
|
|
18
|
+
shareVersion: 1,
|
|
19
|
+
blobHash: 'ab'.repeat(32),
|
|
20
|
+
enrollmentEpoch: 1,
|
|
21
|
+
releasePolicy: 'hold',
|
|
22
|
+
clientEphemeralPublicKey: 'public-key',
|
|
23
|
+
createdLo: 0,
|
|
24
|
+
createdHi: 0,
|
|
25
|
+
policyVersion: 1,
|
|
26
|
+
signature: 'test-signature',
|
|
27
|
+
},
|
|
28
|
+
holdDurationMs: 0,
|
|
29
|
+
ledgerSeq: 0,
|
|
30
|
+
},
|
|
31
|
+
_id: 'hold-id',
|
|
32
|
+
authProvider: { type: 'firebase', id: 'user-1' },
|
|
33
|
+
primaryDid: 'did:key:test',
|
|
34
|
+
shareVersion: 1,
|
|
35
|
+
status: 'pending',
|
|
36
|
+
identityProofType: 'auth-token',
|
|
37
|
+
requestedAt: now,
|
|
38
|
+
releaseAfter: now,
|
|
39
|
+
releasePolicy: 'hold',
|
|
40
|
+
clientEphemeralPublicKey: 'public-key',
|
|
41
|
+
resumeTokenHash: 'a'.repeat(64),
|
|
42
|
+
notifications: [],
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
describe('escrowCancelToken helpers', () => {
|
|
50
|
+
it('generates unique, high-entropy hex tokens', () => {
|
|
51
|
+
const token = generateEscrowCancelToken();
|
|
52
|
+
|
|
53
|
+
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
54
|
+
expect(generateEscrowCancelToken()).not.toBe(token);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hashes deterministically without ever equaling the plaintext', () => {
|
|
58
|
+
const token = generateEscrowCancelToken();
|
|
59
|
+
const hash = hashEscrowCancelToken(token);
|
|
60
|
+
|
|
61
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
62
|
+
expect(hash).not.toBe(token);
|
|
63
|
+
expect(hashEscrowCancelToken(token)).toBe(hash);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('matches the correct token against a pending, unused hold', () => {
|
|
67
|
+
const token = generateEscrowCancelToken();
|
|
68
|
+
const hold = baseHold({ cancelTokenHash: hashEscrowCancelToken(token) });
|
|
69
|
+
|
|
70
|
+
expect(escrowCancelTokenMatches(hold, token)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects a wrong token', () => {
|
|
74
|
+
const token = generateEscrowCancelToken();
|
|
75
|
+
const hold = baseHold({ cancelTokenHash: hashEscrowCancelToken(token) });
|
|
76
|
+
|
|
77
|
+
expect(escrowCancelTokenMatches(hold, generateEscrowCancelToken())).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects an already-used token', () => {
|
|
81
|
+
const token = generateEscrowCancelToken();
|
|
82
|
+
const hold = baseHold({
|
|
83
|
+
cancelTokenHash: hashEscrowCancelToken(token),
|
|
84
|
+
cancelTokenUsedAt: new Date(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(escrowCancelTokenMatches(hold, token)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects a non-pending hold even with a correct, unused token', () => {
|
|
91
|
+
const token = generateEscrowCancelToken();
|
|
92
|
+
|
|
93
|
+
for (const status of ['cancelled', 'completed', 'expired'] as const) {
|
|
94
|
+
const hold = baseHold({ status, cancelTokenHash: hashEscrowCancelToken(token) });
|
|
95
|
+
|
|
96
|
+
expect(escrowCancelTokenMatches(hold, token)).toBe(false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects when no cancel token was ever issued on the hold', () => {
|
|
101
|
+
const hold = baseHold();
|
|
102
|
+
|
|
103
|
+
expect(escrowCancelTokenMatches(hold, generateEscrowCancelToken())).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escrow recovery cancel-link token.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the resume-token primitive in `models/EscrowHold.ts`: a random,
|
|
5
|
+
* high-entropy token whose SHA-256 hash is stored on the hold so the
|
|
6
|
+
* plaintext never needs to be persisted. There is no HMAC key involved —
|
|
7
|
+
* the token's own entropy is the secret, exactly like `resumeTokenHash`.
|
|
8
|
+
*
|
|
9
|
+
* The token is single-use (burned via `cancelTokenUsedAt`) and its
|
|
10
|
+
* effective TTL is the hold's own lifetime: it stops matching the moment
|
|
11
|
+
* the hold leaves the `pending` status (cancelled, completed, or expired),
|
|
12
|
+
* so no separate expiry timestamp is needed.
|
|
13
|
+
*
|
|
14
|
+
* Never log the plaintext token.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
18
|
+
|
|
19
|
+
import type { EscrowHold } from '@models';
|
|
20
|
+
|
|
21
|
+
/** 256 bits of entropy, hex-encoded — same shape as `generateEscrowResumeToken`. */
|
|
22
|
+
export const generateEscrowCancelToken = (): string => randomBytes(32).toString('hex');
|
|
23
|
+
|
|
24
|
+
/** Plain SHA-256, matching `hashEscrowResumeToken` — the token's own entropy is the secret. */
|
|
25
|
+
export const hashEscrowCancelToken = (token: string): string =>
|
|
26
|
+
createHash('sha256').update(token).digest('hex');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Timing-safe cancel-token check.
|
|
30
|
+
*
|
|
31
|
+
* Returns `false` (never throws) when the hold has no cancel token, the
|
|
32
|
+
* token was already used, or the hold is no longer `pending` — a cancel
|
|
33
|
+
* link's effective TTL is the hold's own lifetime, not a separate clock.
|
|
34
|
+
*/
|
|
35
|
+
export const escrowCancelTokenMatches = (hold: EscrowHold, token: string): boolean => {
|
|
36
|
+
if (hold.status !== 'pending' || hold.cancelTokenUsedAt) return false;
|
|
37
|
+
const actual = Buffer.from(hashEscrowCancelToken(token), 'hex');
|
|
38
|
+
const hashes = [
|
|
39
|
+
...(hold.cancelTokenHashes ?? []),
|
|
40
|
+
...(hold.cancelTokenHash ? [hold.cancelTokenHash] : []),
|
|
41
|
+
];
|
|
42
|
+
// Evaluate every hash, including after a match, rather than short-circuiting.
|
|
43
|
+
return hashes.reduce((matches, hash) => {
|
|
44
|
+
const expected = Buffer.from(hash, 'hex');
|
|
45
|
+
return (actual.length === expected.length && timingSafeEqual(actual, expected)) || matches;
|
|
46
|
+
}, false);
|
|
47
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { environment } from '@environment';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { createRemoteJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
|
4
|
+
import type { JWTVerifyGetKey } from 'jose';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
export interface KeycloakVerifyOptions {
|
|
8
|
+
issuers: string[];
|
|
9
|
+
audiences: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeycloakClaims {
|
|
13
|
+
sub: string;
|
|
14
|
+
iss: string;
|
|
15
|
+
typ?: string;
|
|
16
|
+
azp?: string;
|
|
17
|
+
aud?: string | string[];
|
|
18
|
+
email?: string;
|
|
19
|
+
email_verified?: boolean;
|
|
20
|
+
phone_number?: string;
|
|
21
|
+
phone_number_verified?: boolean;
|
|
22
|
+
name?: string;
|
|
23
|
+
picture?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const claimsSchema = z.object({
|
|
27
|
+
sub: z.string().min(1),
|
|
28
|
+
iss: z.string(),
|
|
29
|
+
// The payload typ is a Keycloak extension, not a required OIDC claim.
|
|
30
|
+
typ: z.enum(['ID', 'Bearer']).optional(),
|
|
31
|
+
azp: z.string().optional(),
|
|
32
|
+
aud: z.union([z.string(), z.array(z.string())]).optional(),
|
|
33
|
+
email: z.string().min(1).optional(),
|
|
34
|
+
email_verified: z.boolean().optional(),
|
|
35
|
+
phone_number: z.string().min(1).optional(),
|
|
36
|
+
phone_number_verified: z.boolean().optional(),
|
|
37
|
+
name: z.string().optional(),
|
|
38
|
+
picture: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const jwksByIssuer = new Map<string, { url: string; resolver: JWTVerifyGetKey }>();
|
|
42
|
+
let resolverForTests: ((issuer: string) => JWTVerifyGetKey) | undefined;
|
|
43
|
+
|
|
44
|
+
/** Replace JWKS resolution in isolated tests; resetting also clears memoized resolvers. */
|
|
45
|
+
export const setKeycloakJwksResolverForTests = (
|
|
46
|
+
resolver: ((issuer: string) => JWTVerifyGetKey) | undefined
|
|
47
|
+
): void => {
|
|
48
|
+
resolverForTests = resolver;
|
|
49
|
+
jwksByIssuer.clear();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Resolve an issuer's JWKS endpoint, optionally using a configured internal hostname. */
|
|
53
|
+
export const getKeycloakIssuerJwksUrl = (issuer: string): string => {
|
|
54
|
+
for (const entry of (environment.KEYCLOAK_JWKS_URL_OVERRIDES ?? '').split(',')) {
|
|
55
|
+
const separator = entry.indexOf('=');
|
|
56
|
+
if (separator < 0) continue;
|
|
57
|
+
const configuredIssuer = entry.slice(0, separator).trim();
|
|
58
|
+
const url = entry.slice(separator + 1).trim();
|
|
59
|
+
if (configuredIssuer === issuer && url) return url;
|
|
60
|
+
}
|
|
61
|
+
return `${issuer}/protocol/openid-connect/certs`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Reuse remote key caches and rotation handling per trusted issuer. */
|
|
65
|
+
export const getKeycloakJwks = (issuer: string): JWTVerifyGetKey => {
|
|
66
|
+
const url = getKeycloakIssuerJwksUrl(issuer);
|
|
67
|
+
const cached = jwksByIssuer.get(issuer);
|
|
68
|
+
if (cached?.url === url) return cached.resolver;
|
|
69
|
+
const resolver = resolverForTests ? resolverForTests(issuer) : createRemoteJWKSet(new URL(url));
|
|
70
|
+
jwksByIssuer.set(issuer, { url, resolver });
|
|
71
|
+
return resolver;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Verify signature and claims, checking the issuer allowlist before resolving keys. */
|
|
75
|
+
export const verifyKeycloakJwt = async (
|
|
76
|
+
token: string,
|
|
77
|
+
opts: KeycloakVerifyOptions
|
|
78
|
+
): Promise<KeycloakClaims> => {
|
|
79
|
+
decodeProtectedHeader(token);
|
|
80
|
+
const { iss } = decodeJwt(token);
|
|
81
|
+
if (typeof iss !== 'string' || !opts.issuers.includes(iss)) {
|
|
82
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { payload } = await jwtVerify(token, getKeycloakJwks(iss), {
|
|
86
|
+
issuer: iss,
|
|
87
|
+
algorithms: ['RS256', 'ES256'],
|
|
88
|
+
clockTolerance: 30,
|
|
89
|
+
});
|
|
90
|
+
const result = claimsSchema.safeParse(payload);
|
|
91
|
+
if (!result.success) {
|
|
92
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
|
|
93
|
+
}
|
|
94
|
+
const claims = result.data;
|
|
95
|
+
const audiences = typeof claims.aud === 'string' ? [claims.aud] : (claims.aud ?? []);
|
|
96
|
+
if (claims.azp) audiences.push(claims.azp);
|
|
97
|
+
if (!audiences.some(audience => opts.audiences.includes(audience))) {
|
|
98
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
|
|
99
|
+
}
|
|
100
|
+
return claims;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Read comma-separated issuer and client allowlists from the service configuration. */
|
|
104
|
+
export const getKeycloakVerifyOptionsFromEnv = (): KeycloakVerifyOptions | null => {
|
|
105
|
+
const split = (value: string | undefined): string[] =>
|
|
106
|
+
(value ?? '')
|
|
107
|
+
.split(',')
|
|
108
|
+
.map(entry => entry.trim())
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
const issuers = split(environment.KEYCLOAK_ISSUERS);
|
|
111
|
+
if (!issuers.length) return null;
|
|
112
|
+
const audiences = split(environment.KEYCLOAK_AUDIENCES);
|
|
113
|
+
if (!audiences.length) {
|
|
114
|
+
throw new TRPCError({
|
|
115
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
116
|
+
message: 'KEYCLOAK_AUDIENCES is required when KEYCLOAK_ISSUERS is configured',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return { issuers, audiences };
|
|
120
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
|
-
getSigningAuthorityForDid: vi.fn(
|
|
4
|
+
getSigningAuthorityForDid: vi.fn(),
|
|
5
5
|
initLearnCard: vi.fn(async (options: { didWeb?: string }) => ({
|
|
6
6
|
did: options.didWeb ?? 'did:key:cached-test',
|
|
7
7
|
})),
|
|
@@ -18,9 +18,19 @@ vi.mock('@learncard/didkit-plugin-node', () => ({
|
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
import { getSigningAuthorityLearnCard } from './learnCard.helpers';
|
|
21
|
+
import { seedEncryption } from './seedEncryption.helpers';
|
|
21
22
|
|
|
22
23
|
describe('getSigningAuthorityLearnCard', () => {
|
|
23
24
|
it('isolates cached wallets by owner DID when authorities share a seed', async () => {
|
|
25
|
+
const authorities = await Promise.all(
|
|
26
|
+
['did:key:z6MkOwner', 'did:web:example.com:owner'].map(async ownerDid => {
|
|
27
|
+
const identity = { _id: ownerDid, ownerDid, name: 'shared-authority' };
|
|
28
|
+
return { ...identity, ...(await seedEncryption.encrypt('a'.repeat(64), identity)) };
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
mocks.getSigningAuthorityForDid.mockImplementation(async ownerDid =>
|
|
32
|
+
authorities.find(authority => authority.ownerDid === ownerDid)
|
|
33
|
+
);
|
|
24
34
|
const didKeyWallet = await getSigningAuthorityLearnCard(
|
|
25
35
|
'did:key:z6MkOwner',
|
|
26
36
|
'shared-authority'
|
|
@@ -8,6 +8,11 @@ import type { EmptyLearnCard, LearnCardFromSeed, DidWebLearnCardFromSeed } from
|
|
|
8
8
|
|
|
9
9
|
import { getSigningAuthorityForDid } from '@accesslayer/signing-authority/read';
|
|
10
10
|
import { getLRUCache } from '@cache/in-memory-lru';
|
|
11
|
+
import {
|
|
12
|
+
decryptSigningAuthoritySeed,
|
|
13
|
+
getSeedCacheFingerprint,
|
|
14
|
+
logSeedEncryptionFailure,
|
|
15
|
+
} from '@helpers/seedEncryption.helpers';
|
|
11
16
|
|
|
12
17
|
const cloud = environment.LEARN_CLOUD_URL
|
|
13
18
|
? { url: environment.LEARN_CLOUD_URL }
|
|
@@ -16,11 +21,11 @@ const cloud = environment.LEARN_CLOUD_URL
|
|
|
16
21
|
let emptyLearnCard: EmptyLearnCard['returnValue'];
|
|
17
22
|
let learnCard: LearnCardFromSeed['returnValue'];
|
|
18
23
|
|
|
19
|
-
const saCardsCache = getLRUCache<
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
const saCardsCache = getLRUCache<{
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
card: LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue'];
|
|
27
|
+
}>();
|
|
22
28
|
const didWebCardsCache = getLRUCache<DidWebLearnCardFromSeed['returnValue']>();
|
|
23
|
-
const ephemeralCardsCache = getLRUCache<LearnCardFromSeed['returnValue']>();
|
|
24
29
|
|
|
25
30
|
// The DIDKit WASM is copied next to the compiled handler at build time (see
|
|
26
31
|
// esbuildPlugins.cjs). The Lambda bundle's node_modules layout doesn't match what
|
|
@@ -116,22 +121,20 @@ export const getSigningAuthorityLearnCard = async (
|
|
|
116
121
|
|
|
117
122
|
const sa = await getSigningAuthorityForDid(ownerDID, name);
|
|
118
123
|
|
|
119
|
-
if (!sa
|
|
120
|
-
console.error('[LCA getSigningAuthorityLearnCard] SA not found or has no seed:', {
|
|
121
|
-
ownerDID,
|
|
122
|
-
name,
|
|
123
|
-
saFound: !!sa,
|
|
124
|
-
hasSeed: !!sa?.seed,
|
|
125
|
-
});
|
|
124
|
+
if (!sa) {
|
|
126
125
|
throw new Error(`No signing authority found for ownerDID="${ownerDID}" name="${name}"`);
|
|
127
126
|
}
|
|
128
|
-
const cacheKey = `${
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
const cacheKey = `${ownerDID}|${name}`;
|
|
128
|
+
let fingerprint: string;
|
|
129
|
+
try {
|
|
130
|
+
// Validate the envelope before accepting a cache hit. The database lookup remains
|
|
131
|
+
// on the warm path so deletion/recreation or modification cannot reuse an old key.
|
|
132
|
+
fingerprint = getSeedCacheFingerprint(sa);
|
|
133
|
+
const cachedValue = saCardsCache.get(cacheKey);
|
|
134
|
+
if (cachedValue?.fingerprint === fingerprint) return cachedValue.card;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
logSeedEncryptionFailure(error, 'decrypt', sa);
|
|
137
|
+
throw error;
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
console.log('[LCA getSigningAuthorityLearnCard] Initializing SA LearnCard:', {
|
|
@@ -139,24 +142,40 @@ export const getSigningAuthorityLearnCard = async (
|
|
|
139
142
|
ownerDID,
|
|
140
143
|
});
|
|
141
144
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
145
|
+
// Finish engine setup before decrypting so the seed is only materialized when
|
|
146
|
+
// the signer is ready to initialize.
|
|
147
|
+
const options: LearnCardFromSeed['args'] | DidWebLearnCardFromSeed['args'] = {
|
|
148
|
+
didkit: await getDidKitInit(),
|
|
149
|
+
seed: '',
|
|
150
|
+
cloud,
|
|
151
|
+
allowRemoteContexts: true,
|
|
152
|
+
...(ownerDID.startsWith('did:web:') ? { didWeb: ownerDID } : {}),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
try {
|
|
157
|
+
options.seed = await decryptSigningAuthoritySeed(sa);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logSeedEncryptionFailure(error, 'decrypt', sa);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Select the seed initializer overload without copying the seed-bearing options.
|
|
164
|
+
const initialize: (
|
|
165
|
+
config: LearnCardFromSeed['args'] | DidWebLearnCardFromSeed['args']
|
|
166
|
+
) => Promise<LearnCardFromSeed['returnValue'] | DidWebLearnCardFromSeed['returnValue']> =
|
|
167
|
+
initLearnCard;
|
|
168
|
+
const saLearnCard = await initialize(options);
|
|
169
|
+
saCardsCache.add(cacheKey, { fingerprint, card: saLearnCard });
|
|
170
|
+
|
|
171
|
+
return saLearnCard;
|
|
172
|
+
} finally {
|
|
173
|
+
// Release this transient reference on both success and failure. This is
|
|
174
|
+
// not zeroization: initLearnCard requires an immutable string, and its
|
|
175
|
+
// DID Key plugin retains the seed/private keys for the cached signer's
|
|
176
|
+
// lifetime. Envelope encryption protects storage, not process memory.
|
|
177
|
+
options.seed = '';
|
|
178
|
+
}
|
|
160
179
|
};
|
|
161
180
|
|
|
162
181
|
export const getServerDidWebDID = (): string => {
|
|
@@ -196,17 +215,10 @@ export const getDidWebLearnCard = async (
|
|
|
196
215
|
export const getEphemeralLearnCard = async (
|
|
197
216
|
seed: string
|
|
198
217
|
): Promise<LearnCardFromSeed['returnValue']> => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (cachedValue) return cachedValue;
|
|
202
|
-
|
|
203
|
-
const ephemeralLearnCard = await initLearnCard({
|
|
218
|
+
// Creation is a one-shot operation; do not retain its seed in a cache key.
|
|
219
|
+
return initLearnCard({
|
|
204
220
|
didkit: await getDidKitInit(),
|
|
205
221
|
seed,
|
|
206
222
|
cloud,
|
|
207
223
|
});
|
|
208
|
-
|
|
209
|
-
ephemeralCardsCache.add(seed, ephemeralLearnCard);
|
|
210
|
-
|
|
211
|
-
return ephemeralLearnCard;
|
|
212
224
|
};
|