@oxyhq/core 12.8.0 → 12.10.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +933 -106
- package/dist/cjs/index.js +11 -4
- package/dist/cjs/mixins/OxyServices.auth.js +25 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/cjs/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/cjs/mixins/OxyServices.user.js +4 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +932 -106
- package/dist/esm/index.js +4 -2
- package/dist/esm/mixins/OxyServices.auth.js +22 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/mixins/OxyServices.identityBackup.js +11 -0
- package/dist/esm/mixins/OxyServices.user.js +4 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +245 -3
- package/dist/types/index.d.ts +6 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +31 -6
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1125 -105
- package/src/index.ts +14 -2
- package/src/mixins/OxyServices.auth.ts +40 -12
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/OxyServices.identityBackup.ts +13 -0
- package/src/mixins/OxyServices.user.ts +4 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/identityBackup.test.ts +57 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +6 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/commonsApproval.ts +39 -0
package/src/index.ts
CHANGED
|
@@ -34,7 +34,13 @@ export type { AuthRefreshReason, AuthRefreshHandler } from './HttpService';
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
// Authentication
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
ServiceCredentialMismatchError,
|
|
39
|
+
} from './mixins/OxyServices.auth';
|
|
40
|
+
export {
|
|
41
|
+
getCommonsApprovalBlockingReason,
|
|
42
|
+
parseCommonsApprovalExpiresAt,
|
|
43
|
+
} from './utils/commonsApproval';
|
|
38
44
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
39
45
|
// "Sign in with Oxy" — handoff (Workstream C)
|
|
40
46
|
export type {
|
|
@@ -245,8 +251,14 @@ export {
|
|
|
245
251
|
KeyManager,
|
|
246
252
|
IdentityAlreadyExistsError,
|
|
247
253
|
IdentityPersistError,
|
|
254
|
+
IdentityUnavailableError,
|
|
248
255
|
} from './crypto/keyManager';
|
|
249
|
-
export type { KeyPair } from './crypto/keyManager';
|
|
256
|
+
export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
|
|
257
|
+
export {
|
|
258
|
+
readIdentityMarker,
|
|
259
|
+
updateIdentityMarker,
|
|
260
|
+
} from './crypto/identityMarker';
|
|
261
|
+
export type { IdentityMarker } from './crypto/identityMarker';
|
|
250
262
|
export { SignatureService } from './crypto/signatureService';
|
|
251
263
|
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
252
264
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
@@ -9,6 +9,10 @@ import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
|
|
|
9
9
|
import type { SessionLoginResponse } from '../models/session';
|
|
10
10
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
11
11
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
12
|
+
export {
|
|
13
|
+
getCommonsApprovalBlockingReason,
|
|
14
|
+
parseCommonsApprovalExpiresAt,
|
|
15
|
+
} from '../utils/commonsApproval';
|
|
12
16
|
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
13
17
|
import { KeyManager } from '../crypto/keyManager';
|
|
14
18
|
import { SignatureService } from '../crypto/signatureService';
|
|
@@ -96,7 +100,7 @@ export interface CommonsSignInStatus {
|
|
|
96
100
|
*/
|
|
97
101
|
export interface CommonsApprovalInfo {
|
|
98
102
|
/** Sanitized, display-safe identity of the requesting application. */
|
|
99
|
-
application: PublicApplication;
|
|
103
|
+
application: PublicApplication | null;
|
|
100
104
|
/** OAuth scopes the application is requesting. */
|
|
101
105
|
scopes: string[];
|
|
102
106
|
/** The origin the session is bound to (the RP web origin), when applicable. */
|
|
@@ -109,8 +113,8 @@ export interface CommonsApprovalInfo {
|
|
|
109
113
|
* "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
|
|
110
114
|
*/
|
|
111
115
|
originVerified: boolean;
|
|
112
|
-
/** Server-authoritative expiry (epoch
|
|
113
|
-
expiresAt: number;
|
|
116
|
+
/** Server-authoritative expiry (epoch ms or ISO-8601 string from the API). */
|
|
117
|
+
expiresAt: number | string;
|
|
114
118
|
/** Session lifecycle status. */
|
|
115
119
|
status: string;
|
|
116
120
|
}
|
|
@@ -122,11 +126,11 @@ export interface CommonsApprovalInfo {
|
|
|
122
126
|
* into {@link CommonsApprovalInfo}.
|
|
123
127
|
*/
|
|
124
128
|
interface CommonsApprovalInfoResponse {
|
|
125
|
-
application: PublicApplication;
|
|
129
|
+
application: PublicApplication | null;
|
|
126
130
|
scopes: string[];
|
|
127
131
|
boundOrigin?: string;
|
|
128
132
|
originVerified?: unknown;
|
|
129
|
-
expiresAt: number;
|
|
133
|
+
expiresAt: number | string;
|
|
130
134
|
status: string;
|
|
131
135
|
}
|
|
132
136
|
|
|
@@ -497,14 +501,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
497
501
|
/**
|
|
498
502
|
* Request an authentication challenge
|
|
499
503
|
* The client must sign this challenge with their private key
|
|
500
|
-
*
|
|
504
|
+
*
|
|
501
505
|
* @param publicKey - The user's public key
|
|
506
|
+
* @param requestOptions - Optional per-call transport overrides (`retry`,
|
|
507
|
+
* `timeout`). Interactive callers omit it (defaults keep retries); the
|
|
508
|
+
* cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
|
|
509
|
+
* network cannot multiply boot latency via the inner retry loop.
|
|
502
510
|
*/
|
|
503
|
-
async requestChallenge(
|
|
511
|
+
async requestChallenge(
|
|
512
|
+
publicKey: string,
|
|
513
|
+
requestOptions?: { retry?: boolean; timeout?: number },
|
|
514
|
+
): Promise<ChallengeResponse> {
|
|
504
515
|
try {
|
|
505
516
|
return await this.makeRequest<ChallengeResponse>('POST', '/auth/challenge', {
|
|
506
517
|
publicKey,
|
|
507
|
-
}, { cache: false });
|
|
518
|
+
}, { cache: false, ...requestOptions });
|
|
508
519
|
} catch (error) {
|
|
509
520
|
throw this.handleError(error);
|
|
510
521
|
}
|
|
@@ -519,6 +530,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
519
530
|
* @param timestamp - Timestamp when the signature was created
|
|
520
531
|
* @param deviceName - Optional device name
|
|
521
532
|
* @param deviceFingerprint - Optional device fingerprint
|
|
533
|
+
* @param requestOptions - Optional per-call transport overrides (`retry`,
|
|
534
|
+
* `timeout`). Interactive callers omit it (defaults keep retries); the
|
|
535
|
+
* cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
|
|
536
|
+
* network cannot multiply boot latency via the inner retry loop.
|
|
522
537
|
*/
|
|
523
538
|
async verifyChallenge(
|
|
524
539
|
publicKey: string,
|
|
@@ -526,7 +541,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
526
541
|
signature: string,
|
|
527
542
|
timestamp: number,
|
|
528
543
|
deviceName?: string,
|
|
529
|
-
deviceFingerprint?: string
|
|
544
|
+
deviceFingerprint?: string,
|
|
545
|
+
requestOptions?: { retry?: boolean; timeout?: number },
|
|
530
546
|
): Promise<SessionLoginResponse> {
|
|
531
547
|
try {
|
|
532
548
|
const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
|
|
@@ -536,7 +552,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
536
552
|
timestamp,
|
|
537
553
|
deviceName,
|
|
538
554
|
deviceFingerprint,
|
|
539
|
-
}, { cache: false });
|
|
555
|
+
}, { cache: false, ...requestOptions });
|
|
540
556
|
|
|
541
557
|
// Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
|
|
542
558
|
// `/auth/verify` returns the first access token (and refresh token) in
|
|
@@ -718,9 +734,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
718
734
|
*
|
|
719
735
|
* The cold-boot wiring that CALLS this lives in `OxyContext`
|
|
720
736
|
* (`@oxyhq/services`); this method just performs the exchange.
|
|
737
|
+
*
|
|
738
|
+
* @param opts.requestOptions - Optional per-call transport overrides
|
|
739
|
+
* (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
|
|
740
|
+
* `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
|
|
741
|
+
* retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
|
|
742
|
+
* so this network step cannot multiply boot latency via the inner retry
|
|
743
|
+
* loop. The token-refresh scheduler / 401 lane still retry later.
|
|
721
744
|
*/
|
|
722
745
|
async signInWithSharedIdentity(
|
|
723
|
-
opts: {
|
|
746
|
+
opts: {
|
|
747
|
+
deviceName?: string;
|
|
748
|
+
deviceFingerprint?: string;
|
|
749
|
+
requestOptions?: { retry?: boolean; timeout?: number };
|
|
750
|
+
} = {}
|
|
724
751
|
): Promise<SessionLoginResponse | null> {
|
|
725
752
|
try {
|
|
726
753
|
// `hasSharedIdentity()` already returns false on web (the shared
|
|
@@ -734,7 +761,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
734
761
|
return null;
|
|
735
762
|
}
|
|
736
763
|
|
|
737
|
-
const { challenge } = await this.requestChallenge(sharedPublicKey);
|
|
764
|
+
const { challenge } = await this.requestChallenge(sharedPublicKey, opts.requestOptions);
|
|
738
765
|
const signed = await SignatureService.signChallengeWithSharedKey(challenge);
|
|
739
766
|
|
|
740
767
|
// `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
|
|
@@ -745,6 +772,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
745
772
|
signed.timestamp,
|
|
746
773
|
opts.deviceName,
|
|
747
774
|
opts.deviceFingerprint,
|
|
775
|
+
opts.requestOptions,
|
|
748
776
|
);
|
|
749
777
|
} catch (error) {
|
|
750
778
|
throw this.handleError(error);
|
|
@@ -39,6 +39,14 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
39
39
|
* `no_active_session`) to decide whether to drop the secret and fall back or
|
|
40
40
|
* resolve signed-out.
|
|
41
41
|
*
|
|
42
|
+
* `retry: false`: the mint is a single logical attempt. The proactive
|
|
43
|
+
* token-refresh scheduler and the reactive 401 lane already own backoff and
|
|
44
|
+
* re-arm, so `HttpService`'s inner retry loop here would only multiply the
|
|
45
|
+
* mint's latency on a slow/black-hole network (3 retries × 5s timeout ≈ 20s
|
|
46
|
+
* per lane) with no correctness benefit — it is the dominant term in the cold
|
|
47
|
+
* boot's worst-case time-to-route. A transient failure surfaces once and the
|
|
48
|
+
* scheduler/401 path retries it later.
|
|
49
|
+
*
|
|
42
50
|
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
43
51
|
*/
|
|
44
52
|
async mintFromDeviceSecret(
|
|
@@ -50,7 +58,7 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
50
58
|
'POST',
|
|
51
59
|
'/session/device/token',
|
|
52
60
|
{ deviceId, deviceSecret },
|
|
53
|
-
{ cache: false, skipAuth: true },
|
|
61
|
+
{ cache: false, skipAuth: true, retry: false },
|
|
54
62
|
);
|
|
55
63
|
const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
|
|
56
64
|
if (!parsed) {
|
|
@@ -218,6 +218,19 @@ export function OxyServicesIdentityBackupMixin<T extends typeof OxyServicesBase>
|
|
|
218
218
|
);
|
|
219
219
|
const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
|
|
220
220
|
|
|
221
|
+
if (!payload.privateKey || !payload.publicKey) {
|
|
222
|
+
throw new Error('Backup payload is missing key material');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const derivedFromPhrase = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
|
|
226
|
+
const derivedFromPrivate = KeyManager.derivePublicKey(payload.privateKey);
|
|
227
|
+
const phrasePk = derivedFromPhrase.toLowerCase();
|
|
228
|
+
const payloadPk = payload.publicKey.toLowerCase();
|
|
229
|
+
const privatePk = derivedFromPrivate.toLowerCase();
|
|
230
|
+
if (phrasePk !== payloadPk || privatePk !== payloadPk) {
|
|
231
|
+
throw new Error('Backup payload does not match the recovery phrase');
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
// Persist the recovered key. Native-only; refuses to clobber a different
|
|
222
235
|
// identity unless overwrite — the IdentityAlreadyExistsError propagates.
|
|
223
236
|
return await KeyManager.importKeyPair(payload.privateKey, {
|
|
@@ -606,6 +606,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
606
606
|
const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
|
|
607
607
|
cache: false,
|
|
608
608
|
});
|
|
609
|
+
this.clearCacheByPrefix('GET:/session/user/');
|
|
610
|
+
this.clearCacheByPrefix('GET:/users/me');
|
|
611
|
+
this.clearCacheByPrefix('GET:/profiles/username/');
|
|
612
|
+
this.clearCacheEntry(`GET:/users/${id}`);
|
|
609
613
|
this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
|
|
610
614
|
return result;
|
|
611
615
|
} catch (error) {
|
|
@@ -31,15 +31,17 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
|
|
34
|
+
it('POSTs deviceId + deviceSecret with skipAuth + retry:false (no bearer, no cache, single attempt) and returns the validated mint', async () => {
|
|
35
35
|
makeRequest.mockResolvedValueOnce(MINT);
|
|
36
36
|
const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
|
|
37
37
|
expect(result).toEqual(MINT);
|
|
38
|
+
// `retry: false` — the scheduler/401 lane own backoff; HttpService's inner
|
|
39
|
+
// retry here would only multiply cold-boot latency on a slow network.
|
|
38
40
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
39
41
|
'POST',
|
|
40
42
|
'/session/device/token',
|
|
41
43
|
{ deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
|
|
42
|
-
{ cache: false, skipAuth: true },
|
|
44
|
+
{ cache: false, skipAuth: true, retry: false },
|
|
43
45
|
);
|
|
44
46
|
});
|
|
45
47
|
|
|
@@ -242,6 +242,51 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
describe('requestChallenge / verifyChallenge — requestOptions spread into makeRequest', () => {
|
|
246
|
+
it('requestChallenge omits transport overrides by default (retries ON) and spreads them when given', async () => {
|
|
247
|
+
makeRequestSpy.mockResolvedValue(challengeFixture);
|
|
248
|
+
|
|
249
|
+
await oxy.requestChallenge('pub-x');
|
|
250
|
+
expect(makeRequestSpy).toHaveBeenLastCalledWith(
|
|
251
|
+
'POST',
|
|
252
|
+
'/auth/challenge',
|
|
253
|
+
{ publicKey: 'pub-x' },
|
|
254
|
+
{ cache: false },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await oxy.requestChallenge('pub-x', { retry: false });
|
|
258
|
+
expect(makeRequestSpy).toHaveBeenLastCalledWith(
|
|
259
|
+
'POST',
|
|
260
|
+
'/auth/challenge',
|
|
261
|
+
{ publicKey: 'pub-x' },
|
|
262
|
+
{ cache: false, retry: false },
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('verifyChallenge spreads requestOptions (retry + timeout) into makeRequest', async () => {
|
|
267
|
+
makeRequestSpy.mockResolvedValue(sessionFixture);
|
|
268
|
+
|
|
269
|
+
await oxy.verifyChallenge('pub-x', 'chal', 'sig', 123, 'dev', 'fp', {
|
|
270
|
+
retry: false,
|
|
271
|
+
timeout: 9000,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(makeRequestSpy).toHaveBeenLastCalledWith(
|
|
275
|
+
'POST',
|
|
276
|
+
'/auth/verify',
|
|
277
|
+
{
|
|
278
|
+
publicKey: 'pub-x',
|
|
279
|
+
challenge: 'chal',
|
|
280
|
+
signature: 'sig',
|
|
281
|
+
timestamp: 123,
|
|
282
|
+
deviceName: 'dev',
|
|
283
|
+
deviceFingerprint: 'fp',
|
|
284
|
+
},
|
|
285
|
+
{ cache: false, retry: false, timeout: 9000 },
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
245
290
|
describe('signInWithSharedIdentity (Mechanism A — same-device SSO)', () => {
|
|
246
291
|
it('mints a session from the shared key when one exists (native)', async () => {
|
|
247
292
|
jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
|
|
@@ -263,7 +308,9 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
263
308
|
deviceFingerprint: 'fp-1',
|
|
264
309
|
});
|
|
265
310
|
|
|
266
|
-
|
|
311
|
+
// No requestOptions passed → both round-trips get `undefined` (defaults:
|
|
312
|
+
// retries ON), preserving interactive behaviour.
|
|
313
|
+
expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', undefined);
|
|
267
314
|
expect(verifyChallengeSpy).toHaveBeenCalledWith(
|
|
268
315
|
'shared-pub',
|
|
269
316
|
'chal-shared',
|
|
@@ -271,6 +318,42 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
271
318
|
1700000000456,
|
|
272
319
|
'iPad',
|
|
273
320
|
'fp-1',
|
|
321
|
+
undefined,
|
|
322
|
+
);
|
|
323
|
+
expect(result).toEqual(sessionFixture);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('threads requestOptions into BOTH the challenge and verify round-trips (cold-boot retry:false)', async () => {
|
|
327
|
+
jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
|
|
328
|
+
jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue('shared-pub');
|
|
329
|
+
const requestChallengeSpy = jest
|
|
330
|
+
.spyOn(oxy, 'requestChallenge')
|
|
331
|
+
.mockResolvedValue({ challenge: 'chal-shared', expiresAt: '2026-06-26T00:05:00.000Z' });
|
|
332
|
+
jest.spyOn(SignatureService, 'signChallengeWithSharedKey').mockResolvedValue({
|
|
333
|
+
challenge: 'sig-shared',
|
|
334
|
+
publicKey: 'shared-pub',
|
|
335
|
+
timestamp: 1700000000456,
|
|
336
|
+
});
|
|
337
|
+
const verifyChallengeSpy = jest
|
|
338
|
+
.spyOn(oxy, 'verifyChallenge')
|
|
339
|
+
.mockResolvedValue(sessionFixture);
|
|
340
|
+
|
|
341
|
+
const result = await oxy.signInWithSharedIdentity({
|
|
342
|
+
requestOptions: { retry: false },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// The SAME requestOptions object is forwarded to both calls — this is how
|
|
346
|
+
// the cold-boot `shared-key-signin` step keeps its two round-trips as
|
|
347
|
+
// single attempts without changing interactive defaults.
|
|
348
|
+
expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', { retry: false });
|
|
349
|
+
expect(verifyChallengeSpy).toHaveBeenCalledWith(
|
|
350
|
+
'shared-pub',
|
|
351
|
+
'chal-shared',
|
|
352
|
+
'sig-shared',
|
|
353
|
+
1700000000456,
|
|
354
|
+
undefined,
|
|
355
|
+
undefined,
|
|
356
|
+
{ retry: false },
|
|
274
357
|
);
|
|
275
358
|
expect(result).toEqual(sessionFixture);
|
|
276
359
|
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { OxyServices } from '../../OxyServices';
|
|
16
16
|
import { KeyManager, IdentityAlreadyExistsError } from '../../crypto/keyManager';
|
|
17
17
|
import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
|
|
18
|
+
import { encryptAead, decryptAead } from '../../crypto/aead';
|
|
18
19
|
import type { EncryptedBackupEnvelope, BackupUploadRequest } from '@oxyhq/contracts';
|
|
19
20
|
|
|
20
21
|
const FIXED_PHRASE =
|
|
@@ -192,6 +193,62 @@ describe('encrypted identity backup mixin', () => {
|
|
|
192
193
|
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
|
|
193
194
|
});
|
|
194
195
|
|
|
196
|
+
it('rejects when decrypted payload publicKey does not match the recovery phrase', async () => {
|
|
197
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
198
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
199
|
+
const uploaded = uploadBodyFromCall(0);
|
|
200
|
+
|
|
201
|
+
const { backupKey } = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
|
|
202
|
+
const aad = new TextEncoder().encode(
|
|
203
|
+
JSON.stringify({ version: uploaded.version, publicKeyHint: uploaded.publicKeyHint }),
|
|
204
|
+
);
|
|
205
|
+
const fromHex = (hex: string): Uint8Array => {
|
|
206
|
+
const out = new Uint8Array(hex.length / 2);
|
|
207
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
208
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
212
|
+
const toHex = (bytes: Uint8Array): string =>
|
|
213
|
+
Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
214
|
+
|
|
215
|
+
const plaintext = decryptAead(
|
|
216
|
+
backupKey,
|
|
217
|
+
fromHex(uploaded.nonce),
|
|
218
|
+
fromHex(uploaded.ciphertext),
|
|
219
|
+
aad,
|
|
220
|
+
);
|
|
221
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext)) as {
|
|
222
|
+
privateKey: string;
|
|
223
|
+
publicKey: string;
|
|
224
|
+
createdAt: string;
|
|
225
|
+
};
|
|
226
|
+
payload.publicKey = `${payload.publicKey.slice(0, -1)}0`;
|
|
227
|
+
|
|
228
|
+
const { nonce, ciphertext } = encryptAead(
|
|
229
|
+
backupKey,
|
|
230
|
+
new TextEncoder().encode(JSON.stringify(payload)),
|
|
231
|
+
aad,
|
|
232
|
+
);
|
|
233
|
+
const tampered: EncryptedBackupEnvelope = {
|
|
234
|
+
version: uploaded.version,
|
|
235
|
+
algorithm: uploaded.algorithm,
|
|
236
|
+
kdfInfo: uploaded.kdfInfo,
|
|
237
|
+
nonce: toHex(nonce),
|
|
238
|
+
ciphertext: toHex(ciphertext),
|
|
239
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
240
|
+
createdAt: uploaded.createdAt,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
|
|
244
|
+
fetchMock.mockResolvedValueOnce(plainResponse(tampered));
|
|
245
|
+
|
|
246
|
+
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow(
|
|
247
|
+
/does not match the recovery phrase/,
|
|
248
|
+
);
|
|
249
|
+
expect(importSpy).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
195
252
|
it('propagates IdentityAlreadyExistsError UNCHANGED (so the caller can offer overwrite)', async () => {
|
|
196
253
|
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
197
254
|
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
@@ -129,6 +129,7 @@ describe('privacy cache invalidation', () => {
|
|
|
129
129
|
|
|
130
130
|
it('invalidates the exact logical keys on block/restrict/settings writes', async () => {
|
|
131
131
|
const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
|
|
132
|
+
const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
|
|
132
133
|
|
|
133
134
|
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
134
135
|
await oxy.blockUser('u1');
|
|
@@ -141,7 +142,12 @@ describe('privacy cache invalidation', () => {
|
|
|
141
142
|
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
142
143
|
await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
|
|
143
144
|
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/me/privacy');
|
|
145
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/users/me');
|
|
146
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/session/user/');
|
|
147
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
|
|
148
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/profiles/username/');
|
|
144
149
|
|
|
145
150
|
clearSpy.mockRestore();
|
|
151
|
+
clearPrefixSpy.mockRestore();
|
|
146
152
|
});
|
|
147
153
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCommonsApprovalBlockingReason,
|
|
3
|
+
parseCommonsApprovalExpiresAt,
|
|
4
|
+
} from '../commonsApproval';
|
|
5
|
+
|
|
6
|
+
describe('commonsApproval validation', () => {
|
|
7
|
+
const application = {
|
|
8
|
+
id: 'app1',
|
|
9
|
+
name: 'Mention',
|
|
10
|
+
type: 'first_party' as const,
|
|
11
|
+
isOfficial: true,
|
|
12
|
+
isInternal: false,
|
|
13
|
+
scopes: ['profile'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
it('blocks when the application is missing', () => {
|
|
17
|
+
expect(
|
|
18
|
+
getCommonsApprovalBlockingReason({
|
|
19
|
+
application: null,
|
|
20
|
+
status: 'pending',
|
|
21
|
+
expiresAt: Date.now() + 60_000,
|
|
22
|
+
}),
|
|
23
|
+
).toMatch(/could not be resolved/i);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('blocks non-pending sessions', () => {
|
|
27
|
+
expect(
|
|
28
|
+
getCommonsApprovalBlockingReason({
|
|
29
|
+
application,
|
|
30
|
+
status: 'expired',
|
|
31
|
+
expiresAt: Date.now() + 60_000,
|
|
32
|
+
}),
|
|
33
|
+
).toMatch(/invalid, already used, or expired/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('blocks expired pending sessions (ISO expiresAt)', () => {
|
|
37
|
+
expect(
|
|
38
|
+
getCommonsApprovalBlockingReason({
|
|
39
|
+
application,
|
|
40
|
+
status: 'pending',
|
|
41
|
+
expiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
42
|
+
}),
|
|
43
|
+
).toMatch(/expired/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('allows a pending, unexpired session', () => {
|
|
47
|
+
expect(
|
|
48
|
+
getCommonsApprovalBlockingReason({
|
|
49
|
+
application,
|
|
50
|
+
status: 'pending',
|
|
51
|
+
expiresAt: Date.now() + 60_000,
|
|
52
|
+
}),
|
|
53
|
+
).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('parses ISO expiresAt strings', () => {
|
|
57
|
+
const iso = '2026-07-19T12:00:00.000Z';
|
|
58
|
+
expect(parseCommonsApprovalExpiresAt(iso)).toBe(Date.parse(iso));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PublicApplication } from '../mixins/OxyServices.connectedApps';
|
|
2
|
+
|
|
3
|
+
export interface CommonsApprovalValidationInput {
|
|
4
|
+
application: PublicApplication | null;
|
|
5
|
+
status: string;
|
|
6
|
+
expiresAt: number | string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns a user-facing blocking reason when an approval payload must not be
|
|
11
|
+
* shown as actionable, or `null` when the request is still pending and valid.
|
|
12
|
+
*/
|
|
13
|
+
export function getCommonsApprovalBlockingReason(
|
|
14
|
+
info: CommonsApprovalValidationInput,
|
|
15
|
+
): string | null {
|
|
16
|
+
if (!info.application?.id) {
|
|
17
|
+
return 'The requesting application could not be resolved.';
|
|
18
|
+
}
|
|
19
|
+
if (info.status !== 'pending') {
|
|
20
|
+
return 'This sign-in request is invalid, already used, or expired.';
|
|
21
|
+
}
|
|
22
|
+
const expiresAtMs = parseCommonsApprovalExpiresAt(info.expiresAt);
|
|
23
|
+
if (expiresAtMs !== null && expiresAtMs < Date.now()) {
|
|
24
|
+
return 'This sign-in request has expired. Ask for a new QR code.';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Normalize API `expiresAt` (number or ISO string) to epoch ms. */
|
|
30
|
+
export function parseCommonsApprovalExpiresAt(
|
|
31
|
+
expiresAt: CommonsApprovalValidationInput['expiresAt'],
|
|
32
|
+
): number | null {
|
|
33
|
+
if (typeof expiresAt === 'number' && Number.isFinite(expiresAt)) return expiresAt;
|
|
34
|
+
if (typeof expiresAt === 'string') {
|
|
35
|
+
const ms = Date.parse(expiresAt);
|
|
36
|
+
return Number.isFinite(ms) ? ms : null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|