@learncard/lca-api-service 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +26 -2
- package/AGENTS.md +59 -44
- package/CHANGELOG.md +57 -0
- package/compose.env +5 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +297 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/environment.d.ts +152 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/EscrowHold.d.ts +75 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +316 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/routes/escrow.d.ts +150 -0
- package/dist/routes/firebase.test.d.ts +1 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +181 -7
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/services/escrow-enclave/index.d.ts +9 -0
- package/dist/services/escrow-enclave/notifications.d.ts +9 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +67 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/lambda.ts +8 -3
- package/package.json +17 -11
- package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/seedMigrationLambda.test.ts +145 -0
- package/seedMigrationLambda.ts +50 -0
- package/serverless-local.yml +3 -0
- package/serverless.escrow-env.cjs +20 -0
- package/serverless.yml +133 -0
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/app.ts +2 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/environment.test.ts +116 -0
- package/src/config/environment.ts +97 -0
- package/src/docker-entry.ts +4 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/EscrowHold.ts +225 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1024 -40
- package/src/models/index.ts +1 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +826 -0
- package/src/routes/firebase.test.ts +94 -0
- package/src/routes/firebase.ts +99 -56
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1017 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
- package/src/services/escrow-enclave/README.md +51 -0
- package/src/services/escrow-enclave/index.ts +36 -0
- package/src/services/escrow-enclave/notifications.ts +14 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
- package/src/services/escrow-enclave/types.ts +76 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/credentials.spec.ts +2 -0
- package/test/keys-escrow.spec.ts +1389 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/escrow.spec.ts +210 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +40 -0
- package/vitest.config.ts +1 -1
- package/vitest.integration.config.ts +5 -0
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -15,6 +15,11 @@ export const lcaApiEnvironmentShape = {
|
|
|
15
15
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
16
16
|
PORT: environmentPort.default(3000),
|
|
17
17
|
SEED: requiredEnvironmentString,
|
|
18
|
+
SA_SEED_KMS_KEY_ARN: optionalEnvironmentString,
|
|
19
|
+
SA_SEED_LOCAL_KEK: optionalEnvironmentString,
|
|
20
|
+
// Defaults depend on offline/test mode and are resolved below.
|
|
21
|
+
SA_SEED_ENCRYPT_WRITES: optionalEnvironmentBoolean,
|
|
22
|
+
SA_SEED_ALLOW_LEGACY_READ: optionalEnvironmentBoolean,
|
|
18
23
|
MONGO_URI: requiredEnvironmentString,
|
|
19
24
|
MONGO_DB_NAME: requiredEnvironmentString,
|
|
20
25
|
DOMAIN_NAME: optionalEnvironmentString,
|
|
@@ -32,6 +37,17 @@ export const lcaApiEnvironmentShape = {
|
|
|
32
37
|
POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS: optionalEnvironmentString,
|
|
33
38
|
POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS: optionalEnvironmentString,
|
|
34
39
|
POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS: optionalEnvironmentString,
|
|
40
|
+
ESCROW_RELAY_URL: optionalEnvironmentUrl,
|
|
41
|
+
ESCROW_RELAY_AUTH_TOKEN: optionalEnvironmentString,
|
|
42
|
+
ESCROW_ENCLAVE_MODE: optionalEnvironmentString.pipe(z.enum(['software', 'remote']).optional()),
|
|
43
|
+
ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: optionalEnvironmentString,
|
|
44
|
+
ESCROW_ENCLAVE_ACTIVE_KEY_ID: optionalEnvironmentString,
|
|
45
|
+
ESCROW_HOLD_DURATION_MS: optionalEnvironmentString
|
|
46
|
+
.transform(value => (value === undefined ? 604_800_000 : Number(value)))
|
|
47
|
+
.pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
|
|
48
|
+
ESCROW_HOLD_RESTART_MIN_AGE_MS: optionalEnvironmentString
|
|
49
|
+
.transform(value => (value === undefined ? 86_400_000 : Number(value)))
|
|
50
|
+
.pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
|
|
35
51
|
ANDROID_PUSH_ICON: optionalEnvironmentString,
|
|
36
52
|
REDIS_HOST: optionalEnvironmentString,
|
|
37
53
|
REDIS_PORT: optionalEnvironmentPort,
|
|
@@ -49,7 +65,74 @@ export const lcaApiEnvironmentShape = {
|
|
|
49
65
|
|
|
50
66
|
export const lcaApiEnvironmentSchema = z
|
|
51
67
|
.object(lcaApiEnvironmentShape)
|
|
68
|
+
.transform(environment => {
|
|
69
|
+
const legacyLocalDefaults = environment.IS_OFFLINE && environment.NODE_ENV !== 'test';
|
|
70
|
+
return {
|
|
71
|
+
...environment,
|
|
72
|
+
SA_SEED_ENCRYPT_WRITES: environment.SA_SEED_ENCRYPT_WRITES ?? !legacyLocalDefaults,
|
|
73
|
+
SA_SEED_ALLOW_LEGACY_READ: environment.SA_SEED_ALLOW_LEGACY_READ ?? legacyLocalDefaults,
|
|
74
|
+
};
|
|
75
|
+
})
|
|
52
76
|
.superRefine((environment, context) => {
|
|
77
|
+
if (environment.ESCROW_ENCLAVE_MODE === 'software') {
|
|
78
|
+
try {
|
|
79
|
+
const keys = parseEscrowPrivateKeys(
|
|
80
|
+
environment.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
|
|
81
|
+
);
|
|
82
|
+
if (
|
|
83
|
+
!environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID ||
|
|
84
|
+
!Object.hasOwn(keys, environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID)
|
|
85
|
+
) {
|
|
86
|
+
context.addIssue({
|
|
87
|
+
code: 'custom',
|
|
88
|
+
path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
|
|
89
|
+
message: 'Software escrow requires an active key ID present in the key map',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
context.addIssue({
|
|
94
|
+
code: 'custom',
|
|
95
|
+
path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
|
|
96
|
+
message:
|
|
97
|
+
'Software escrow requires a valid nonempty key ID to private key JSON map',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (environment.SA_SEED_KMS_KEY_ARN) {
|
|
102
|
+
if (
|
|
103
|
+
!/^arn:aws[a-z-]*:kms:[a-z0-9-]+:\d{12}:key\/[a-zA-Z0-9-]+$/.test(
|
|
104
|
+
environment.SA_SEED_KMS_KEY_ARN
|
|
105
|
+
)
|
|
106
|
+
) {
|
|
107
|
+
context.addIssue({
|
|
108
|
+
code: 'custom',
|
|
109
|
+
path: ['SA_SEED_KMS_KEY_ARN'],
|
|
110
|
+
message: 'Must be a KMS key ARN (not an alias)',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} else if (!environment.IS_OFFLINE && environment.NODE_ENV !== 'test') {
|
|
114
|
+
context.addIssue({
|
|
115
|
+
code: 'custom',
|
|
116
|
+
path: ['SA_SEED_KMS_KEY_ARN'],
|
|
117
|
+
message: 'Required outside offline/test environments',
|
|
118
|
+
});
|
|
119
|
+
} else if (
|
|
120
|
+
(environment.SA_SEED_ENCRYPT_WRITES || environment.SA_SEED_LOCAL_KEK !== undefined) &&
|
|
121
|
+
!/^[a-fA-F0-9]{64}$/.test(environment.SA_SEED_LOCAL_KEK ?? '')
|
|
122
|
+
) {
|
|
123
|
+
context.addIssue({
|
|
124
|
+
code: 'custom',
|
|
125
|
+
path: ['SA_SEED_LOCAL_KEK'],
|
|
126
|
+
message: 'A 32-byte hex key is required for local seed encryption',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (!environment.SA_SEED_ENCRYPT_WRITES && !environment.SA_SEED_ALLOW_LEGACY_READ) {
|
|
130
|
+
context.addIssue({
|
|
131
|
+
code: 'custom',
|
|
132
|
+
path: ['SA_SEED_ENCRYPT_WRITES'],
|
|
133
|
+
message: 'Plaintext writes require the compatibility reader',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
53
136
|
if (
|
|
54
137
|
environment.NODE_ENV === 'production' &&
|
|
55
138
|
!environment.IS_OFFLINE &&
|
|
@@ -85,6 +168,7 @@ export const parseLcaApiEnvironment = (
|
|
|
85
168
|
raw.NODE_ENV === 'test'
|
|
86
169
|
? {
|
|
87
170
|
SEED: 'a'.repeat(64),
|
|
171
|
+
SA_SEED_LOCAL_KEK: 'f'.repeat(64),
|
|
88
172
|
MONGO_URI: 'mongodb://localhost:27017',
|
|
89
173
|
MONGO_DB_NAME: 'lca-api-test',
|
|
90
174
|
}
|
|
@@ -101,4 +185,17 @@ export const parseLcaApiEnvironment = (
|
|
|
101
185
|
);
|
|
102
186
|
};
|
|
103
187
|
|
|
188
|
+
/** Parse configuration without exposing secret values in validation errors. */
|
|
189
|
+
export const parseEscrowPrivateKeys = (serialized: string): Record<string, string> => {
|
|
190
|
+
try {
|
|
191
|
+
const parsed: unknown = JSON.parse(serialized);
|
|
192
|
+
return z
|
|
193
|
+
.record(z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), z.string().trim().min(1))
|
|
194
|
+
.refine(keys => Object.keys(keys).length > 0)
|
|
195
|
+
.parse(parsed);
|
|
196
|
+
} catch {
|
|
197
|
+
throw new Error('Invalid escrow private key configuration');
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
104
201
|
export const environment = parseLcaApiEnvironment(process.env);
|
package/src/docker-entry.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { fastifyTRPCOpenApiPlugin, CreateOpenApiFastifyPluginOptions } from 'trp
|
|
|
9
9
|
import { appRouter, type AppRouter, createContext } from './app';
|
|
10
10
|
import { openApiDocument } from './openapi';
|
|
11
11
|
import { didFastifyPlugin } from './dids';
|
|
12
|
+
import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './models';
|
|
12
13
|
|
|
13
14
|
const server = Fastify({ maxParamLength: 5000 });
|
|
14
15
|
|
|
@@ -50,6 +51,7 @@ server.register(fastifyCors, {
|
|
|
50
51
|
'Authorization',
|
|
51
52
|
'X-Tenant-Id',
|
|
52
53
|
'X-Guardian-Approval',
|
|
54
|
+
'X-Auth-Token',
|
|
53
55
|
'baggage',
|
|
54
56
|
'sentry-trace',
|
|
55
57
|
],
|
|
@@ -104,6 +106,8 @@ server.register(didFastifyPlugin);
|
|
|
104
106
|
(async () => {
|
|
105
107
|
try {
|
|
106
108
|
console.log('Server starting on port ', environment.PORT || 3000);
|
|
109
|
+
await ensureUserKeysIndexes();
|
|
110
|
+
await createEscrowHoldsIndexes();
|
|
107
111
|
await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
|
|
108
112
|
} catch (err) {
|
|
109
113
|
console.error(err);
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { KMSClient, GenerateDataKeyCommand } from '@aws-sdk/client-kms';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
read: vi.fn(),
|
|
6
|
+
init: vi.fn(),
|
|
7
|
+
nativeInit: vi.fn(async () => ({})),
|
|
8
|
+
keys: [] as string[],
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@accesslayer/signing-authority/read', () => ({ getSigningAuthorityForDid: mocks.read }));
|
|
11
|
+
vi.mock('@learncard/init', () => ({ initLearnCard: mocks.init }));
|
|
12
|
+
vi.mock('@learncard/didkit-plugin-node', () => ({ getDidKitPlugin: mocks.nativeInit }));
|
|
13
|
+
vi.mock('@cache/in-memory-lru', async importOriginal => {
|
|
14
|
+
const actual = await importOriginal<typeof import('@cache/in-memory-lru')>();
|
|
15
|
+
return {
|
|
16
|
+
getLRUCache: <T>() => {
|
|
17
|
+
const cache = actual.getLRUCache<T>();
|
|
18
|
+
return {
|
|
19
|
+
...cache,
|
|
20
|
+
add: (key: string, value: T) => {
|
|
21
|
+
mocks.keys.push(key);
|
|
22
|
+
return cache.add(key, value);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
vi.mock('@environment', async importOriginal => {
|
|
29
|
+
const actual = await importOriginal<typeof import('@environment')>();
|
|
30
|
+
return {
|
|
31
|
+
environment: {
|
|
32
|
+
...actual.environment,
|
|
33
|
+
SKIP_DIDKIT_NAPI: false,
|
|
34
|
+
SA_SEED_KMS_KEY_ARN:
|
|
35
|
+
'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import { environment } from '@environment';
|
|
41
|
+
import { seedEncryption } from './seedEncryption.helpers';
|
|
42
|
+
import { getEphemeralLearnCard, getSigningAuthorityLearnCard } from './learnCard.helpers';
|
|
43
|
+
|
|
44
|
+
const spyOnKms = () => vi.spyOn(KMSClient.prototype, 'send');
|
|
45
|
+
let send: ReturnType<typeof spyOnKms>;
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
mocks.read.mockReset();
|
|
49
|
+
mocks.init.mockReset().mockImplementation(async options => ({ options }));
|
|
50
|
+
mocks.keys.length = 0;
|
|
51
|
+
send = vi.spyOn(KMSClient.prototype, 'send').mockImplementation(async command => ({
|
|
52
|
+
Plaintext: Buffer.alloc(32, 7),
|
|
53
|
+
KeyId: environment.SA_SEED_KMS_KEY_ARN,
|
|
54
|
+
...(command instanceof GenerateDataKeyCommand
|
|
55
|
+
? { CiphertextBlob: Buffer.from('wrapped') }
|
|
56
|
+
: {}),
|
|
57
|
+
$metadata: {},
|
|
58
|
+
}));
|
|
59
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const authority = async (id: string, ownerDid = 'did:example:owner', seed = 'a'.repeat(64)) => {
|
|
63
|
+
const identity = { _id: id, ownerDid, name: 'main', did: 'did:key:example' };
|
|
64
|
+
return { ...identity, ...(await seedEncryption.encrypt(seed, identity)) };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe('SA signing cache', () => {
|
|
68
|
+
it('waits for engine setup before decrypting the seed', async () => {
|
|
69
|
+
const sa = await authority('engine-setup', 'did:example:engine-setup');
|
|
70
|
+
mocks.read.mockResolvedValue(sa);
|
|
71
|
+
let finishEngineSetup!: () => void;
|
|
72
|
+
mocks.nativeInit.mockImplementationOnce(
|
|
73
|
+
() =>
|
|
74
|
+
new Promise(resolve => {
|
|
75
|
+
finishEngineSetup = () => resolve({});
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
send.mockClear();
|
|
79
|
+
|
|
80
|
+
const initialization = getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
81
|
+
await vi.waitFor(() => expect(finishEngineSetup).toBeTypeOf('function'));
|
|
82
|
+
expect(send).not.toHaveBeenCalled();
|
|
83
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
84
|
+
|
|
85
|
+
finishEngineSetup();
|
|
86
|
+
await initialization;
|
|
87
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each(['did:example:seed-lifetime', 'did:web:seed-lifetime.example'])(
|
|
91
|
+
'releases initialization options after success for %s',
|
|
92
|
+
async ownerDid => {
|
|
93
|
+
const seed = 'c'.repeat(64);
|
|
94
|
+
const sa = await authority(ownerDid, ownerDid, seed);
|
|
95
|
+
mocks.read.mockResolvedValue(sa);
|
|
96
|
+
let finishInitialization!: () => void;
|
|
97
|
+
let initializationOptions: { seed: string; didWeb?: string } | undefined;
|
|
98
|
+
const card = {};
|
|
99
|
+
mocks.init.mockImplementationOnce(options => {
|
|
100
|
+
initializationOptions = options;
|
|
101
|
+
return new Promise(resolve => {
|
|
102
|
+
finishInitialization = () => resolve(card);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const initialization = getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
107
|
+
await vi.waitFor(() => expect(initializationOptions?.seed).toBe(seed));
|
|
108
|
+
expect(initializationOptions?.didWeb).toBe(
|
|
109
|
+
ownerDid.startsWith('did:web:') ? ownerDid : undefined
|
|
110
|
+
);
|
|
111
|
+
expect(mocks.keys).toEqual([]);
|
|
112
|
+
|
|
113
|
+
finishInitialization();
|
|
114
|
+
expect(await initialization).toBe(card);
|
|
115
|
+
expect(initializationOptions?.seed).toBe('');
|
|
116
|
+
expect(await getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).toBe(card);
|
|
117
|
+
expect(mocks.init).toHaveBeenCalledTimes(1);
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
it('releases initialization options after failure and does not cache a failed signer', async () => {
|
|
122
|
+
const sa = await authority('init-failure', 'did:example:init-failure');
|
|
123
|
+
mocks.read.mockResolvedValue(sa);
|
|
124
|
+
let initializationOptions: { seed: string } | undefined;
|
|
125
|
+
const failure = new Error('Signer initialization failed');
|
|
126
|
+
mocks.init.mockImplementationOnce(async options => {
|
|
127
|
+
initializationOptions = options;
|
|
128
|
+
expect(options.seed).toBe('a'.repeat(64));
|
|
129
|
+
throw failure;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toBe(failure);
|
|
133
|
+
expect(initializationOptions?.seed).toBe('');
|
|
134
|
+
expect(mocks.keys).toEqual([]);
|
|
135
|
+
|
|
136
|
+
await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
137
|
+
expect(mocks.init).toHaveBeenCalledTimes(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('makes one KMS call when cold and zero when warm, retaining no seed in cache keys', async () => {
|
|
141
|
+
const sa = await authority('warm');
|
|
142
|
+
mocks.read.mockResolvedValue(sa);
|
|
143
|
+
send.mockClear();
|
|
144
|
+
const first = await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
145
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(await getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).toBe(first);
|
|
147
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(mocks.read).toHaveBeenCalledTimes(2);
|
|
149
|
+
expect(mocks.keys).toEqual([`${sa.ownerDid}|${sa.name}`]);
|
|
150
|
+
await getEphemeralLearnCard('b'.repeat(64));
|
|
151
|
+
expect(mocks.keys).toEqual([`${sa.ownerDid}|${sa.name}`]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not reuse a deleted/recreated authority under the same owner and name', async () => {
|
|
155
|
+
const firstRecord = await authority('old');
|
|
156
|
+
const replacement = await authority('replacement', firstRecord.ownerDid, 'b'.repeat(64));
|
|
157
|
+
mocks.read
|
|
158
|
+
.mockResolvedValueOnce(firstRecord)
|
|
159
|
+
.mockResolvedValueOnce(null)
|
|
160
|
+
.mockResolvedValueOnce(replacement);
|
|
161
|
+
send.mockClear();
|
|
162
|
+
const first = await getSigningAuthorityLearnCard(firstRecord.ownerDid, firstRecord.name);
|
|
163
|
+
await expect(
|
|
164
|
+
getSigningAuthorityLearnCard(firstRecord.ownerDid, firstRecord.name)
|
|
165
|
+
).rejects.toThrow();
|
|
166
|
+
const second = await getSigningAuthorityLearnCard(replacement.ownerDid, replacement.name);
|
|
167
|
+
expect(first).not.toBe(second);
|
|
168
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('does not serve a warm wallet after its envelope is tampered with', async () => {
|
|
172
|
+
const sa = await authority('tamper');
|
|
173
|
+
mocks.read.mockResolvedValue(sa);
|
|
174
|
+
await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
175
|
+
const bytes = Buffer.from(sa.encryptedSeed, 'base64');
|
|
176
|
+
bytes[15] = bytes[15]! ^ 1;
|
|
177
|
+
mocks.read.mockResolvedValue({ ...sa, encryptedSeed: bytes.toString('base64') });
|
|
178
|
+
await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toMatchObject({
|
|
179
|
+
category: 'authentication_failed',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps did:web initialization behavior and does not cache failed decrypts', async () => {
|
|
184
|
+
const sa = await authority('did-web', 'did:web:issuer.example');
|
|
185
|
+
mocks.read.mockResolvedValue(sa);
|
|
186
|
+
send.mockRejectedValueOnce({ name: 'AccessDeniedException' });
|
|
187
|
+
await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toThrow();
|
|
188
|
+
expect(mocks.keys).toEqual([]);
|
|
189
|
+
await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
190
|
+
expect(mocks.init).toHaveBeenLastCalledWith(
|
|
191
|
+
expect.objectContaining({ didWeb: sa.ownerDid })
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting Helpers
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for rate limiting across routes. Uses atomic Redis
|
|
5
|
+
* operations to prevent TOCTOU races.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import cache from '@cache';
|
|
9
|
+
|
|
10
|
+
/** Default Redis key prefix for rate limit counters */
|
|
11
|
+
const DEFAULT_RATE_PREFIX = 'rate-limit:';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lua script for atomic increment with fixed-window TTL.
|
|
15
|
+
* Only sets EXPIRE on first increment OR when TTL is missing/expired.
|
|
16
|
+
* This prevents a sliding window where blocked clients keep retrying
|
|
17
|
+
* and re-arming their own TTL indefinitely.
|
|
18
|
+
*/
|
|
19
|
+
const ATOMIC_INCR_SCRIPT = `
|
|
20
|
+
local current = redis.call('INCR', KEYS[1])
|
|
21
|
+
if current == 1 or redis.call('TTL', KEYS[1]) < 0 then
|
|
22
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
23
|
+
end
|
|
24
|
+
return current
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check and increment a rate-limit counter in Redis.
|
|
29
|
+
* Returns true if the request is allowed, false if rate-limited.
|
|
30
|
+
*
|
|
31
|
+
* Uses an atomic Lua script to increment and set TTL in a single operation,
|
|
32
|
+
* preventing the race condition where a crash between INCR and EXPIRE
|
|
33
|
+
* could leave a key with no expiry, permanently blocking users.
|
|
34
|
+
*
|
|
35
|
+
* @param key - Unique key identifying the resource being rate-limited (e.g., `login-verify:user@example.com`)
|
|
36
|
+
* @param maxAttempts - Maximum number of attempts allowed within the window
|
|
37
|
+
* @param windowSeconds - Duration of the rate-limit window in seconds
|
|
38
|
+
* @param prefix - Optional key prefix (defaults to 'rate-limit:')
|
|
39
|
+
* @returns true if the request is allowed, false if rate-limited
|
|
40
|
+
*/
|
|
41
|
+
export const checkRateLimit = async (
|
|
42
|
+
key: string,
|
|
43
|
+
maxAttempts: number,
|
|
44
|
+
windowSeconds: number,
|
|
45
|
+
prefix: string = DEFAULT_RATE_PREFIX
|
|
46
|
+
): Promise<boolean> => {
|
|
47
|
+
const fullKey = `${prefix}${key}`;
|
|
48
|
+
const redis = cache.redis ?? cache.node;
|
|
49
|
+
|
|
50
|
+
// Use Lua script for atomic INCR + EXPIRE
|
|
51
|
+
const current = (await redis.eval(
|
|
52
|
+
ATOMIC_INCR_SCRIPT,
|
|
53
|
+
1,
|
|
54
|
+
fullKey,
|
|
55
|
+
windowSeconds.toString()
|
|
56
|
+
)) as number;
|
|
57
|
+
|
|
58
|
+
if (current > maxAttempts) return false;
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the current count for a rate-limit key without incrementing.
|
|
65
|
+
*
|
|
66
|
+
* @param key - The rate-limit key
|
|
67
|
+
* @param prefix - Optional key prefix (defaults to 'rate-limit:')
|
|
68
|
+
* @returns Current count, or 0 if key doesn't exist
|
|
69
|
+
*/
|
|
70
|
+
export const getRateLimitCount = async (
|
|
71
|
+
key: string,
|
|
72
|
+
prefix: string = DEFAULT_RATE_PREFIX
|
|
73
|
+
): Promise<number> => {
|
|
74
|
+
const fullKey = `${prefix}${key}`;
|
|
75
|
+
const redis = cache.redis ?? cache.node;
|
|
76
|
+
const count = await redis.get(fullKey);
|
|
77
|
+
return count ? parseInt(count, 10) : 0;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Clear a rate-limit counter (e.g., after successful authentication).
|
|
82
|
+
*
|
|
83
|
+
* @param key - The rate-limit key to clear
|
|
84
|
+
* @param prefix - Optional key prefix (defaults to 'rate-limit:')
|
|
85
|
+
*/
|
|
86
|
+
export const clearRateLimit = async (
|
|
87
|
+
key: string,
|
|
88
|
+
prefix: string = DEFAULT_RATE_PREFIX
|
|
89
|
+
): Promise<void> => {
|
|
90
|
+
const fullKey = `${prefix}${key}`;
|
|
91
|
+
const redis = cache.redis ?? cache.node;
|
|
92
|
+
await redis.del(fullKey);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decrement a rate-limit counter (e.g., on successful login to not penalize legit users).
|
|
97
|
+
* Unlike clearRateLimit, this preserves the counter for abuse detection while
|
|
98
|
+
* ensuring legitimate successes don't accumulate toward the limit.
|
|
99
|
+
*
|
|
100
|
+
* @param key - The rate-limit key to decrement
|
|
101
|
+
* @param prefix - Optional key prefix (defaults to 'rate-limit:')
|
|
102
|
+
*/
|
|
103
|
+
export const decrementRateLimit = async (
|
|
104
|
+
key: string,
|
|
105
|
+
prefix: string = DEFAULT_RATE_PREFIX
|
|
106
|
+
): Promise<void> => {
|
|
107
|
+
const fullKey = `${prefix}${key}`;
|
|
108
|
+
const redis = cache.redis ?? cache.node;
|
|
109
|
+
// DECR returns -1 if key doesn't exist, but we don't care about the result
|
|
110
|
+
// The key will naturally expire via its TTL
|
|
111
|
+
await redis.decr(fullKey);
|
|
112
|
+
};
|