@learncard/lca-api-service 1.6.1 → 1.6.2
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/AGENTS.md +21 -0
- package/CHANGELOG.md +17 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -0
- package/dist/helpers/accountSecureLink.d.ts +4 -0
- package/dist/helpers/accountSecureLink.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +4 -1
- package/dist/models/UserKey.d.ts +199 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/escrow.d.ts +4 -0
- package/dist/routes/keys.d.ts +47 -1
- package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
- package/dist/services/escrow-enclave/notifications.d.ts +1 -1
- package/package.json +10 -10
- package/src/cache/recoverySessions.ts +38 -0
- package/src/helpers/accountSecureLink.test.ts +26 -0
- package/src/helpers/accountSecureLink.ts +16 -0
- package/src/jobs/escrowBlobRewrap.ts +95 -3
- package/src/models/EscrowHold.ts +17 -3
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +540 -48
- package/src/routes/escrow.ts +326 -67
- package/src/routes/keys.ts +416 -7
- package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
- package/src/services/escrow-enclave/README.md +8 -3
- package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
- package/src/services/escrow-enclave/notifications.test.ts +69 -0
- package/src/services/escrow-enclave/notifications.ts +19 -1
- package/test/keys-escrow.spec.ts +4 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1152 -0
package/src/models/UserKey.ts
CHANGED
|
@@ -110,10 +110,74 @@ export const EscrowPinValidator = z.object({
|
|
|
110
110
|
disabledAt: z.date().optional(),
|
|
111
111
|
shareVersion: z.number().int().positive(),
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* PIN-operation lease TTL. A lease is held across exactly one authority call
|
|
116
|
+
* (`applyEnrollmentIntent`), which the remote transport bounds at
|
|
117
|
+
* `ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS` (15 s default, 30 s max). Four times the maximum
|
|
118
|
+
* leaves room for the Mongo writes around it, so a live request never outlives its
|
|
119
|
+
* lease; an expired lease means a crash or a lost response, and is settled against
|
|
120
|
+
* the authority's state by the next request that touches the account.
|
|
121
|
+
*/
|
|
122
|
+
export const ESCROW_ENROLLMENT_LEASE_TTL_MS = 120_000;
|
|
123
|
+
/** Same bound for a PIN compare: one `releaseEscrow` authority call per reservation. */
|
|
124
|
+
export const ESCROW_PIN_COMPARE_TTL_MS = 120_000;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Per-account PIN-operation lease (remote/authority mode). Claimed before an
|
|
128
|
+
* owner-signed enrollment intent is sent to the authority, and records what lca-api
|
|
129
|
+
* must persist if the authority commits, so the result can be adopted even when the
|
|
130
|
+
* request that sent it never finishes its own write. A security rotation (revoke,
|
|
131
|
+
* wiping rebind) drops it with the escrow it guards; see `buildSecurityRotationUpdate`.
|
|
132
|
+
*/
|
|
133
|
+
export const EscrowEnrollmentLeaseValidator = z.object({
|
|
134
|
+
id: z.string().min(1).max(128),
|
|
135
|
+
expiresAt: z.date(),
|
|
136
|
+
/** An owner-signed intent's action, or the delegated background `rewrap`. */
|
|
137
|
+
action: z.enum(['enroll', 'replace', 'carry', 'disable', 'rewrap']),
|
|
138
|
+
/** The signed intent's PIN action: anything but `keep` starts a new PIN generation. */
|
|
139
|
+
pinAction: z.enum(['set', 'change', 'clear', 'keep']),
|
|
140
|
+
/** `fresh`: new PIN record from `pinSalt`; `keep`: the current record; `clear`: none. */
|
|
141
|
+
pin: z.enum(['fresh', 'keep', 'clear']),
|
|
142
|
+
pinSalt: EscrowPinSaltValidator.optional(),
|
|
143
|
+
optOut: z.boolean().optional(),
|
|
144
|
+
shareVersion: z.number().int().positive(),
|
|
145
|
+
authorityGeneration: z.string().min(1).max(128),
|
|
146
|
+
/** Authority state read before the call; a commit advances both by exactly one. */
|
|
147
|
+
expectedEnrollmentVersion: z.number().int().nonnegative(),
|
|
148
|
+
/** Absent when the caller did not read the authority first (disable). */
|
|
149
|
+
expectedPinGeneration: z.number().int().nonnegative().optional(),
|
|
150
|
+
enrollmentEpoch: z.number().int().positive(),
|
|
151
|
+
/** The Mongo blob identity the operation replaces (null: none). */
|
|
152
|
+
sourceBlobHash: z.string().nullable(),
|
|
153
|
+
measurements: EscrowBlobValidator.shape.measurements,
|
|
154
|
+
});
|
|
155
|
+
export type EscrowEnrollmentLease = z.infer<typeof EscrowEnrollmentLeaseValidator>;
|
|
156
|
+
|
|
157
|
+
/** One in-flight PIN compare: from its reservation until the authority answers. */
|
|
158
|
+
export const EscrowPinCompareValidator = z.object({
|
|
159
|
+
id: z.string().min(1).max(128),
|
|
160
|
+
expiresAt: z.date(),
|
|
161
|
+
});
|
|
162
|
+
|
|
113
163
|
export const EscrowPinStatusValidator = SharedEscrowPinStatusValidator.extend({
|
|
114
164
|
salt: EscrowPinSaltValidator.optional(),
|
|
115
165
|
});
|
|
116
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Verified wrong entries only. `failedAttempts` is the charged budget (it also counts
|
|
169
|
+
* successful releases and unresolved reservations), so it is used only when it is 0,
|
|
170
|
+
* which proves no wrong entry. A legacy record with charges but no verified counter
|
|
171
|
+
* omits the field rather than guessing.
|
|
172
|
+
*/
|
|
173
|
+
const getVerifiedWrongPinAttempts = (
|
|
174
|
+
pin: NonNullable<MongoUserKeyType['escrowPin']>
|
|
175
|
+
): { wrongAttempts?: number } => {
|
|
176
|
+
if (pin.verifiedFailedAttempts !== undefined)
|
|
177
|
+
return { wrongAttempts: pin.verifiedFailedAttempts };
|
|
178
|
+
return pin.failedAttempts === 0 ? { wrongAttempts: 0 } : {};
|
|
179
|
+
};
|
|
180
|
+
|
|
117
181
|
export const getEscrowPinStatus = (
|
|
118
182
|
userKey: MongoUserKeyType
|
|
119
183
|
): z.infer<typeof EscrowPinStatusValidator> => {
|
|
@@ -130,6 +194,7 @@ export const getEscrowPinStatus = (
|
|
|
130
194
|
enabled,
|
|
131
195
|
attemptsRemaining: pin ? Math.max(0, ESCROW_PIN_MAX_ATTEMPTS - pin.failedAttempts) : 0,
|
|
132
196
|
...(enabled ? { salt: pin.salt } : {}),
|
|
197
|
+
...(pin ? getVerifiedWrongPinAttempts(pin) : {}),
|
|
133
198
|
};
|
|
134
199
|
};
|
|
135
200
|
|
|
@@ -137,6 +202,10 @@ const MongoUserKeyBaseValidator = z.object({
|
|
|
137
202
|
escrowBlob: EscrowBlobValidator.optional(),
|
|
138
203
|
escrowPin: EscrowPinValidator.optional(),
|
|
139
204
|
escrowOptedOutAt: z.date().optional(),
|
|
205
|
+
// Authority mode only: the PIN-operation lease and the in-flight PIN compares it
|
|
206
|
+
// excludes (see claimEscrowEnrollmentLease).
|
|
207
|
+
escrowEnrollmentLease: EscrowEnrollmentLeaseValidator.optional(),
|
|
208
|
+
escrowPinCompares: z.array(EscrowPinCompareValidator).optional(),
|
|
140
209
|
_id: z.string().optional(),
|
|
141
210
|
|
|
142
211
|
contactMethod: ContactMethodValidator,
|
|
@@ -176,6 +245,12 @@ const MongoUserKeyBaseValidator = z.object({
|
|
|
176
245
|
sssActivationState: z.enum(['provisional', 'active']).optional(),
|
|
177
246
|
provisionalCreatedAt: z.date().optional(),
|
|
178
247
|
|
|
248
|
+
// Security epoch: incremented by every wiping rotation (identity rebind and
|
|
249
|
+
// "Secure my account"). Every recovery-session token records it at mint, and the
|
|
250
|
+
// rebind/re-link writes only land while it still matches, so a revoke or rebind
|
|
251
|
+
// invalidates every older token. Absent on legacy records; read as 0.
|
|
252
|
+
securityEpoch: z.number().int().nonnegative().optional(),
|
|
253
|
+
|
|
179
254
|
createdAt: z.date(),
|
|
180
255
|
updatedAt: z.date(),
|
|
181
256
|
});
|
|
@@ -744,6 +819,40 @@ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoU
|
|
|
744
819
|
authProviders: { $elemMatch: authProvider },
|
|
745
820
|
});
|
|
746
821
|
|
|
822
|
+
/** The account's security epoch; a legacy record without the field is at epoch 0. */
|
|
823
|
+
export const getSecurityEpoch = (userKey: Pick<MongoUserKeyType, 'securityEpoch'>): number =>
|
|
824
|
+
userKey.securityEpoch ?? 0;
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Match only while the account is still at `epoch`. Epoch 0 also matches a legacy
|
|
828
|
+
* record without the field (`$not: {$gt: 0}` matches 0 and a missing field). A
|
|
829
|
+
* field-level condition, so it composes with any other filter, including `$or`.
|
|
830
|
+
*/
|
|
831
|
+
const getSecurityEpochFilter = (epoch: number): Filter<MongoUserKeyType> =>
|
|
832
|
+
epoch === 0 ? { securityEpoch: { $not: { $gt: 0 } } } : { securityEpoch: epoch };
|
|
833
|
+
|
|
834
|
+
/** Pipeline expression: every non-escrow method, plus one confirmed escrow method. */
|
|
835
|
+
const confirmedEscrowMethodExpression = (now: Date, shareVersion: number) => ({
|
|
836
|
+
$concatArrays: [
|
|
837
|
+
{
|
|
838
|
+
$filter: {
|
|
839
|
+
input: { $ifNull: ['$recoveryMethods', []] },
|
|
840
|
+
as: 'm',
|
|
841
|
+
cond: { $ne: ['$$m.type', 'escrow'] },
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
[
|
|
845
|
+
{
|
|
846
|
+
type: 'escrow',
|
|
847
|
+
createdAt: now,
|
|
848
|
+
confirmationStatus: 'confirmed',
|
|
849
|
+
confirmedAt: now,
|
|
850
|
+
shareVersion,
|
|
851
|
+
},
|
|
852
|
+
],
|
|
853
|
+
],
|
|
854
|
+
});
|
|
855
|
+
|
|
747
856
|
/** Persist verified escrow and its confirmed descriptor atomically. */
|
|
748
857
|
export const setEscrowBlobByAuthProvider = async (
|
|
749
858
|
authProvider: AuthProviderMapping,
|
|
@@ -755,7 +864,13 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
755
864
|
enrollmentEpoch: number;
|
|
756
865
|
expectedBlob?: EscrowBlob;
|
|
757
866
|
expectedPin?: MongoUserKeyType['escrowPin'];
|
|
758
|
-
}
|
|
867
|
+
},
|
|
868
|
+
/**
|
|
869
|
+
* Same-PIN budget refresh after a PIN sign-in (E): write only while the current PIN
|
|
870
|
+
* still shows exactly these charges, so a guess reserved or resolved after the
|
|
871
|
+
* client's check is never wiped by the fresh PIN generation.
|
|
872
|
+
*/
|
|
873
|
+
expectedPinCharges?: { failedAttempts: number; verifiedFailedAttempts: number }
|
|
759
874
|
): Promise<MongoUserKeyType | null> => {
|
|
760
875
|
const now = new Date();
|
|
761
876
|
const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
|
|
@@ -781,6 +896,13 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
781
896
|
shareVersion: expectedShareVersion,
|
|
782
897
|
escrowOptedOutAt: { $exists: false },
|
|
783
898
|
...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
|
|
899
|
+
...(expectedPinCharges
|
|
900
|
+
? {
|
|
901
|
+
'escrowPin.failedAttempts': expectedPinCharges.failedAttempts,
|
|
902
|
+
'escrowPin.verifiedFailedAttempts': expectedPinCharges.verifiedFailedAttempts,
|
|
903
|
+
'escrowPin.disabledAt': { $exists: false },
|
|
904
|
+
}
|
|
905
|
+
: {}),
|
|
784
906
|
...(authority
|
|
785
907
|
? {
|
|
786
908
|
escrowBlob: authority.expectedBlob ?? { $exists: false },
|
|
@@ -819,26 +941,180 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
819
941
|
}
|
|
820
942
|
: '$$REMOVE',
|
|
821
943
|
updatedAt: now,
|
|
822
|
-
recoveryMethods:
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
944
|
+
recoveryMethods: confirmedEscrowMethodExpression(now, expectedShareVersion),
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
],
|
|
948
|
+
{ returnDocument: 'after' }
|
|
949
|
+
);
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
/** No PIN compare reserved within its TTL (missing or all-expired markers). */
|
|
953
|
+
const noLiveEscrowPinCompare = (now: Date): Filter<MongoUserKeyType> => ({
|
|
954
|
+
escrowPinCompares: { $not: { $elemMatch: { expiresAt: { $gt: now } } } },
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
export interface ClaimEscrowEnrollmentLeaseInput {
|
|
958
|
+
lease: EscrowEnrollmentLease;
|
|
959
|
+
/** The blob and PIN the caller read; any change since (a compare's charge) misses. */
|
|
960
|
+
expectedBlob?: EscrowBlob;
|
|
961
|
+
expectedPin?: MongoUserKeyType['escrowPin'];
|
|
962
|
+
/** Enroll paths only: an opted-out account never gets a lease. */
|
|
963
|
+
requireOptedIn: boolean;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Claim the account's PIN-operation lease before an enrollment intent goes to the
|
|
968
|
+
* authority. One atomic write, conditioned on: no other lease (an expired one must be
|
|
969
|
+
* settled first, see the escrow router), no PIN compare in flight, and the exact blob
|
|
970
|
+
* and PIN (so its charges) the caller validated. Returns null, with nothing sent to
|
|
971
|
+
* the authority, when any of those changed. While the lease is held,
|
|
972
|
+
* `reserveEscrowPinAttempt` refuses new compares, so the authority's operation
|
|
973
|
+
* revision cannot move under the intent.
|
|
974
|
+
*/
|
|
975
|
+
export const claimEscrowEnrollmentLease = async (
|
|
976
|
+
authProvider: AuthProviderMapping,
|
|
977
|
+
input: ClaimEscrowEnrollmentLeaseInput
|
|
978
|
+
): Promise<MongoUserKeyType | null> => {
|
|
979
|
+
const lease = EscrowEnrollmentLeaseValidator.parse(input.lease);
|
|
980
|
+
const now = new Date();
|
|
981
|
+
if (lease.expiresAt <= now) return null;
|
|
982
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
983
|
+
{
|
|
984
|
+
...getAuthProviderFilter(authProvider),
|
|
985
|
+
shareVersion: lease.shareVersion,
|
|
986
|
+
...(input.requireOptedIn ? { escrowOptedOutAt: { $exists: false } } : {}),
|
|
987
|
+
escrowBlob: input.expectedBlob ?? { $exists: false },
|
|
988
|
+
escrowPin: input.expectedPin ?? { $exists: false },
|
|
989
|
+
escrowEnrollmentLease: { $exists: false },
|
|
990
|
+
...noLiveEscrowPinCompare(now),
|
|
991
|
+
},
|
|
992
|
+
{ $set: { escrowEnrollmentLease: lease, updatedAt: now } },
|
|
993
|
+
{ returnDocument: 'after' }
|
|
994
|
+
);
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
/** Drop a lease the authority provably did not commit (or that cannot be adopted). */
|
|
998
|
+
export const releaseEscrowEnrollmentLease = async (
|
|
999
|
+
authProvider: AuthProviderMapping,
|
|
1000
|
+
leaseId: string
|
|
1001
|
+
): Promise<void> => {
|
|
1002
|
+
await getUserKeysCollection().updateOne(
|
|
1003
|
+
{ ...getAuthProviderFilter(authProvider), 'escrowEnrollmentLease.id': leaseId },
|
|
1004
|
+
{ $unset: { escrowEnrollmentLease: '' }, $set: { updatedAt: new Date() } }
|
|
1005
|
+
);
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
export interface EscrowAuthorityCommit {
|
|
1009
|
+
/** The authority's committed envelope; null for a disable. */
|
|
1010
|
+
envelope: EscrowBlob['envelope'] | null;
|
|
1011
|
+
authorityGeneration: string;
|
|
1012
|
+
enrollmentVersion: number;
|
|
1013
|
+
enrollmentEpoch: number;
|
|
1014
|
+
receipt?: AuthorityRecord;
|
|
1015
|
+
stateProof?: AuthorityRecord;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Persist an authority commit made under `lease` and release the lease in the same
|
|
1020
|
+
* update. Conditioned on holding the lease (not on PIN charges: no compare can start
|
|
1021
|
+
* while it is held) and on the blob identity it replaced, so a concurrent compare can
|
|
1022
|
+
* no longer make the write miss after the authority committed. `adoptWithoutLease`
|
|
1023
|
+
* also accepts a lease that is already gone; only callers that verified the commit
|
|
1024
|
+
* against the authority's current state may set it.
|
|
1025
|
+
*/
|
|
1026
|
+
export const commitEscrowEnrollmentLease = async (
|
|
1027
|
+
authProvider: AuthProviderMapping,
|
|
1028
|
+
lease: EscrowEnrollmentLease,
|
|
1029
|
+
commit: EscrowAuthorityCommit,
|
|
1030
|
+
{ adoptWithoutLease = false }: { adoptWithoutLease?: boolean } = {}
|
|
1031
|
+
): Promise<MongoUserKeyType | null> => {
|
|
1032
|
+
const now = new Date();
|
|
1033
|
+
const filter: Filter<MongoUserKeyType> = {
|
|
1034
|
+
...getAuthProviderFilter(authProvider),
|
|
1035
|
+
shareVersion: lease.shareVersion,
|
|
1036
|
+
...(lease.sourceBlobHash
|
|
1037
|
+
? { 'escrowBlob.blobHash': lease.sourceBlobHash }
|
|
1038
|
+
: { escrowBlob: { $exists: false } }),
|
|
1039
|
+
...(adoptWithoutLease
|
|
1040
|
+
? {
|
|
1041
|
+
$or: [
|
|
1042
|
+
{ 'escrowEnrollmentLease.id': lease.id },
|
|
1043
|
+
{ escrowEnrollmentLease: { $exists: false } },
|
|
1044
|
+
],
|
|
1045
|
+
}
|
|
1046
|
+
: { 'escrowEnrollmentLease.id': lease.id }),
|
|
1047
|
+
};
|
|
1048
|
+
if (lease.action === 'disable') {
|
|
1049
|
+
if (commit.envelope) return null;
|
|
1050
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
1051
|
+
filter,
|
|
1052
|
+
{
|
|
1053
|
+
$unset: { escrowBlob: '', escrowPin: '', escrowEnrollmentLease: '' },
|
|
1054
|
+
$pull: { recoveryMethods: { type: 'escrow' } },
|
|
1055
|
+
$set: { updatedAt: now, ...(lease.optOut ? { escrowOptedOutAt: now } : {}) },
|
|
1056
|
+
},
|
|
1057
|
+
{ returnDocument: 'after' }
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
if (!commit.envelope) return null;
|
|
1061
|
+
const blob = EscrowBlobValidator.safeParse({
|
|
1062
|
+
envelope: commit.envelope,
|
|
1063
|
+
enclaveKeyId: commit.envelope.keyId,
|
|
1064
|
+
enclaveMode: 'nitro',
|
|
1065
|
+
measurements: lease.measurements,
|
|
1066
|
+
shareVersion: lease.shareVersion,
|
|
1067
|
+
createdAt: now,
|
|
1068
|
+
authorityGeneration: commit.authorityGeneration,
|
|
1069
|
+
enrollmentVersion: commit.enrollmentVersion,
|
|
1070
|
+
enrollmentEpoch: commit.enrollmentEpoch,
|
|
1071
|
+
blobHash: escrowBlobIdentity(commit.envelope),
|
|
1072
|
+
...(commit.receipt ? { commitReceipt: commit.receipt } : {}),
|
|
1073
|
+
...(commit.stateProof ? { authorityStateProof: commit.stateProof } : {}),
|
|
1074
|
+
});
|
|
1075
|
+
if (!blob.success) throw new Error('Invalid escrow payload');
|
|
1076
|
+
if (lease.pin === 'fresh' && !lease.pinSalt) throw new Error('Invalid escrow payload');
|
|
1077
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
1078
|
+
{ ...filter, escrowOptedOutAt: { $exists: false } },
|
|
1079
|
+
[
|
|
1080
|
+
{
|
|
1081
|
+
$set: {
|
|
1082
|
+
escrowBlob: { $literal: blob.data },
|
|
1083
|
+
escrowPin:
|
|
1084
|
+
lease.pin === 'fresh'
|
|
1085
|
+
? {
|
|
1086
|
+
$literal: EscrowPinValidator.parse({
|
|
1087
|
+
salt: lease.pinSalt,
|
|
1088
|
+
failedAttempts: 0,
|
|
1089
|
+
enabledAt: now,
|
|
1090
|
+
shareVersion: lease.shareVersion,
|
|
1091
|
+
}),
|
|
1092
|
+
}
|
|
1093
|
+
: lease.pin === 'keep'
|
|
1094
|
+
? {
|
|
1095
|
+
$cond: [
|
|
1096
|
+
{ $eq: [{ $type: '$escrowPin' }, 'object'] },
|
|
1097
|
+
{
|
|
1098
|
+
$mergeObjects: [
|
|
1099
|
+
'$escrowPin',
|
|
1100
|
+
{ shareVersion: lease.shareVersion },
|
|
1101
|
+
],
|
|
1102
|
+
},
|
|
1103
|
+
'$$REMOVE',
|
|
1104
|
+
],
|
|
1105
|
+
}
|
|
1106
|
+
: '$$REMOVE',
|
|
1107
|
+
escrowEnrollmentLease: '$$REMOVE',
|
|
1108
|
+
updatedAt: now,
|
|
1109
|
+
// A rewrap only re-seals the confirmed copy: its descriptor stands.
|
|
1110
|
+
...(lease.action === 'rewrap'
|
|
1111
|
+
? {}
|
|
1112
|
+
: {
|
|
1113
|
+
recoveryMethods: confirmedEscrowMethodExpression(
|
|
1114
|
+
now,
|
|
1115
|
+
lease.shareVersion
|
|
1116
|
+
),
|
|
1117
|
+
}),
|
|
842
1118
|
},
|
|
843
1119
|
},
|
|
844
1120
|
],
|
|
@@ -897,6 +1173,8 @@ export const rewrapEscrowBlobByAuthProvider = async (
|
|
|
897
1173
|
escrowBlob: oldBlob,
|
|
898
1174
|
escrowOptedOutAt: { $exists: false },
|
|
899
1175
|
...(oldPin ? { escrowPin: oldPin } : { escrowPin: { $exists: false } }),
|
|
1176
|
+
// Never under a PIN-operation lease: its intent replaces this blob.
|
|
1177
|
+
escrowEnrollmentLease: { $exists: false },
|
|
900
1178
|
};
|
|
901
1179
|
return getUserKeysCollection().findOneAndUpdate(
|
|
902
1180
|
filter,
|
|
@@ -959,19 +1237,27 @@ export const setEscrowOptInByAuthProvider = async (
|
|
|
959
1237
|
{ returnDocument: 'after' }
|
|
960
1238
|
);
|
|
961
1239
|
|
|
962
|
-
/**
|
|
1240
|
+
/**
|
|
1241
|
+
* Reserve before claiming a hold or invoking the enclave; never read-then-increment.
|
|
1242
|
+
* Refused while a PIN-operation lease is held (an expired one too: the router settles
|
|
1243
|
+
* it first). With `compareId`, also records the compare as in flight until
|
|
1244
|
+
* `releaseEscrowPinCompare` (or its TTL), so no lease can be claimed under it.
|
|
1245
|
+
*/
|
|
963
1246
|
export const reserveEscrowPinAttempt = async (
|
|
964
1247
|
authProvider: AuthProviderMapping,
|
|
965
1248
|
shareVersion: number,
|
|
966
|
-
expectedCiphertext?: string
|
|
967
|
-
|
|
968
|
-
|
|
1249
|
+
expectedCiphertext?: string,
|
|
1250
|
+
compareId?: string
|
|
1251
|
+
): Promise<MongoUserKeyType | null> => {
|
|
1252
|
+
const now = new Date();
|
|
1253
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
969
1254
|
{
|
|
970
1255
|
...getAuthProviderFilter(authProvider),
|
|
971
1256
|
shareVersion,
|
|
972
1257
|
'escrowPin.shareVersion': shareVersion,
|
|
973
1258
|
'escrowPin.failedAttempts': { $lt: ESCROW_PIN_MAX_ATTEMPTS },
|
|
974
1259
|
'escrowPin.disabledAt': { $exists: false },
|
|
1260
|
+
escrowEnrollmentLease: { $exists: false },
|
|
975
1261
|
...(expectedCiphertext === undefined
|
|
976
1262
|
? {}
|
|
977
1263
|
: { 'escrowBlob.envelope.ciphertext': expectedCiphertext }),
|
|
@@ -985,12 +1271,46 @@ export const reserveEscrowPinAttempt = async (
|
|
|
985
1271
|
$ifNull: ['$escrowPin.verifiedFailedAttempts', 0],
|
|
986
1272
|
},
|
|
987
1273
|
'escrowPin.failedAttempts': { $add: ['$escrowPin.failedAttempts', 1] },
|
|
988
|
-
|
|
1274
|
+
...(compareId === undefined
|
|
1275
|
+
? {}
|
|
1276
|
+
: {
|
|
1277
|
+
escrowPinCompares: {
|
|
1278
|
+
$concatArrays: [
|
|
1279
|
+
{
|
|
1280
|
+
$filter: {
|
|
1281
|
+
input: { $ifNull: ['$escrowPinCompares', []] },
|
|
1282
|
+
as: 'c',
|
|
1283
|
+
cond: { $gt: ['$$c.expiresAt', now] },
|
|
1284
|
+
},
|
|
1285
|
+
},
|
|
1286
|
+
[
|
|
1287
|
+
{
|
|
1288
|
+
id: compareId,
|
|
1289
|
+
expiresAt: new Date(
|
|
1290
|
+
now.getTime() + ESCROW_PIN_COMPARE_TTL_MS
|
|
1291
|
+
),
|
|
1292
|
+
},
|
|
1293
|
+
],
|
|
1294
|
+
],
|
|
1295
|
+
},
|
|
1296
|
+
}),
|
|
1297
|
+
updatedAt: now,
|
|
989
1298
|
},
|
|
990
1299
|
},
|
|
991
1300
|
],
|
|
992
1301
|
{ returnDocument: 'after' }
|
|
993
1302
|
);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
/** The authority answered this compare (or it never reached it); it no longer blocks a lease. */
|
|
1306
|
+
export const releaseEscrowPinCompare = async (
|
|
1307
|
+
authProvider: AuthProviderMapping,
|
|
1308
|
+
compareId: string
|
|
1309
|
+
): Promise<void> => {
|
|
1310
|
+
await getUserKeysCollection().updateOne(getAuthProviderFilter(authProvider), {
|
|
1311
|
+
$pull: { escrowPinCompares: { id: compareId } },
|
|
1312
|
+
});
|
|
1313
|
+
};
|
|
994
1314
|
|
|
995
1315
|
/** Refund infrastructure/claim failures without modifying a replacement PIN enrollment. */
|
|
996
1316
|
export const refundEscrowPinAttempt = async (
|
|
@@ -1306,7 +1626,7 @@ export const activateUserKeyByAuthProvider = async (
|
|
|
1306
1626
|
* skipped the PIN, but escrow can recover them, and staying provisional keeps
|
|
1307
1627
|
* the faster Web3Auth re-derive on new devices. Such records stay until the
|
|
1308
1628
|
* user activates (e.g. sets a PIN) or operators activate them before Web3Auth
|
|
1309
|
-
* is retired (
|
|
1629
|
+
* is retired (Confluence LC: "Production Web3Auth SSS migration runbook").
|
|
1310
1630
|
*/
|
|
1311
1631
|
export const purgeExpiredProvisionalMigrationByAuthProvider = async (
|
|
1312
1632
|
authProvider: AuthProviderMapping,
|
|
@@ -1457,8 +1777,80 @@ export interface CompleteIdentityRebindInput {
|
|
|
1457
1777
|
newContactMethod: ContactMethod;
|
|
1458
1778
|
primaryDid: string;
|
|
1459
1779
|
authShare: ServerEncryptedShare;
|
|
1780
|
+
/**
|
|
1781
|
+
* Only commit if the account is still at this share version. Set for tokens minted
|
|
1782
|
+
* by a PIN release, so a stale token cannot undo a later "Secure my account".
|
|
1783
|
+
*/
|
|
1784
|
+
expectedShareVersion?: number;
|
|
1785
|
+
/**
|
|
1786
|
+
* The security epoch the recovery token was minted at. Required: the write only
|
|
1787
|
+
* lands while the account is still at this epoch, so any token minted before a
|
|
1788
|
+
* revoke or another wiping rebind is rejected atomically.
|
|
1789
|
+
*/
|
|
1790
|
+
expectedSecurityEpoch: number;
|
|
1460
1791
|
}
|
|
1461
1792
|
|
|
1793
|
+
/**
|
|
1794
|
+
* The security rotation shared by identity rebind and "Secure my account": a new
|
|
1795
|
+
* auth share at `shareVersion + 1`, every historical auth share purged (so device
|
|
1796
|
+
* shares from the old split stop working), every recovery method kept only as an
|
|
1797
|
+
* unconfirmed descriptor (their external shares belong to the old split), and
|
|
1798
|
+
* escrow cleared (the client re-enrolls on the new split).
|
|
1799
|
+
*/
|
|
1800
|
+
const buildSecurityRotationUpdate = (
|
|
1801
|
+
existing: MongoUserKeyType,
|
|
1802
|
+
authShare: ServerEncryptedShare,
|
|
1803
|
+
now: Date
|
|
1804
|
+
): {
|
|
1805
|
+
shareVersion: number;
|
|
1806
|
+
recoveryMethods: RecoveryMethod[];
|
|
1807
|
+
$set: Record<string, unknown>;
|
|
1808
|
+
$unset: Record<string, ''>;
|
|
1809
|
+
$inc: { securityEpoch: 1 };
|
|
1810
|
+
} => {
|
|
1811
|
+
const shareVersion = (existing.shareVersion ?? 1) + 1;
|
|
1812
|
+
const recoveryMethods = (existing.recoveryMethods ?? []).map(method => ({
|
|
1813
|
+
type: method.type,
|
|
1814
|
+
createdAt: now,
|
|
1815
|
+
confirmationStatus: 'pending' as const,
|
|
1816
|
+
credentialId: method.credentialId,
|
|
1817
|
+
shareVersion,
|
|
1818
|
+
}));
|
|
1819
|
+
|
|
1820
|
+
return {
|
|
1821
|
+
shareVersion,
|
|
1822
|
+
recoveryMethods,
|
|
1823
|
+
$set: {
|
|
1824
|
+
authShare,
|
|
1825
|
+
shareVersion,
|
|
1826
|
+
shareUpdatedAt: now,
|
|
1827
|
+
previousAuthShares: [],
|
|
1828
|
+
recoveryMethods,
|
|
1829
|
+
keyProvider: 'sss',
|
|
1830
|
+
// The new split has no confirmed recovery method yet. SSS records
|
|
1831
|
+
// are excluded from provisional migration cleanup (Web3Auth only).
|
|
1832
|
+
sssActivationState: 'provisional',
|
|
1833
|
+
updatedAt: now,
|
|
1834
|
+
},
|
|
1835
|
+
// The PIN-operation lease and compare markers go with the escrow they guard,
|
|
1836
|
+
// so the client's immediate re-enrollment on the new split is not refused as
|
|
1837
|
+
// busy. Nothing they cover can land here any more: every lease commit, settle
|
|
1838
|
+
// adoption and compare write is conditioned on the old `shareVersion` (bumped
|
|
1839
|
+
// above), and at the authority the re-enrollment's intent is a Replace of its
|
|
1840
|
+
// current copy at its current enrollment version, so a superseded intent that
|
|
1841
|
+
// lands first only makes it refuse and re-read, never diverge.
|
|
1842
|
+
$unset: {
|
|
1843
|
+
provisionalCreatedAt: '',
|
|
1844
|
+
escrowBlob: '',
|
|
1845
|
+
escrowPin: '',
|
|
1846
|
+
escrowEnrollmentLease: '',
|
|
1847
|
+
escrowPinCompares: '',
|
|
1848
|
+
},
|
|
1849
|
+
// Every wiping rotation retires every recovery token minted before it.
|
|
1850
|
+
$inc: { securityEpoch: 1 },
|
|
1851
|
+
};
|
|
1852
|
+
};
|
|
1853
|
+
|
|
1462
1854
|
/**
|
|
1463
1855
|
* Atomically bind a replacement login identity and commit a security rotation.
|
|
1464
1856
|
* Historical auth shares are purged. Existing recovery methods are retained only
|
|
@@ -1477,6 +1869,13 @@ export const completeIdentityRebind = async (
|
|
|
1477
1869
|
});
|
|
1478
1870
|
|
|
1479
1871
|
if (!existing) return null;
|
|
1872
|
+
if (
|
|
1873
|
+
input.expectedShareVersion !== undefined &&
|
|
1874
|
+
(existing.shareVersion ?? 1) !== input.expectedShareVersion
|
|
1875
|
+
) {
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
if (getSecurityEpoch(existing) !== input.expectedSecurityEpoch) return null;
|
|
1480
1879
|
|
|
1481
1880
|
const providerConflict = await collection.findOne({
|
|
1482
1881
|
...getAuthProviderFilter(input.newAuthProvider),
|
|
@@ -1486,7 +1885,6 @@ export const completeIdentityRebind = async (
|
|
|
1486
1885
|
if (providerConflict) return null;
|
|
1487
1886
|
|
|
1488
1887
|
const now = new Date();
|
|
1489
|
-
const shareVersion = (existing.shareVersion ?? 1) + 1;
|
|
1490
1888
|
const remainingAuthProviders = (existing.authProviders ?? []).filter(
|
|
1491
1889
|
provider =>
|
|
1492
1890
|
provider.type !== input.oldAuthProvider.type || provider.id !== input.oldAuthProvider.id
|
|
@@ -1497,35 +1895,22 @@ export const completeIdentityRebind = async (
|
|
|
1497
1895
|
)
|
|
1498
1896
|
? remainingAuthProviders
|
|
1499
1897
|
: [...remainingAuthProviders, input.newAuthProvider];
|
|
1500
|
-
const
|
|
1501
|
-
type: method.type,
|
|
1502
|
-
createdAt: now,
|
|
1503
|
-
confirmationStatus: 'pending' as const,
|
|
1504
|
-
credentialId: method.credentialId,
|
|
1505
|
-
shareVersion,
|
|
1506
|
-
}));
|
|
1898
|
+
const rotation = buildSecurityRotationUpdate(existing, input.authShare, now);
|
|
1507
1899
|
const result = await collection.findOneAndUpdate(
|
|
1508
1900
|
{
|
|
1509
1901
|
...getAuthProviderFilter(input.oldAuthProvider),
|
|
1510
1902
|
primaryDid: input.primaryDid,
|
|
1511
1903
|
shareVersion: existing.shareVersion ?? 1,
|
|
1904
|
+
...getSecurityEpochFilter(input.expectedSecurityEpoch),
|
|
1512
1905
|
},
|
|
1513
1906
|
{
|
|
1514
1907
|
$set: {
|
|
1908
|
+
...rotation.$set,
|
|
1515
1909
|
authProviders,
|
|
1516
1910
|
contactMethod: input.newContactMethod,
|
|
1517
|
-
authShare: input.authShare,
|
|
1518
|
-
shareVersion,
|
|
1519
|
-
shareUpdatedAt: now,
|
|
1520
|
-
previousAuthShares: [],
|
|
1521
|
-
recoveryMethods,
|
|
1522
|
-
keyProvider: 'sss',
|
|
1523
|
-
// The new split has no confirmed recovery method yet. SSS records
|
|
1524
|
-
// are excluded from provisional migration cleanup (Web3Auth only).
|
|
1525
|
-
sssActivationState: 'provisional',
|
|
1526
|
-
updatedAt: now,
|
|
1527
1911
|
},
|
|
1528
|
-
$unset:
|
|
1912
|
+
$unset: rotation.$unset,
|
|
1913
|
+
$inc: rotation.$inc,
|
|
1529
1914
|
},
|
|
1530
1915
|
{ returnDocument: 'after' }
|
|
1531
1916
|
);
|
|
@@ -1533,7 +1918,114 @@ export const completeIdentityRebind = async (
|
|
|
1533
1918
|
if (!result) return null;
|
|
1534
1919
|
|
|
1535
1920
|
return {
|
|
1536
|
-
shareVersion,
|
|
1537
|
-
methodsRequiringConfirmation: recoveryMethods.map(method => method.type),
|
|
1921
|
+
shareVersion: rotation.shareVersion,
|
|
1922
|
+
methodsRequiringConfirmation: rotation.recoveryMethods.map(method => method.type),
|
|
1923
|
+
};
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
export interface LinkAuthProviderKeepingSharesInput {
|
|
1927
|
+
oldAuthProvider: AuthProviderMapping;
|
|
1928
|
+
newAuthProvider: AuthProviderMapping;
|
|
1929
|
+
primaryDid: string;
|
|
1930
|
+
/** The share version the PIN release unlocked; the link only lands at that version. */
|
|
1931
|
+
shareVersion: number;
|
|
1932
|
+
/** The security epoch the PIN-release token was minted at; enforced in the write. */
|
|
1933
|
+
securityEpoch: number;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* PIN sign-in with a different login (keep mode): add the new login to the account
|
|
1938
|
+
* without rotating. Shares, history, recovery methods, escrow, activation state and
|
|
1939
|
+
* the contact method are untouched, and the old login stays linked. Returns null
|
|
1940
|
+
* when the new login belongs to another account or the account moved past
|
|
1941
|
+
* `shareVersion` (a concurrent rotation or revoke), so a revoke is never undone.
|
|
1942
|
+
*/
|
|
1943
|
+
export const linkAuthProviderKeepingShares = async (
|
|
1944
|
+
input: LinkAuthProviderKeepingSharesInput
|
|
1945
|
+
): Promise<{ shareVersion: number } | null> => {
|
|
1946
|
+
const collection = getUserKeysCollection();
|
|
1947
|
+
const existing = await collection.findOne({
|
|
1948
|
+
...getAuthProviderFilter(input.oldAuthProvider),
|
|
1949
|
+
primaryDid: input.primaryDid,
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
if (!existing) return null;
|
|
1953
|
+
if (getSecurityEpoch(existing) !== input.securityEpoch) return null;
|
|
1954
|
+
|
|
1955
|
+
const providerConflict = await collection.findOne({
|
|
1956
|
+
...getAuthProviderFilter(input.newAuthProvider),
|
|
1957
|
+
...(existing._id ? { _id: { $ne: existing._id } } : {}),
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
if (providerConflict) return null;
|
|
1961
|
+
|
|
1962
|
+
const result = await collection.findOneAndUpdate(
|
|
1963
|
+
{
|
|
1964
|
+
...getAuthProviderFilter(input.oldAuthProvider),
|
|
1965
|
+
primaryDid: input.primaryDid,
|
|
1966
|
+
shareVersion: input.shareVersion,
|
|
1967
|
+
...getSecurityEpochFilter(input.securityEpoch),
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
$addToSet: { authProviders: input.newAuthProvider },
|
|
1971
|
+
$set: { updatedAt: new Date() },
|
|
1972
|
+
},
|
|
1973
|
+
{ returnDocument: 'after' }
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
return result ? { shareVersion: input.shareVersion } : null;
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
export interface RevokeOtherDeviceSharesInput {
|
|
1980
|
+
authProvider: AuthProviderMapping;
|
|
1981
|
+
primaryDid: string;
|
|
1982
|
+
/** The version the caller split from; any concurrent rotation makes the revoke miss. */
|
|
1983
|
+
expectedShareVersion: number;
|
|
1984
|
+
authShare: ServerEncryptedShare;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/**
|
|
1988
|
+
* "Secure my account" / "Sign out of all other devices": the same security rotation
|
|
1989
|
+
* as an identity rebind, and every login except the caller's is unlinked (a login
|
|
1990
|
+
* added by a lost-login PIN sign-in must not survive the revoke). The contact method
|
|
1991
|
+
* is unchanged. One CAS document update on `{provider, primaryDid,
|
|
1992
|
+
* expectedShareVersion}`, so it either lands whole or not at all. Returns null on a
|
|
1993
|
+
* version miss.
|
|
1994
|
+
*
|
|
1995
|
+
* This rotates shares, not the key: a device that already holds the private key is
|
|
1996
|
+
* not evicted (SECURITY.md D19).
|
|
1997
|
+
*/
|
|
1998
|
+
export const revokeOtherDeviceShares = async (
|
|
1999
|
+
input: RevokeOtherDeviceSharesInput
|
|
2000
|
+
): Promise<{
|
|
2001
|
+
shareVersion: number;
|
|
2002
|
+
methodsRequiringConfirmation: RecoveryMethod['type'][];
|
|
2003
|
+
} | null> => {
|
|
2004
|
+
const collection = getUserKeysCollection();
|
|
2005
|
+
const filter = {
|
|
2006
|
+
...getAuthProviderFilter(input.authProvider),
|
|
2007
|
+
primaryDid: input.primaryDid,
|
|
2008
|
+
shareVersion: input.expectedShareVersion,
|
|
2009
|
+
};
|
|
2010
|
+
const existing = await collection.findOne(filter);
|
|
2011
|
+
|
|
2012
|
+
if (!existing) return null;
|
|
2013
|
+
|
|
2014
|
+
const rotation = buildSecurityRotationUpdate(existing, input.authShare, new Date());
|
|
2015
|
+
const result = await collection.findOneAndUpdate(
|
|
2016
|
+
filter,
|
|
2017
|
+
{
|
|
2018
|
+
$set: { ...rotation.$set, authProviders: [input.authProvider] },
|
|
2019
|
+
$unset: rotation.$unset,
|
|
2020
|
+
$inc: rotation.$inc,
|
|
2021
|
+
},
|
|
2022
|
+
{ returnDocument: 'after' }
|
|
2023
|
+
);
|
|
2024
|
+
|
|
2025
|
+
if (!result) return null;
|
|
2026
|
+
|
|
2027
|
+
return {
|
|
2028
|
+
shareVersion: rotation.shareVersion,
|
|
2029
|
+
methodsRequiringConfirmation: rotation.recoveryMethods.map(method => method.type),
|
|
1538
2030
|
};
|
|
1539
2031
|
};
|