@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.
Files changed (99) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +57 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/firebase.test.d.ts +1 -0
  25. package/dist/routes/index.d.ts +6 -0
  26. package/dist/routes/keys.d.ts +181 -7
  27. package/dist/routes/qr-login.d.ts +1 -1
  28. package/dist/services/escrow-enclave/index.d.ts +9 -0
  29. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  31. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  32. package/dist/services/escrow-enclave/types.d.ts +67 -0
  33. package/dist/types/seed-encryption.d.ts +31 -0
  34. package/dist/types/seed-migration.d.ts +49 -0
  35. package/lambda.ts +8 -3
  36. package/package.json +17 -11
  37. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  38. package/scripts/check-signing-authority-redis.mjs +93 -0
  39. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  40. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  41. package/seedMigrationLambda.test.ts +145 -0
  42. package/seedMigrationLambda.ts +50 -0
  43. package/serverless-local.yml +3 -0
  44. package/serverless.escrow-env.cjs +20 -0
  45. package/serverless.yml +133 -0
  46. package/src/accesslayer/signing-authority/create.ts +11 -7
  47. package/src/app.ts +2 -0
  48. package/src/cache/challenges.ts +12 -2
  49. package/src/cache/in-memory-lru.ts +1 -0
  50. package/src/cache/recoverySessions.ts +252 -0
  51. package/src/config/environment.test.ts +116 -0
  52. package/src/config/environment.ts +97 -0
  53. package/src/docker-entry.ts +4 -0
  54. package/src/helpers/learnCard.helpers.test.ts +11 -1
  55. package/src/helpers/learnCard.helpers.ts +57 -45
  56. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  57. package/src/helpers/rateLimit.helpers.ts +112 -0
  58. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  59. package/src/helpers/seedEncryption.helpers.ts +372 -0
  60. package/src/helpers/signingAuthority.helpers.ts +4 -1
  61. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  62. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  63. package/src/models/EscrowHold.ts +225 -0
  64. package/src/models/SigningAuthority.ts +14 -3
  65. package/src/models/UserKey.escrowPin.test.ts +49 -0
  66. package/src/models/UserKey.ts +1024 -40
  67. package/src/models/index.ts +1 -0
  68. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  69. package/src/routes/credentials.ts +8 -0
  70. package/src/routes/escrow.ts +826 -0
  71. package/src/routes/firebase.test.ts +94 -0
  72. package/src/routes/firebase.ts +99 -56
  73. package/src/routes/index.ts +98 -5
  74. package/src/routes/keys.ts +1017 -174
  75. package/src/routes/qr-login.ts +51 -53
  76. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  77. package/src/services/escrow-enclave/README.md +51 -0
  78. package/src/services/escrow-enclave/index.ts +36 -0
  79. package/src/services/escrow-enclave/notifications.ts +14 -0
  80. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  81. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  82. package/src/services/escrow-enclave/types.ts +76 -0
  83. package/src/types/seed-encryption.ts +51 -0
  84. package/src/types/seed-migration.ts +61 -0
  85. package/test/credentials.spec.ts +2 -0
  86. package/test/keys-escrow.spec.ts +1389 -0
  87. package/test/keys-identity-rebind.spec.ts +456 -0
  88. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  89. package/test/keys-recovery-confirmation.spec.ts +586 -0
  90. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  91. package/test/keys-secret-leakage.spec.ts +265 -0
  92. package/test/models/escrow.spec.ts +210 -0
  93. package/test/signing-authority-http.spec.ts +161 -0
  94. package/test/signing-authority-seed-migration.spec.ts +414 -0
  95. package/test/signing-authority.spec.ts +40 -0
  96. package/vitest.config.ts +1 -1
  97. package/vitest.integration.config.ts +5 -0
  98. package/dist/cache/signing-authority.d.ts +0 -6
  99. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,225 @@
1
+ import { createHash, randomBytes, randomUUID } from 'crypto';
2
+ import { z } from 'zod';
3
+ import type { Collection } from 'mongodb';
4
+ import mongodb from '@mongo';
5
+ import { environment } from '@environment';
6
+ import { AuthProviderMappingValidator, type AuthProviderMapping } from './UserKey';
7
+
8
+ export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
9
+ export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
10
+ export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
11
+ export const EscrowHoldValidator = z.object({
12
+ _id: z.string().uuid(),
13
+ authProvider: AuthProviderMappingValidator,
14
+ primaryDid: z.string(),
15
+ shareVersion: z.number().int().positive(),
16
+ status: z.enum(['pending', 'cancelled', 'completed', 'expired']),
17
+ identityProofType: z.enum(['auth-token', 'recovery-session']),
18
+ requestedAt: z.date(),
19
+ releaseAfter: z.date(),
20
+ releasePolicy: z.enum(['hold', 'pin']).default('hold'),
21
+ cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
22
+ cancelledAt: z.date().optional(),
23
+ cancelledBy: z.enum(['did', 'system']).optional(),
24
+ completedAt: z.date().optional(),
25
+ clientEphemeralPublicKey: z.string().min(1).max(512),
26
+ resumeTokenHash: z.string().regex(/^[0-9a-f]{64}$/),
27
+ notifications: z
28
+ .array(
29
+ z.object({
30
+ kind: z.enum(['started', 'cancelled', 'completed']),
31
+ sentAt: z.date(),
32
+ })
33
+ )
34
+ .default([]),
35
+ requestIp: z.string().optional(),
36
+ createdAt: z.date(),
37
+ updatedAt: z.date(),
38
+ });
39
+ export type EscrowHold = z.infer<typeof EscrowHoldValidator>;
40
+ export type CreateEscrowHoldInput = Omit<
41
+ z.input<typeof EscrowHoldValidator>,
42
+ | '_id'
43
+ | 'status'
44
+ | 'createdAt'
45
+ | 'updatedAt'
46
+ | 'notifications'
47
+ | 'cancelledAt'
48
+ | 'cancelledBy'
49
+ | 'completedAt'
50
+ | 'cancelReason'
51
+ >;
52
+
53
+ export const getEscrowHoldsCollection = (): Collection<EscrowHold> =>
54
+ mongodb.collection<EscrowHold>(ESCROW_HOLDS_COLLECTION);
55
+
56
+ const runIndexMigrationOperation = async (operation: () => Promise<unknown>): Promise<void> => {
57
+ try {
58
+ await operation();
59
+ } catch (error) {
60
+ const code =
61
+ typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
62
+ // Unlike lookup indexes, uniqueness is a security invariant: fail closed on duplicates.
63
+ if (code === 27 || code === 85 || code === 86) {
64
+ const indexes = await getEscrowHoldsCollection().listIndexes().toArray();
65
+ if (
66
+ indexes.some(
67
+ index =>
68
+ index.unique &&
69
+ Object.keys(index.key ?? {}).length === 3 &&
70
+ index.key?.['authProvider.type'] === 1 &&
71
+ index.key?.['authProvider.id'] === 1 &&
72
+ index.key?.releasePolicy === 1 &&
73
+ Object.keys(index.partialFilterExpression ?? {}).length === 1 &&
74
+ index.partialFilterExpression?.status === 'pending'
75
+ )
76
+ )
77
+ return;
78
+ }
79
+ throw new Error('Escrow hold index initialization failed', { cause: error });
80
+ }
81
+ };
82
+
83
+ export const createEscrowHoldsIndexes = async (): Promise<void> => {
84
+ const collection = getEscrowHoldsCollection();
85
+ // Normalize legacy rows while the old, stricter index still protects them.
86
+ await collection.updateMany(
87
+ { releasePolicy: { $exists: false } },
88
+ { $set: { releasePolicy: 'hold' } }
89
+ );
90
+ await runIndexMigrationOperation(() =>
91
+ collection.createIndex(
92
+ { 'authProvider.type': 1, 'authProvider.id': 1, releasePolicy: 1 },
93
+ {
94
+ name: 'pending_escrow_identity_policy_unique',
95
+ unique: true,
96
+ partialFilterExpression: { status: 'pending' },
97
+ }
98
+ )
99
+ );
100
+ // Install the replacement before dropping the old index: no uniqueness gap.
101
+ try {
102
+ await collection.dropIndex('pending_escrow_identity_unique');
103
+ } catch (error) {
104
+ if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 27))
105
+ throw error;
106
+ }
107
+ await runIndexMigrationOperation(() =>
108
+ collection.createIndex({ 'authProvider.type': 1, 'authProvider.id': 1, status: 1 })
109
+ );
110
+ await runIndexMigrationOperation(() => collection.createIndex({ releaseAfter: 1 }));
111
+ };
112
+
113
+ export const createEscrowHold = async (input: CreateEscrowHoldInput): Promise<EscrowHold> => {
114
+ const now = new Date();
115
+ const parsed = EscrowHoldValidator.safeParse({
116
+ ...input,
117
+ _id: randomUUID(),
118
+ status: 'pending',
119
+ notifications: [],
120
+ createdAt: now,
121
+ updatedAt: now,
122
+ });
123
+ if (!parsed.success) throw new Error('Invalid escrow hold');
124
+ const hold = parsed.data;
125
+ await getEscrowHoldsCollection().insertOne(hold);
126
+ return hold;
127
+ };
128
+ export const findPendingEscrowHoldByAuthProvider = async (
129
+ authProvider: AuthProviderMapping,
130
+ releasePolicy?: EscrowHold['releasePolicy']
131
+ ): Promise<EscrowHold | null> =>
132
+ getEscrowHoldsCollection().findOne({
133
+ 'authProvider.type': authProvider.type,
134
+ 'authProvider.id': authProvider.id,
135
+ status: 'pending',
136
+ ...(releasePolicy === 'hold'
137
+ ? { $or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }] }
138
+ : releasePolicy
139
+ ? { releasePolicy }
140
+ : {}),
141
+ });
142
+ export const findEscrowHoldById = async (id: string): Promise<EscrowHold | null> =>
143
+ getEscrowHoldsCollection().findOne({ _id: id });
144
+ export const cancelEscrowHold = async (
145
+ id: string,
146
+ cancelledBy: 'did' | 'system',
147
+ cancelReason?: EscrowHold['cancelReason']
148
+ ): Promise<EscrowHold | null> => {
149
+ const now = new Date();
150
+ return getEscrowHoldsCollection().findOneAndUpdate(
151
+ { _id: id, status: 'pending' },
152
+ {
153
+ $set: {
154
+ status: 'cancelled',
155
+ cancelledBy,
156
+ cancelledAt: now,
157
+ updatedAt: now,
158
+ ...(cancelReason ? { cancelReason } : {}),
159
+ },
160
+ },
161
+ { returnDocument: 'after' }
162
+ );
163
+ };
164
+ export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
165
+ const now = new Date();
166
+ return getEscrowHoldsCollection().findOneAndUpdate(
167
+ {
168
+ _id: id,
169
+ status: 'pending',
170
+ releaseAfter: { $gte: new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS) },
171
+ },
172
+ {
173
+ $set: { status: 'completed', completedAt: now, updatedAt: now },
174
+ },
175
+ { returnDocument: 'after' }
176
+ );
177
+ };
178
+ /** Whether escrow material at this share version has already been released. */
179
+ export const hasCompletedEscrowHoldForVersion = async (
180
+ authProvider: AuthProviderMapping,
181
+ shareVersion: number
182
+ ): Promise<boolean> =>
183
+ (await getEscrowHoldsCollection().countDocuments(
184
+ {
185
+ 'authProvider.type': authProvider.type,
186
+ 'authProvider.id': authProvider.id,
187
+ shareVersion,
188
+ status: 'completed',
189
+ },
190
+ { limit: 1 }
191
+ )) > 0;
192
+ export const expireStaleEscrowHolds = async (now: Date): Promise<number> => {
193
+ const result = await getEscrowHoldsCollection().updateMany(
194
+ {
195
+ status: 'pending',
196
+ releaseAfter: { $lt: new Date(now.getTime() - ESCROW_HOLD_STALE_WINDOW_MS) },
197
+ },
198
+ { $set: { status: 'expired', updatedAt: now } }
199
+ );
200
+ return result.modifiedCount;
201
+ };
202
+
203
+ /** Only rewrite the completed row claimed by this request; never reopen a burned hold. */
204
+ export const markClaimedEscrowHoldFailed = async (
205
+ id: string,
206
+ reason: 'pin-mismatch' | 'pin-locked' | 'release-failed',
207
+ completedAt: Date
208
+ ): Promise<void> => {
209
+ const now = new Date();
210
+ await getEscrowHoldsCollection().updateOne(
211
+ { _id: id, status: 'completed', completedAt },
212
+ {
213
+ $set: {
214
+ status: 'cancelled',
215
+ cancelledBy: 'system',
216
+ cancelReason: reason,
217
+ cancelledAt: now,
218
+ updatedAt: now,
219
+ },
220
+ }
221
+ );
222
+ };
223
+ export const hashEscrowResumeToken = (token: string): string =>
224
+ createHash('sha256').update(token).digest('hex');
225
+ export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
@@ -2,17 +2,28 @@ import { z } from 'zod';
2
2
 
3
3
  export const SIGNING_AUTHORITIES_COLLECTION = 'signingauthorities';
4
4
 
5
+ /**
6
+ * Encrypted seeds authenticate _id, ownerDid, name, and did (including an absent did).
7
+ * Never rename/backfill these fields in place: decrypt using the ORIGINAL identity, then
8
+ * re-encrypt for the new identity and atomically replace the metadata and envelope together.
9
+ */
5
10
  export const MongoSigningAuthorityValidator = z.object({
6
11
  _id: z.string().optional(),
7
12
  ownerDid: z.string(),
8
13
  name: z.string(),
9
- seed: z.string(),
14
+ // Legacy and prepared migration rows only. New writes use the envelope fields.
15
+ seed: z.string().optional(),
16
+ encryptedSeed: z.string().optional(),
17
+ encryptedDek: z.string().optional(),
18
+ keyVersion: z.enum(['kms-v1', 'local-v1']).optional(),
10
19
  did: z.string().optional(),
11
20
  endpoint: z.string().optional(),
12
21
  });
13
22
 
14
- export const SigningAuthorityResponseValidator = MongoSigningAuthorityValidator.omit({
15
- seed: true,
23
+ export const SigningAuthorityResponseValidator = MongoSigningAuthorityValidator.pick({
24
+ _id: true,
25
+ ownerDid: true,
26
+ name: true,
16
27
  }).extend({
17
28
  did: z.string(),
18
29
  endpoint: z.string(),
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getEscrowPinStatus, type MongoUserKeyType } from './UserKey';
3
+
4
+ vi.mock('@mongo', () => ({ default: {} }));
5
+
6
+ describe('getEscrowPinStatus', () => {
7
+ it('distinguishes an absent PIN from lockout', () => {
8
+ const userKey = { shareVersion: 1 } as MongoUserKeyType;
9
+ expect(getEscrowPinStatus(userKey)).toEqual({
10
+ state: 'none',
11
+ enabled: false,
12
+ attemptsRemaining: 0,
13
+ });
14
+ });
15
+
16
+ it.each(['locked', 'stale'] as const)('reports %s without exposing salt', state => {
17
+ const userKey = {
18
+ shareVersion: 1,
19
+ escrowPin: {
20
+ salt: 'salt',
21
+ shareVersion: state === 'stale' ? 2 : 1,
22
+ failedAttempts: 0,
23
+ enabledAt: new Date(),
24
+ ...(state === 'locked' ? { disabledAt: new Date() } : {}),
25
+ },
26
+ } as MongoUserKeyType;
27
+ expect(getEscrowPinStatus(userKey)).toEqual({
28
+ state,
29
+ enabled: false,
30
+ attemptsRemaining: 10,
31
+ });
32
+ });
33
+
34
+ it.each([0, 9, 10, 11])(
35
+ 'does not label reservations as verified lockout (%i reserved)',
36
+ failedAttempts => {
37
+ const userKey = {
38
+ shareVersion: 1,
39
+ escrowPin: { salt: 'salt', shareVersion: 1, failedAttempts, enabledAt: new Date() },
40
+ } as MongoUserKeyType;
41
+ expect(getEscrowPinStatus(userKey)).toEqual({
42
+ state: 'enabled',
43
+ enabled: true,
44
+ attemptsRemaining: Math.max(0, 10 - failedAttempts),
45
+ salt: 'salt',
46
+ });
47
+ }
48
+ );
49
+ });