@oxyhq/core 3.10.1 → 3.11.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 (82) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +103 -0
  8. package/dist/cjs/index.js +15 -4
  9. package/dist/cjs/mixins/OxyServices.assets.js +16 -1
  10. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  11. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  12. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  13. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  14. package/dist/cjs/mixins/index.js +3 -0
  15. package/dist/cjs/server/cors.js +20 -21
  16. package/dist/cjs/server/rateLimit.js +32 -8
  17. package/dist/cjs/utils/ssoReturn.js +1 -1
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/AuthManager.js +9 -2
  20. package/dist/esm/HttpService.js +27 -9
  21. package/dist/esm/OxyServices.base.js +3 -2
  22. package/dist/esm/crypto/canonicalJson.js +104 -0
  23. package/dist/esm/crypto/keyManager.js +67 -8
  24. package/dist/esm/crypto/signatureService.js +102 -0
  25. package/dist/esm/index.js +9 -1
  26. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  27. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  28. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  29. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  30. package/dist/esm/mixins/OxyServices.user.js +1 -0
  31. package/dist/esm/mixins/index.js +3 -0
  32. package/dist/esm/server/cors.js +20 -21
  33. package/dist/esm/server/rateLimit.js +32 -8
  34. package/dist/esm/utils/ssoReturn.js +1 -1
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/HttpService.d.ts +3 -0
  37. package/dist/types/OxyServices.d.ts +2 -2
  38. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  39. package/dist/types/crypto/keyManager.d.ts +7 -0
  40. package/dist/types/crypto/signatureService.d.ts +61 -0
  41. package/dist/types/index.d.ts +6 -2
  42. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  43. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  44. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  45. package/dist/types/mixins/index.d.ts +2 -1
  46. package/dist/types/models/interfaces.d.ts +3 -0
  47. package/dist/types/server/cors.d.ts +5 -5
  48. package/dist/types/utils/ssoReturn.d.ts +1 -1
  49. package/package.json +2 -2
  50. package/src/AuthManager.ts +8 -2
  51. package/src/HttpService.ts +36 -8
  52. package/src/OxyServices.base.ts +3 -2
  53. package/src/OxyServices.ts +1 -1
  54. package/src/__tests__/authManager.security.test.ts +31 -0
  55. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  56. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  57. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  58. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  59. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  60. package/src/crypto/canonicalJson.ts +120 -0
  61. package/src/crypto/keyManager.ts +62 -12
  62. package/src/crypto/signatureService.ts +126 -0
  63. package/src/index.ts +27 -2
  64. package/src/mixins/OxyServices.assets.ts +16 -1
  65. package/src/mixins/OxyServices.auth.ts +309 -1
  66. package/src/mixins/OxyServices.identity.ts +445 -0
  67. package/src/mixins/OxyServices.sso.ts +30 -1
  68. package/src/mixins/OxyServices.user.ts +1 -0
  69. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  70. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  71. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  72. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  73. package/src/mixins/__tests__/sso.test.ts +31 -0
  74. package/src/mixins/index.ts +4 -0
  75. package/src/models/interfaces.ts +3 -0
  76. package/src/server/__tests__/cors.test.ts +5 -1
  77. package/src/server/__tests__/rateLimit.test.ts +116 -0
  78. package/src/server/cors.ts +25 -20
  79. package/src/server/rateLimit.ts +39 -8
  80. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  81. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  82. package/src/utils/ssoReturn.ts +2 -2
@@ -103,6 +103,9 @@ export interface User {
103
103
  */
104
104
  name: UserNameResponse;
105
105
  bio?: string;
106
+ phone?: string;
107
+ address?: string;
108
+ birthday?: string;
106
109
  location?: string;
107
110
  website?: string;
108
111
  createdAt?: string;
@@ -11,10 +11,9 @@
11
11
  *
12
12
  * `createOxyCors` returns a self-contained Express middleware (no `cors`
13
13
  * package dependency) that:
14
- * - allows the Oxy apex origin family (anything under `*.${CENTRAL_IDP_APEX}`,
15
- * i.e. `oxy.so` covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
16
- * `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
17
- * central-origin constants already in core, NOT a fresh hardcoded list,
14
+ * - allows the Oxy apex origin family over HTTPS only: the apex plus
15
+ * one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
16
+ * `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
18
17
  * - allows the caller's explicit `appOrigins`,
19
18
  * - DENIES everything else (no reflection, never a wildcard with credentials),
20
19
  * - echoes back the EXACT matched origin (so credentialed requests work) and
@@ -28,7 +27,8 @@ export interface OxyCorsOptions {
28
27
  /**
29
28
  * Explicit additional allowed origins (exact-origin match, e.g.
30
29
  * `https://app.example.com`, `http://localhost:3000`). These are allowed IN
31
- * ADDITION TO the Oxy apex origin family. Each is normalized via `new URL().origin`.
30
+ * ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
31
+ * via `new URL().origin`.
32
32
  */
33
33
  appOrigins?: string[];
34
34
  /**
@@ -142,5 +142,5 @@ export interface ConsumeSsoReturnDeps {
142
142
  * @returns The exchanged session on success, otherwise `null`.
143
143
  */
144
144
  export declare function consumeSsoReturn(oxy: {
145
- exchangeSsoCode: (code: string) => Promise<SessionLoginResponse>;
145
+ exchangeSsoCode: (code: string, state?: string) => Promise<SessionLoginResponse>;
146
146
  }, deps?: ConsumeSsoReturnDeps): Promise<SessionLoginResponse | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -98,7 +98,7 @@
98
98
  }
99
99
  },
100
100
  "dependencies": {
101
- "@oxyhq/contracts": "^0.2.1",
101
+ "@oxyhq/contracts": "^0.3.0",
102
102
  "bip39": "^3.1.0",
103
103
  "buffer": "^6.0.3",
104
104
  "elliptic": "^6.6.1",
@@ -445,8 +445,14 @@ export class AuthManager {
445
445
  * Get default storage based on environment.
446
446
  */
447
447
  private getDefaultStorage(): StorageAdapter {
448
- if (typeof window !== 'undefined' && window.localStorage) {
449
- return new LocalStorageAdapter();
448
+ try {
449
+ if (typeof window !== 'undefined' && window.localStorage) {
450
+ return new LocalStorageAdapter();
451
+ }
452
+ } catch {
453
+ // Accessing window.localStorage can throw in opaque-origin/sandboxed
454
+ // browser contexts or when storage is disabled. Fall back to memory so
455
+ // AuthManager construction remains safe during provider render.
450
456
  }
451
457
  return new MemoryStorage();
452
458
  }
@@ -68,6 +68,7 @@ export interface RequestOptions {
68
68
  timeout?: number;
69
69
  signal?: AbortSignal;
70
70
  headers?: Record<string, string>;
71
+ responseType?: 'blob';
71
72
  }
72
73
 
73
74
  interface RequestConfig extends RequestOptions {
@@ -496,13 +497,21 @@ export class HttpService {
496
497
  const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
497
498
 
498
499
  const response = useXhrForUpload
499
- ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue as FormData, controller.signal, timeout)
500
+ ? await this.uploadViaXHR(
501
+ fullUrl,
502
+ method,
503
+ headers,
504
+ bodyValue as FormData,
505
+ controller.signal,
506
+ timeout,
507
+ this.shouldSendCredentials(fullUrl),
508
+ )
500
509
  : await fetch(fullUrl, {
501
510
  method,
502
511
  headers,
503
512
  body: bodyValue as BodyInit | null | undefined,
504
513
  signal: controller.signal,
505
- credentials: 'include', // Include cookies for cross-origin requests (CSRF, session)
514
+ credentials: this.getCredentialsMode(fullUrl),
506
515
  });
507
516
 
508
517
  if (timeoutId) clearTimeout(timeoutId);
@@ -530,7 +539,7 @@ export class HttpService {
530
539
  const errBody = await clonedResponse.json() as { code?: string } | null;
531
540
  if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
532
541
  this.tokenStore.clearCsrfToken();
533
- return this.request<T>({ ...config, _isCsrfRetry: true, retry: false });
542
+ return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
534
543
  }
535
544
  } catch {
536
545
  // Failed to parse error body — not a CSRF error
@@ -568,7 +577,9 @@ export class HttpService {
568
577
  const contentType = response.headers.get('content-type');
569
578
  let responseData: unknown;
570
579
 
571
- if (contentType && contentType.includes('application/json')) {
580
+ if (config.responseType === 'blob') {
581
+ responseData = await response.blob();
582
+ } else if (contentType && contentType.includes('application/json')) {
572
583
  // Use response.json() directly for better performance
573
584
  try {
574
585
  responseData = await response.json();
@@ -694,13 +705,15 @@ export class HttpService {
694
705
  body: FormData,
695
706
  abortSignal: AbortSignal,
696
707
  timeout: number,
708
+ withCredentials: boolean,
697
709
  ): Promise<Response> {
698
710
  return new Promise<Response>((resolve, reject) => {
699
711
  const xhr = new XMLHttpRequest();
700
712
  xhr.open(method, url, true);
701
- // withCredentials mirrors fetch's `credentials: 'include'` so the
702
- // session cookie and CSRF cookie continue to flow.
703
- xhr.withCredentials = true;
713
+ // Only send ambient cookies to the configured API origin. Absolute
714
+ // caller-supplied URLs can target arbitrary origins, so they must not
715
+ // receive credential-bearing requests by default.
716
+ xhr.withCredentials = withCredentials;
704
717
 
705
718
  // Forward headers but skip Content-Type — XHR sets the multipart
706
719
  // boundary automatically and overriding it breaks the upload.
@@ -874,6 +887,18 @@ export class HttpService {
874
887
  return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
875
888
  }
876
889
 
890
+ private getCredentialsMode(url: string): RequestCredentials {
891
+ return this.shouldSendCredentials(url) ? 'include' : 'omit';
892
+ }
893
+
894
+ private shouldSendCredentials(url: string): boolean {
895
+ try {
896
+ return new URL(url).origin === new URL(this.baseURL).origin;
897
+ } catch {
898
+ return false;
899
+ }
900
+ }
901
+
877
902
  /**
878
903
  * Fetch CSRF token from server (with deduplication)
879
904
  * Required for state-changing requests (POST, PUT, PATCH, DELETE)
@@ -915,8 +940,11 @@ export class HttpService {
915
940
 
916
941
  if (response.ok) {
917
942
  const data = await response.json() as { csrfToken?: string };
918
- this.logger.debug('CSRF response data:', data);
919
943
  const token = data.csrfToken || null;
944
+ this.logger.debug('CSRF response data:', {
945
+ hasCsrfToken: typeof token === 'string' && token.length > 0,
946
+ csrfTokenLength: token?.length,
947
+ });
920
948
  this.tokenStore.setCsrfToken(token);
921
949
  this.logger.debug('CSRF token fetched');
922
950
  return token;
@@ -287,8 +287,9 @@ export class OxyServicesBase {
287
287
 
288
288
  try {
289
289
  const decoded = jwtDecode<JwtPayload>(accessToken);
290
- this._cachedUserId = decoded.userId || decoded.id || null;
291
- return this._cachedUserId;
290
+ const userId = decoded.userId || decoded.id || null;
291
+ this._cachedUserId = userId;
292
+ return userId;
292
293
  } catch {
293
294
  this._cachedUserId = null;
294
295
  return null;
@@ -153,7 +153,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
153
153
  signUpWithRedirect(options?: RedirectAuthOptions): void;
154
154
 
155
155
  // Central cross-domain SSO (opaque single-use code exchange)
156
- exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
156
+ exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse>;
157
157
  generateSsoState(): string;
158
158
 
159
159
  // Express.js middleware
@@ -164,6 +164,37 @@ describe('AuthManager.switchAuthuser — concurrency lock', () => {
164
164
  });
165
165
  });
166
166
 
167
+ describe('AuthManager default storage selection', () => {
168
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
169
+
170
+ afterEach(() => {
171
+ if (originalWindow) {
172
+ Object.defineProperty(globalThis, 'window', originalWindow);
173
+ } else {
174
+ Reflect.deleteProperty(globalThis, 'window');
175
+ }
176
+ });
177
+
178
+ it('falls back to memory storage when localStorage access throws', () => {
179
+ const blockedWindow = {};
180
+ Object.defineProperty(blockedWindow, 'localStorage', {
181
+ configurable: true,
182
+ get() {
183
+ throw new DOMException('Blocked localStorage', 'SecurityError');
184
+ },
185
+ });
186
+ Object.defineProperty(globalThis, 'window', {
187
+ configurable: true,
188
+ value: blockedWindow,
189
+ });
190
+
191
+ expect(() => new AuthManager(makeMockServices() as unknown as OxyServices, {
192
+ autoRefresh: false,
193
+ crossTabSync: false,
194
+ })).not.toThrow();
195
+ });
196
+ });
197
+
167
198
  describe('AuthManager.switchAuthuser — hydration of unknown slots', () => {
168
199
  it('hydrates a slot with no prior user metadata via getCurrentUser()', async () => {
169
200
  const services = makeMockServices();
@@ -157,4 +157,79 @@ describe('HttpService CSRF behavior', () => {
157
157
  expect(headers.Authorization).toBeUndefined();
158
158
  expect(headers['X-CSRF-Token']).toBe('csrf_1');
159
159
  });
160
+
161
+ it('includes credentials for configured API origin requests', async () => {
162
+ const calls: FetchCall[] = [];
163
+ globalThis.fetch = async (input, init) => {
164
+ calls.push({ url: String(input), init });
165
+ return jsonResponse({ ok: true });
166
+ };
167
+
168
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
169
+
170
+ await http.get('/users/me');
171
+
172
+ expect(calls).toHaveLength(1);
173
+ expect(calls[0].url).toBe('https://api.oxy.so/users/me');
174
+ expect(calls[0].init?.credentials).toBe('include');
175
+ });
176
+
177
+ it('omits credentials for caller-supplied absolute URLs outside the configured API origin', async () => {
178
+ const calls: FetchCall[] = [];
179
+ globalThis.fetch = async (input, init) => {
180
+ calls.push({ url: String(input), init });
181
+ return jsonResponse({ ok: true });
182
+ };
183
+
184
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
185
+
186
+ await http.get('https://attacker.oxy.so/collect');
187
+
188
+ expect(calls).toHaveLength(1);
189
+ expect(calls[0].url).toBe('https://attacker.oxy.so/collect');
190
+ expect(calls[0].init?.credentials).toBe('omit');
191
+ });
192
+
193
+ it('bypasses request deduplication for the internal CSRF retry', async () => {
194
+ const calls: FetchCall[] = [];
195
+ let csrfFetches = 0;
196
+ let postFetches = 0;
197
+
198
+ globalThis.fetch = async (input, init) => {
199
+ const url = String(input);
200
+ calls.push({ url, init });
201
+
202
+ if (url.endsWith('/csrf-token')) {
203
+ csrfFetches += 1;
204
+ return new Response(JSON.stringify({ csrfToken: `csrf_${csrfFetches}` }), {
205
+ status: 200,
206
+ headers: { 'content-type': 'application/json' },
207
+ });
208
+ }
209
+
210
+ postFetches += 1;
211
+ if (postFetches === 1) {
212
+ return new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
213
+ status: 403,
214
+ statusText: 'Forbidden',
215
+ headers: { 'content-type': 'application/json' },
216
+ });
217
+ }
218
+
219
+ return jsonResponse({ ok: true });
220
+ };
221
+
222
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
223
+
224
+ await expect(http.post('/posts', { text: 'hello' })).resolves.toEqual({ ok: true });
225
+ expect(calls.map((call) => call.url)).toEqual([
226
+ 'https://api.mention.earth/csrf-token',
227
+ 'https://api.mention.earth/posts',
228
+ 'https://api.mention.earth/csrf-token',
229
+ 'https://api.mention.earth/posts',
230
+ ]);
231
+
232
+ expect(readHeaders(calls[1].init)['X-CSRF-Token']).toBe('csrf_1');
233
+ expect(readHeaders(calls[3].init)['X-CSRF-Token']).toBe('csrf_2');
234
+ });
160
235
  });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Canonical JSON tests.
3
+ *
4
+ * The whole point of `canonicalize` is that two structurally-equal values
5
+ * produce identical strings regardless of how their keys were ordered, so a
6
+ * client which signs and a server which verifies agree on the signing input.
7
+ * These tests pin that determinism, the array-order guarantee, the nesting
8
+ * behaviour, and the JSON value/omit semantics.
9
+ */
10
+
11
+ import { canonicalize } from '../canonicalJson';
12
+
13
+ describe('canonicalize', () => {
14
+ describe('object key ordering', () => {
15
+ it('produces identical output regardless of insertion order', () => {
16
+ const a = canonicalize({ b: 1, a: 2, c: 3 });
17
+ const b = canonicalize({ c: 3, a: 2, b: 1 });
18
+ const c = canonicalize({ a: 2, b: 1, c: 3 });
19
+ expect(a).toBe(b);
20
+ expect(b).toBe(c);
21
+ expect(a).toBe('{"a":2,"b":1,"c":3}');
22
+ });
23
+
24
+ it('sorts keys recursively at every level', () => {
25
+ const value = {
26
+ z: { y: 1, x: 2 },
27
+ a: { c: 3, b: { e: 5, d: 4 } },
28
+ };
29
+ expect(canonicalize(value)).toBe(
30
+ '{"a":{"b":{"d":4,"e":5},"c":3},"z":{"x":2,"y":1}}',
31
+ );
32
+ });
33
+
34
+ it('is order-insensitive across deep nesting', () => {
35
+ const first = canonicalize({
36
+ outer: { inner: { p: 1, q: 2 }, lead: 'x' },
37
+ meta: { issuedAt: 10, version: 1 },
38
+ });
39
+ const second = canonicalize({
40
+ meta: { version: 1, issuedAt: 10 },
41
+ outer: { lead: 'x', inner: { q: 2, p: 1 } },
42
+ });
43
+ expect(first).toBe(second);
44
+ });
45
+ });
46
+
47
+ describe('array ordering', () => {
48
+ it('preserves array element order (never sorts arrays)', () => {
49
+ expect(canonicalize([3, 1, 2])).toBe('[3,1,2]');
50
+ expect(canonicalize(['b', 'a', 'c'])).toBe('["b","a","c"]');
51
+ });
52
+
53
+ it('distinguishes arrays that differ only in order', () => {
54
+ expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
55
+ });
56
+
57
+ it('canonicalizes objects inside arrays without reordering the array', () => {
58
+ const value = [
59
+ { b: 1, a: 2 },
60
+ { d: 3, c: 4 },
61
+ ];
62
+ expect(canonicalize(value)).toBe('[{"a":2,"b":1},{"c":4,"d":3}]');
63
+ });
64
+ });
65
+
66
+ describe('primitives', () => {
67
+ it('serializes null, booleans, strings and numbers as JSON', () => {
68
+ expect(canonicalize(null)).toBe('null');
69
+ expect(canonicalize(true)).toBe('true');
70
+ expect(canonicalize(false)).toBe('false');
71
+ expect(canonicalize('hi')).toBe('"hi"');
72
+ expect(canonicalize(42)).toBe('42');
73
+ expect(canonicalize(-1.5)).toBe('-1.5');
74
+ expect(canonicalize(0)).toBe('0');
75
+ });
76
+
77
+ it('escapes strings the same way JSON does', () => {
78
+ expect(canonicalize('a"b\\c\n')).toBe(JSON.stringify('a"b\\c\n'));
79
+ });
80
+ });
81
+
82
+ describe('JSON value/omit semantics', () => {
83
+ it('omits object properties whose value is undefined', () => {
84
+ expect(canonicalize({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}');
85
+ });
86
+
87
+ it('renders undefined array elements as null (preserving length)', () => {
88
+ expect(canonicalize([1, undefined, 3])).toBe('[1,null,3]');
89
+ });
90
+
91
+ it('respects toJSON (Date and its ISO string canonicalize identically)', () => {
92
+ const date = new Date('2026-06-26T00:00:00.000Z');
93
+ expect(canonicalize(date)).toBe(JSON.stringify(date.toISOString()));
94
+ expect(canonicalize({ at: date })).toBe(
95
+ canonicalize({ at: '2026-06-26T00:00:00.000Z' }),
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('rejects values outside the JSON data model', () => {
101
+ it('throws on non-finite numbers', () => {
102
+ expect(() => canonicalize(NaN)).toThrow();
103
+ expect(() => canonicalize(Infinity)).toThrow();
104
+ expect(() => canonicalize({ x: Infinity })).toThrow();
105
+ });
106
+
107
+ it('throws on bigint', () => {
108
+ expect(() => canonicalize(BigInt(1))).toThrow();
109
+ });
110
+
111
+ it('throws on a bare undefined / function at the top level', () => {
112
+ expect(() => canonicalize(undefined)).toThrow();
113
+ expect(() => canonicalize(() => 1)).toThrow();
114
+ });
115
+ });
116
+ });
@@ -21,7 +21,7 @@ import { setPlatformOS } from '../../utils/platform';
21
21
  // Fault-injectable in-memory secure store. `failPlan` lets a test make a
22
22
  // specific (op, key) pair throw to simulate a keychain that fails mid-write or
23
23
  // is transiently locked.
24
- const failPlan: { failKey?: string; failOp?: 'set' | 'get' } = {};
24
+ const failPlan: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number } = {};
25
25
 
26
26
  jest.mock(
27
27
  'expo-secure-store',
@@ -29,6 +29,14 @@ jest.mock(
29
29
  const store = new Map<string, string>();
30
30
  const maybeFail = (op: 'set' | 'get', key: string) => {
31
31
  if (failPlan.failOp === op && failPlan.failKey === key) {
32
+ if (failPlan.failTimes !== undefined) {
33
+ failPlan.failTimes -= 1;
34
+ if (failPlan.failTimes <= 0) {
35
+ failPlan.failKey = undefined;
36
+ failPlan.failOp = undefined;
37
+ failPlan.failTimes = undefined;
38
+ }
39
+ }
32
40
  throw new Error(`Simulated ${op} failure for ${key}`);
33
41
  }
34
42
  };
@@ -51,6 +59,7 @@ jest.mock(
51
59
  store.clear();
52
60
  failPlan.failKey = undefined;
53
61
  failPlan.failOp = undefined;
62
+ failPlan.failTimes = undefined;
54
63
  },
55
64
  __getStore__: () => store,
56
65
  __failPlan__: failPlan,
@@ -92,7 +101,7 @@ jest.mock('../../utils/platformCrypto', () => ({
92
101
  interface SecureStoreTestHandle {
93
102
  __resetStore__: () => void;
94
103
  __getStore__: () => Map<string, string>;
95
- __failPlan__: { failKey?: string; failOp?: 'set' | 'get' };
104
+ __failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number };
96
105
  }
97
106
 
98
107
  describe('KeyManager atomicity & recoverability under flaky storage', () => {
@@ -146,6 +155,36 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
146
155
  expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
147
156
  });
148
157
 
158
+ it('a failed final backup refresh rejects and rolls back instead of succeeding with a stale backup', async () => {
159
+ const originalPublic = await KeyManager.createIdentity();
160
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
161
+ const originalPriv = ss.__getStore__().get('oxy_identity_private_key');
162
+ resetCaches();
163
+
164
+ // Let the new primary write and verify, then fail exactly once while
165
+ // refreshing the backup to the new identity. This used to return success
166
+ // with primary=B and backup=A, enabling a later absent-primary restore to
167
+ // silently switch back to A.
168
+ ss.__failPlan__.failOp = 'set';
169
+ ss.__failPlan__.failKey = 'oxy_identity_backup_public_key';
170
+ ss.__failPlan__.failTimes = 1;
171
+ await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
172
+
173
+ resetCaches();
174
+
175
+ // The operation failed atomically: primary and backup both still identify
176
+ // the original account, so callers cannot observe success with a stale
177
+ // cross-account backup.
178
+ expect(await KeyManager.hasIdentity()).toBe(true);
179
+ expect(await KeyManager.getPublicKey()).toBe(originalPublic);
180
+ const m = ss.__getStore__();
181
+ expect(m.get('oxy_identity_private_key')).toBe(originalPriv);
182
+ expect(m.get('oxy_identity_public_key')).toBe(originalPublic);
183
+ expect(m.get('oxy_identity_backup_private_key')).toBe(originalPriv);
184
+ expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
185
+ });
186
+
187
+
149
188
  it('restoreIdentityFromBackup does NOT clobber a healthy primary that is only transiently unreadable', async () => {
150
189
  const original = await KeyManager.createIdentity();
151
190
  const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `signChallengeWithSharedKey` tests.
3
+ *
4
+ * Verifies the shared-key challenge signer mirrors `signChallenge` exactly —
5
+ * same `auth:${publicKey}:${challenge}:${timestamp}` message format so the
6
+ * server verification path is unchanged — but sources the SHARED key from
7
+ * `KeyManager` (not the primary device key). We mock the shared key access with
8
+ * a REAL elliptic secp256k1 keypair so signing/verification is genuine.
9
+ */
10
+
11
+ import { ec as EC } from 'elliptic';
12
+ import { KeyManager } from '../keyManager';
13
+ import { SignatureService } from '../signatureService';
14
+
15
+ const ec = new EC('secp256k1');
16
+
17
+ describe('SignatureService.signChallengeWithSharedKey', () => {
18
+ const sharedKeyPair = ec.genKeyPair();
19
+ const sharedPublicKey = sharedKeyPair.getPublic('hex');
20
+ const sharedPrivateKey = sharedKeyPair.getPrivate('hex');
21
+
22
+ afterEach(() => {
23
+ jest.restoreAllMocks();
24
+ });
25
+
26
+ it('signs with the shared key and uses the unchanged message format', async () => {
27
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(sharedPublicKey);
28
+ jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(sharedPrivateKey);
29
+ // Guard: it must NOT fall back to the primary device key.
30
+ const primarySpy = jest.spyOn(KeyManager, 'getPublicKey');
31
+
32
+ const result = await SignatureService.signChallengeWithSharedKey('chal-123');
33
+
34
+ expect(result.publicKey).toBe(sharedPublicKey);
35
+ expect(typeof result.challenge).toBe('string'); // the signature
36
+ expect(typeof result.timestamp).toBe('number');
37
+ expect(primarySpy).not.toHaveBeenCalled();
38
+
39
+ // The signature verifies against the SAME message format `signChallenge`
40
+ // uses, proving the format is unchanged and the shared key signed it.
41
+ const message = `auth:${sharedPublicKey}:chal-123:${result.timestamp}`;
42
+ await expect(
43
+ SignatureService.verify(message, result.challenge, sharedPublicKey),
44
+ ).resolves.toBe(true);
45
+ });
46
+
47
+ it('throws when no shared identity exists', async () => {
48
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(null);
49
+ jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(null);
50
+
51
+ await expect(
52
+ SignatureService.signChallengeWithSharedKey('chal-123'),
53
+ ).rejects.toThrow(/No shared identity/);
54
+ });
55
+
56
+ it('throws when the shared private key is missing even if the public key is present', async () => {
57
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(sharedPublicKey);
58
+ jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(null);
59
+
60
+ await expect(
61
+ SignatureService.signChallengeWithSharedKey('chal-123'),
62
+ ).rejects.toThrow(/No shared identity/);
63
+ });
64
+ });