@learncard/sss-key-manager 0.2.6 → 0.3.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/README.md +9 -0
- package/dist/escrow-authority.d.ts +4 -3
- package/dist/escrow-authority.d.ts.map +1 -1
- package/dist/escrow-authority.types.d.ts +15 -0
- package/dist/escrow-authority.types.d.ts.map +1 -1
- package/dist/escrow-crypto.d.ts +19 -1
- package/dist/escrow-crypto.d.ts.map +1 -1
- package/dist/escrow-enrollment-intent.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +776 -89
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +30 -30
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +778 -89
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +26 -0
- package/dist/sss-strategy.d.ts.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/escrow-authority-brand-names-v1.json +29 -0
- package/src/__fixtures__/escrow-carry-contact-contract-v1.json +25 -1
- package/src/__fixtures__/escrow-email-brand-contract-v1.json +225 -0
- package/src/escrow-authority.test.ts +2 -0
- package/src/escrow-authority.ts +24 -7
- package/src/escrow-authority.types.ts +27 -0
- package/src/escrow-crypto.test.ts +75 -0
- package/src/escrow-crypto.ts +43 -1
- package/src/escrow-enrollment-intent.ts +2 -1
- package/src/index.ts +14 -2
- package/src/sss-authority.test.ts +392 -32
- package/src/sss-strategy.test.ts +238 -0
- package/src/sss-strategy.ts +245 -104
- package/src/types.ts +29 -0
package/src/sss-strategy.test.ts
CHANGED
|
@@ -1042,6 +1042,244 @@ describe('escrow strategy', () => {
|
|
|
1042
1042
|
expect(attemptsRemaining).toBe(8);
|
|
1043
1043
|
});
|
|
1044
1044
|
|
|
1045
|
+
describe('own-typos rule (wrong entries made in this sign-in flow)', () => {
|
|
1046
|
+
const recoverInFlow = (
|
|
1047
|
+
pin: string,
|
|
1048
|
+
pinAttempts: { priorWrongAttempts?: number; ownWrongAttempts: number; pinSalt?: string }
|
|
1049
|
+
) =>
|
|
1050
|
+
strategy.executeRecovery({
|
|
1051
|
+
...params,
|
|
1052
|
+
input: { method: 'escrow-pin', pin, pinAttempts },
|
|
1053
|
+
didFromPrivateKey: async key => (key === privateKey ? did : ''),
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("refreshes the budget after the user's own typo, sending their wrong entry as expected", async () => {
|
|
1057
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1058
|
+
const saltBefore = pinSalt;
|
|
1059
|
+
enrollBodies = [];
|
|
1060
|
+
// Flow starts with a clean PIN; the user mistypes once, then gets it right.
|
|
1061
|
+
await expect(
|
|
1062
|
+
recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
|
|
1063
|
+
).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
|
|
1064
|
+
await expect(
|
|
1065
|
+
recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
|
|
1066
|
+
).resolves.toMatchObject({
|
|
1067
|
+
pinSignIn: { mode: 'kept', pinRefreshed: true, newPinRequired: false },
|
|
1068
|
+
});
|
|
1069
|
+
expect(enrollBodies.at(-1)?.expectedPinCharges).toEqual({
|
|
1070
|
+
attemptsRemaining: 8,
|
|
1071
|
+
wrongAttempts: 1,
|
|
1072
|
+
});
|
|
1073
|
+
expect(pinSalt).not.toBe(saltBefore);
|
|
1074
|
+
expect(attemptsRemaining).toBe(10);
|
|
1075
|
+
expect(wrongAttempts).toBe(0);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it('keeps the reduced budget when wrong entries were recorded before the flow started', async () => {
|
|
1079
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1080
|
+
// Someone else's guess, before this sign-in.
|
|
1081
|
+
await expect(recoverPin('246802')).rejects.toMatchObject({
|
|
1082
|
+
name: 'EscrowPinMismatchError',
|
|
1083
|
+
});
|
|
1084
|
+
calls = [];
|
|
1085
|
+
await expect(
|
|
1086
|
+
recoverInFlow('135790', { priorWrongAttempts: 1, ownWrongAttempts: 0 })
|
|
1087
|
+
).resolves.toMatchObject({
|
|
1088
|
+
pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
|
|
1089
|
+
});
|
|
1090
|
+
expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
|
|
1091
|
+
expect(attemptsRemaining).toBe(8);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('does not excuse a foreign wrong entry that landed during the flow', async () => {
|
|
1095
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1096
|
+
await expect(
|
|
1097
|
+
recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
|
|
1098
|
+
).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
|
|
1099
|
+
// A guess from somewhere else between the user's typo and their right PIN.
|
|
1100
|
+
await expect(recoverPin('975310')).rejects.toMatchObject({
|
|
1101
|
+
name: 'EscrowPinMismatchError',
|
|
1102
|
+
});
|
|
1103
|
+
calls = [];
|
|
1104
|
+
await expect(
|
|
1105
|
+
recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
|
|
1106
|
+
).resolves.toMatchObject({
|
|
1107
|
+
pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
|
|
1108
|
+
});
|
|
1109
|
+
expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('does not excuse a foreign wrong entry that lands during the release', async () => {
|
|
1113
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1114
|
+
await expect(
|
|
1115
|
+
recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
|
|
1116
|
+
).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
|
|
1117
|
+
const originalFetch = vi.mocked(fetch).getMockImplementation()!;
|
|
1118
|
+
vi.mocked(fetch).mockImplementation(async (url, init) => {
|
|
1119
|
+
const response = await originalFetch(url, init);
|
|
1120
|
+
// A foreign wrong entry resolved right after this release.
|
|
1121
|
+
if (String(url).endsWith('/keys/escrow/complete') && response.ok) {
|
|
1122
|
+
attemptsRemaining--;
|
|
1123
|
+
wrongAttempts = (wrongAttempts ?? 0) + 1;
|
|
1124
|
+
}
|
|
1125
|
+
return response;
|
|
1126
|
+
});
|
|
1127
|
+
calls = [];
|
|
1128
|
+
await expect(
|
|
1129
|
+
recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
|
|
1130
|
+
).resolves.toMatchObject({
|
|
1131
|
+
pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
|
|
1132
|
+
});
|
|
1133
|
+
expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('lets the server refuse the refresh when a foreign wrong entry lands after the checks', async () => {
|
|
1137
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1138
|
+
await expect(
|
|
1139
|
+
recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
|
|
1140
|
+
).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
|
|
1141
|
+
const originalFetch = vi.mocked(fetch).getMockImplementation()!;
|
|
1142
|
+
vi.mocked(fetch).mockImplementation(async (url, init) => {
|
|
1143
|
+
const path = new URL(String(url)).pathname.replace('/api', '');
|
|
1144
|
+
if (path === '/keys/escrow' && init?.method === 'POST') {
|
|
1145
|
+
attemptsRemaining--;
|
|
1146
|
+
wrongAttempts = (wrongAttempts ?? 0) + 1;
|
|
1147
|
+
}
|
|
1148
|
+
return originalFetch(url, init);
|
|
1149
|
+
});
|
|
1150
|
+
const saltBefore = pinSalt;
|
|
1151
|
+
await expect(
|
|
1152
|
+
recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
|
|
1153
|
+
).resolves.toMatchObject({
|
|
1154
|
+
// The refused refresh re-checks the status: the foreign entry shows, so
|
|
1155
|
+
// the user gets the warning sheet, not "You're signed in".
|
|
1156
|
+
pinSignIn: {
|
|
1157
|
+
mode: 'kept',
|
|
1158
|
+
pinRefreshed: false,
|
|
1159
|
+
newPinRequired: true,
|
|
1160
|
+
pinRefreshFailed: true,
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
expect(pinSalt).toBe(saltBefore);
|
|
1164
|
+
expect(wrongAttempts).toBe(2);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it('warns on a 409-refused refresh when the status cannot be re-read', async () => {
|
|
1168
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1169
|
+
await expect(
|
|
1170
|
+
recoverInFlow('246802', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
|
|
1171
|
+
).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
|
|
1172
|
+
const originalFetch = vi.mocked(fetch).getMockImplementation()!;
|
|
1173
|
+
let refused = false;
|
|
1174
|
+
vi.mocked(fetch).mockImplementation(async (url, init) => {
|
|
1175
|
+
const path = new URL(String(url)).pathname.replace('/api', '');
|
|
1176
|
+
if (path === '/keys/escrow' && init?.method === 'POST') {
|
|
1177
|
+
refused = true;
|
|
1178
|
+
return new Response(JSON.stringify({ message: 'changed' }), { status: 409 });
|
|
1179
|
+
}
|
|
1180
|
+
if (refused && path === '/keys/auth-share') throw new TypeError('offline');
|
|
1181
|
+
return originalFetch(url, init);
|
|
1182
|
+
});
|
|
1183
|
+
await expect(
|
|
1184
|
+
recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 1 })
|
|
1185
|
+
).resolves.toMatchObject({
|
|
1186
|
+
pinSignIn: { pinRefreshed: false, newPinRequired: true, pinRefreshFailed: true },
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it('ignores flow counts taken against another PIN generation (new salt)', async () => {
|
|
1191
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1192
|
+
// The flow's own typo was against an earlier PIN; this record's one wrong
|
|
1193
|
+
// entry is someone else's guess against the current PIN.
|
|
1194
|
+
await expect(recoverPin('246802')).rejects.toMatchObject({
|
|
1195
|
+
name: 'EscrowPinMismatchError',
|
|
1196
|
+
});
|
|
1197
|
+
calls = [];
|
|
1198
|
+
await expect(
|
|
1199
|
+
recoverInFlow('135790', {
|
|
1200
|
+
priorWrongAttempts: 0,
|
|
1201
|
+
ownWrongAttempts: 1,
|
|
1202
|
+
pinSalt: 'an-earlier-generation-salt',
|
|
1203
|
+
})
|
|
1204
|
+
).resolves.toMatchObject({
|
|
1205
|
+
pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
|
|
1206
|
+
});
|
|
1207
|
+
expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
|
|
1208
|
+
expect(wrongAttempts).toBe(1);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it('still refreshes after an own typo when the flow saw the same PIN generation', async () => {
|
|
1212
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1213
|
+
const flowSalt = pinSalt;
|
|
1214
|
+
await expect(
|
|
1215
|
+
recoverInFlow('246802', {
|
|
1216
|
+
priorWrongAttempts: 0,
|
|
1217
|
+
ownWrongAttempts: 0,
|
|
1218
|
+
pinSalt: flowSalt,
|
|
1219
|
+
})
|
|
1220
|
+
).rejects.toMatchObject({ name: 'EscrowPinMismatchError' });
|
|
1221
|
+
await expect(
|
|
1222
|
+
recoverInFlow('135790', {
|
|
1223
|
+
priorWrongAttempts: 0,
|
|
1224
|
+
ownWrongAttempts: 1,
|
|
1225
|
+
pinSalt: flowSalt,
|
|
1226
|
+
})
|
|
1227
|
+
).resolves.toMatchObject({
|
|
1228
|
+
pinSignIn: { mode: 'kept', pinRefreshed: true, newPinRequired: false },
|
|
1229
|
+
});
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
it.each([
|
|
1233
|
+
['an unknown prior count', { ownWrongAttempts: 0 }],
|
|
1234
|
+
['an invalid own count', { priorWrongAttempts: 0, ownWrongAttempts: -1 }],
|
|
1235
|
+
])('treats %s as wrong entries recorded', async (_label, pinAttempts) => {
|
|
1236
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1237
|
+
calls = [];
|
|
1238
|
+
await expect(recoverInFlow('135790', pinAttempts)).resolves.toMatchObject({
|
|
1239
|
+
pinSignIn: { mode: 'kept', pinRefreshed: false, newPinRequired: true },
|
|
1240
|
+
});
|
|
1241
|
+
expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it('keeps a legacy record (no verified count) blocked even with a clean prior count', async () => {
|
|
1245
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1246
|
+
wrongAttempts = undefined;
|
|
1247
|
+
calls = [];
|
|
1248
|
+
await expect(
|
|
1249
|
+
recoverInFlow('135790', { priorWrongAttempts: 0, ownWrongAttempts: 0 })
|
|
1250
|
+
).resolves.toMatchObject({
|
|
1251
|
+
pinSignIn: { pinRefreshed: false, newPinRequired: true },
|
|
1252
|
+
});
|
|
1253
|
+
expect(calls.some(call => call.path === '/keys/escrow')).toBe(false);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it.each([
|
|
1257
|
+
[1, 0, false],
|
|
1258
|
+
[2, 0, true],
|
|
1259
|
+
[1, 1, true],
|
|
1260
|
+
])(
|
|
1261
|
+
'excuses only own typos after a lost-login sign-in (recorded %s, prior %s)',
|
|
1262
|
+
async (recorded, prior, expected) => {
|
|
1263
|
+
await strategy.setEscrowPin!({ ...params, pin: '135790' });
|
|
1264
|
+
wrongAttempts = recorded;
|
|
1265
|
+
await strategy.executeRecovery({
|
|
1266
|
+
token: '',
|
|
1267
|
+
providerType,
|
|
1268
|
+
input: {
|
|
1269
|
+
method: 'escrow-pin',
|
|
1270
|
+
pin: '135790',
|
|
1271
|
+
recoverySessionToken: 'otp',
|
|
1272
|
+
pinAttempts: { priorWrongAttempts: prior, ownWrongAttempts: 1 },
|
|
1273
|
+
},
|
|
1274
|
+
didFromPrivateKey: async () => did,
|
|
1275
|
+
});
|
|
1276
|
+
await expect(strategy.completeIdentityRecovery!(params)).resolves.toMatchObject({
|
|
1277
|
+
pinSignIn: { mode: 'relinked', pinRefreshed: false, newPinRequired: expected },
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1045
1283
|
it.each([
|
|
1046
1284
|
[0, false],
|
|
1047
1285
|
[2, true],
|