@oxyhq/core 12.2.1 → 12.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/aead.js +79 -0
  3. package/dist/cjs/crypto/ecdh.js +53 -0
  4. package/dist/cjs/crypto/kdf.js +39 -0
  5. package/dist/cjs/crypto/keyManager.js +7 -0
  6. package/dist/cjs/crypto/recoveryPhrase.js +89 -1
  7. package/dist/cjs/i18n/locales/en-US.json +5 -0
  8. package/dist/cjs/i18n/locales/es-ES.json +5 -0
  9. package/dist/cjs/i18n/locales/locales/en-US.json +5 -0
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +5 -0
  11. package/dist/cjs/index.js +17 -4
  12. package/dist/cjs/mixins/OxyServices.identity.js +166 -0
  13. package/dist/cjs/mixins/OxyServices.identityBackup.js +161 -0
  14. package/dist/cjs/mixins/OxyServices.user.js +43 -0
  15. package/dist/cjs/mixins/index.js +4 -0
  16. package/dist/cjs/utils/validationUtils.js +58 -21
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/crypto/aead.js +74 -0
  19. package/dist/esm/crypto/ecdh.js +51 -0
  20. package/dist/esm/crypto/kdf.js +36 -0
  21. package/dist/esm/crypto/keyManager.js +7 -0
  22. package/dist/esm/crypto/recoveryPhrase.js +88 -0
  23. package/dist/esm/i18n/locales/en-US.json +5 -0
  24. package/dist/esm/i18n/locales/es-ES.json +5 -0
  25. package/dist/esm/i18n/locales/locales/en-US.json +5 -0
  26. package/dist/esm/i18n/locales/locales/es-ES.json +5 -0
  27. package/dist/esm/index.js +5 -1
  28. package/dist/esm/mixins/OxyServices.identity.js +166 -0
  29. package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
  30. package/dist/esm/mixins/OxyServices.user.js +43 -0
  31. package/dist/esm/mixins/index.js +4 -0
  32. package/dist/esm/utils/validationUtils.js +60 -23
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/crypto/aead.d.ts +56 -0
  35. package/dist/types/crypto/ecdh.d.ts +29 -0
  36. package/dist/types/crypto/kdf.d.ts +25 -0
  37. package/dist/types/crypto/keyManager.d.ts +5 -0
  38. package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
  39. package/dist/types/index.d.ts +8 -4
  40. package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
  41. package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
  42. package/dist/types/mixins/OxyServices.user.d.ts +38 -8
  43. package/dist/types/mixins/index.d.ts +2 -1
  44. package/dist/types/utils/validationUtils.d.ts +65 -0
  45. package/package.json +4 -2
  46. package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
  47. package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
  48. package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
  49. package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
  50. package/src/crypto/aead.ts +97 -0
  51. package/src/crypto/ecdh.ts +60 -0
  52. package/src/crypto/kdf.ts +43 -0
  53. package/src/crypto/keyManager.ts +8 -0
  54. package/src/crypto/recoveryPhrase.ts +133 -0
  55. package/src/i18n/locales/en-US.json +5 -0
  56. package/src/i18n/locales/es-ES.json +5 -0
  57. package/src/index.ts +19 -1
  58. package/src/mixins/OxyServices.identity.ts +250 -0
  59. package/src/mixins/OxyServices.identityBackup.ts +237 -0
  60. package/src/mixins/OxyServices.user.ts +82 -4
  61. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
  62. package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
  63. package/src/mixins/__tests__/identityBackup.test.ts +258 -0
  64. package/src/mixins/index.ts +5 -0
  65. package/src/types/elliptic.d.ts +10 -2
  66. package/src/utils/__tests__/validationUtils.test.ts +27 -0
  67. package/src/utils/validationUtils.ts +61 -20
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `getFollowStatuses` — batched follow-status resolution tests.
3
+ *
4
+ * The SDK method collapses N per-button `getFollowStatus` calls into one bulk
5
+ * `POST /users/follow-status/bulk` per chunk. It must:
6
+ * - dedup + drop blank ids, and resolve to `{}` with NO network call on empty
7
+ * input;
8
+ * - POST `{ userIds }` uncached (`{ cache: false }`) so the UI store owns
9
+ * follow-status freshness;
10
+ * - chunk at the server cap (200) and MERGE the per-chunk `{ statuses }` maps
11
+ * into one record covering every requested id.
12
+ *
13
+ * `makeRequest` is stubbed so the tests run with no network.
14
+ */
15
+
16
+ import { OxyServices } from '../../OxyServices';
17
+
18
+ describe('OxyServices.getFollowStatuses — batched follow status', () => {
19
+ let oxy: OxyServices;
20
+ let makeRequestSpy: jest.SpyInstance;
21
+
22
+ beforeEach(() => {
23
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
24
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
25
+ });
26
+
27
+ afterEach(() => {
28
+ jest.restoreAllMocks();
29
+ });
30
+
31
+ it('returns {} and performs no network call for empty / whitespace input', async () => {
32
+ await expect(oxy.getFollowStatuses([])).resolves.toEqual({});
33
+ await expect(oxy.getFollowStatuses(['', ' '])).resolves.toEqual({});
34
+ expect(makeRequestSpy).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it('POSTs { userIds } uncached and returns the statuses map', async () => {
38
+ makeRequestSpy.mockResolvedValueOnce({ statuses: { a: true, b: false } });
39
+
40
+ const result = await oxy.getFollowStatuses(['a', 'b']);
41
+
42
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
43
+ expect(makeRequestSpy).toHaveBeenCalledWith(
44
+ 'POST',
45
+ '/users/follow-status/bulk',
46
+ { userIds: ['a', 'b'] },
47
+ { cache: false },
48
+ );
49
+ expect(result).toEqual({ a: true, b: false });
50
+ });
51
+
52
+ it('de-duplicates ids and drops blanks before the request', async () => {
53
+ makeRequestSpy.mockResolvedValueOnce({ statuses: { a: true } });
54
+
55
+ await oxy.getFollowStatuses(['a', 'a', ' ', 'a']);
56
+
57
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
58
+ expect(makeRequestSpy).toHaveBeenCalledWith(
59
+ 'POST',
60
+ '/users/follow-status/bulk',
61
+ { userIds: ['a'] },
62
+ { cache: false },
63
+ );
64
+ });
65
+
66
+ it('chunks at 200 ids per request and merges the per-chunk maps', async () => {
67
+ const ids = Array.from({ length: 450 }, (_, i) => `id-${i}`);
68
+ makeRequestSpy.mockImplementation(
69
+ async (
70
+ _method: string,
71
+ _url: string,
72
+ data?: { userIds: string[] },
73
+ ): Promise<{ statuses: Record<string, boolean> }> => {
74
+ const statuses: Record<string, boolean> = {};
75
+ for (const id of data?.userIds ?? []) {
76
+ statuses[id] = id.endsWith('0'); // deterministic mix of true/false
77
+ }
78
+ return { statuses };
79
+ },
80
+ );
81
+
82
+ const result = await oxy.getFollowStatuses(ids);
83
+
84
+ // 450 unique ids => 200 + 200 + 50 across three POSTs.
85
+ expect(makeRequestSpy).toHaveBeenCalledTimes(3);
86
+ const chunkSizes = makeRequestSpy.mock.calls.map(
87
+ (call) => (call[2] as { userIds: string[] }).userIds.length,
88
+ );
89
+ expect(chunkSizes).toEqual([200, 200, 50]);
90
+ // Every requested id is present in the merged result.
91
+ expect(Object.keys(result)).toHaveLength(450);
92
+ expect(result['id-10']).toBe(true);
93
+ expect(result['id-11']).toBe(false);
94
+ });
95
+ });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Encrypted off-device identity backup mixin (b3 Feature 1).
3
+ *
4
+ * Exercises the full client contract with a REAL crypto round-trip (only
5
+ * `KeyManager.importKeyPair` — the native SecureStore write — is mocked):
6
+ * - `createEncryptedBackup` derives the material, encrypts the identity, and
7
+ * POSTs the envelope + raw `lookupId` (the server never sees the phrase/key).
8
+ * - the uploaded envelope decrypts back to the SAME private key via
9
+ * `restoreFromEncryptedBackup` (round-trip).
10
+ * - any tamper (ciphertext / nonce / AAD-bound publicKeyHint) fails the
11
+ * Poly1305 check → restore rejects.
12
+ * - an existing on-device identity surfaces `IdentityAlreadyExistsError`
13
+ * UNCHANGED (not flattened by `handleError`), and `overwrite` forwards.
14
+ */
15
+ import { OxyServices } from '../../OxyServices';
16
+ import { KeyManager, IdentityAlreadyExistsError } from '../../crypto/keyManager';
17
+ import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
18
+ import type { EncryptedBackupEnvelope, BackupUploadRequest } from '@oxyhq/contracts';
19
+
20
+ const FIXED_PHRASE =
21
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
22
+ const EXPECTED_LOOKUP_ID = '8cad137ca961bfc62a2ef329869e8369777737c7c5353a8d94bb70d888c0ad0d';
23
+ const EXPECTED_PRIVATE_KEY = '5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1';
24
+
25
+ /** Build a non-verified JWT whose payload decodes to the given claims. */
26
+ function makeJwt(payload: Record<string, unknown>): string {
27
+ const b64url = (obj: Record<string, unknown>): string =>
28
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
29
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
30
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
31
+ }
32
+
33
+ /** A JSON `Response` returning the body verbatim (no `{ data }` wrapper). */
34
+ function plainResponse(body: unknown, status = 200): Response {
35
+ return new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { 'content-type': 'application/json' },
38
+ });
39
+ }
40
+
41
+ describe('encrypted identity backup mixin', () => {
42
+ let originalFetch: typeof globalThis.fetch;
43
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
44
+ let oxy: OxyServices;
45
+
46
+ beforeEach(() => {
47
+ originalFetch = globalThis.fetch;
48
+ fetchMock = jest.fn();
49
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
50
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
51
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
52
+ });
53
+
54
+ afterEach(() => {
55
+ globalThis.fetch = originalFetch;
56
+ jest.restoreAllMocks();
57
+ jest.clearAllMocks();
58
+ });
59
+
60
+ /** Read + parse the body of the Nth fetch call as the upload request. */
61
+ function uploadBodyFromCall(index: number): BackupUploadRequest {
62
+ const init = fetchMock.mock.calls[index][1];
63
+ return JSON.parse(String(init?.body)) as BackupUploadRequest;
64
+ }
65
+
66
+ it('createEncryptedBackup uploads an envelope + raw lookupId derived from the phrase', async () => {
67
+ fetchMock.mockResolvedValueOnce(
68
+ plainResponse({ exists: true, publicKeyHint: '04abc', createdAt: 'x' }),
69
+ );
70
+
71
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
72
+
73
+ expect(fetchMock).toHaveBeenCalledTimes(1);
74
+ const [url, init] = fetchMock.mock.calls[0];
75
+ expect(String(url)).toBe('http://test.invalid/identity/backup');
76
+ expect(init?.method).toBe('POST');
77
+
78
+ const body = uploadBodyFromCall(0);
79
+ // Envelope metadata.
80
+ expect(body.version).toBe(1);
81
+ expect(body.algorithm).toBe('xchacha20poly1305');
82
+ expect(body.kdfInfo).toBe('oxy-backup-encryption-key');
83
+ // The raw lookup id is the phrase-derived value (server will hash it).
84
+ expect(body.lookupId).toBe(EXPECTED_LOOKUP_ID);
85
+ // Hex ciphertext + 24-byte (48 hex) nonce.
86
+ expect(body.nonce).toMatch(/^[0-9a-f]{48}$/);
87
+ expect(body.ciphertext).toMatch(/^[0-9a-f]+$/);
88
+ // The hint is a 16-char prefix of the identity's real public key.
89
+ const publicKey = KeyManager.derivePublicKey(EXPECTED_PRIVATE_KEY);
90
+ expect(body.publicKeyHint).toBe(publicKey.slice(0, 16));
91
+ expect(body.publicKeyHint).toHaveLength(16);
92
+ // The plaintext private key NEVER appears on the wire.
93
+ expect(String(init?.body)).not.toContain(EXPECTED_PRIVATE_KEY);
94
+ });
95
+
96
+ it('round-trips: the uploaded envelope decrypts back to the SAME private key', async () => {
97
+ // 1) Encrypt + capture the real envelope.
98
+ fetchMock.mockResolvedValueOnce(
99
+ plainResponse({ exists: true, publicKeyHint: '04', createdAt: 'x' }),
100
+ );
101
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
102
+ const uploaded = uploadBodyFromCall(0);
103
+ const envelope: EncryptedBackupEnvelope = {
104
+ version: uploaded.version,
105
+ algorithm: uploaded.algorithm,
106
+ kdfInfo: uploaded.kdfInfo,
107
+ nonce: uploaded.nonce,
108
+ ciphertext: uploaded.ciphertext,
109
+ publicKeyHint: uploaded.publicKeyHint,
110
+ createdAt: uploaded.createdAt,
111
+ };
112
+
113
+ // 2) Restore: intercept the native SecureStore write.
114
+ const importSpy = jest
115
+ .spyOn(KeyManager, 'importKeyPair')
116
+ .mockResolvedValue('restored-public-key');
117
+ fetchMock.mockResolvedValueOnce(plainResponse(envelope));
118
+
119
+ const publicKey = await oxy.restoreFromEncryptedBackup(FIXED_PHRASE);
120
+
121
+ // The GET targets the locator; import receives the decrypted private key.
122
+ const [url, init] = fetchMock.mock.calls[1];
123
+ expect(String(url)).toBe(`http://test.invalid/identity/backup/${EXPECTED_LOOKUP_ID}`);
124
+ expect(init?.method).toBe('GET');
125
+ expect(importSpy).toHaveBeenCalledWith(EXPECTED_PRIVATE_KEY, { overwrite: false });
126
+ expect(publicKey).toBe('restored-public-key');
127
+ });
128
+
129
+ it('rejects a tampered ciphertext (Poly1305 authentication fails)', async () => {
130
+ fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
131
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
132
+ const uploaded = uploadBodyFromCall(0);
133
+
134
+ // Flip the first ciphertext byte.
135
+ const firstByte = uploaded.ciphertext.slice(0, 2);
136
+ const flipped = (Number.parseInt(firstByte, 16) ^ 0xff).toString(16).padStart(2, '0');
137
+ const tampered: EncryptedBackupEnvelope = {
138
+ version: uploaded.version,
139
+ algorithm: uploaded.algorithm,
140
+ kdfInfo: uploaded.kdfInfo,
141
+ nonce: uploaded.nonce,
142
+ ciphertext: flipped + uploaded.ciphertext.slice(2),
143
+ publicKeyHint: uploaded.publicKeyHint,
144
+ createdAt: uploaded.createdAt,
145
+ };
146
+
147
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
148
+ fetchMock.mockResolvedValueOnce(plainResponse(tampered));
149
+
150
+ await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
151
+ expect(importSpy).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('rejects when the AAD-bound publicKeyHint is altered', async () => {
155
+ fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
156
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
157
+ const uploaded = uploadBodyFromCall(0);
158
+
159
+ const tampered: EncryptedBackupEnvelope = {
160
+ version: uploaded.version,
161
+ algorithm: uploaded.algorithm,
162
+ kdfInfo: uploaded.kdfInfo,
163
+ nonce: uploaded.nonce,
164
+ ciphertext: uploaded.ciphertext,
165
+ publicKeyHint: 'deadbeefdeadbeef', // wrong hint → AAD mismatch
166
+ createdAt: uploaded.createdAt,
167
+ };
168
+
169
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
170
+ fetchMock.mockResolvedValueOnce(plainResponse(tampered));
171
+
172
+ await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
173
+ expect(importSpy).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('rejects a mismatched nonce', async () => {
177
+ fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
178
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
179
+ const uploaded = uploadBodyFromCall(0);
180
+
181
+ const tampered: EncryptedBackupEnvelope = {
182
+ version: uploaded.version,
183
+ algorithm: uploaded.algorithm,
184
+ kdfInfo: uploaded.kdfInfo,
185
+ nonce: '00'.repeat(24), // wrong nonce
186
+ ciphertext: uploaded.ciphertext,
187
+ publicKeyHint: uploaded.publicKeyHint,
188
+ createdAt: uploaded.createdAt,
189
+ };
190
+
191
+ fetchMock.mockResolvedValueOnce(plainResponse(tampered));
192
+ await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
193
+ });
194
+
195
+ it('propagates IdentityAlreadyExistsError UNCHANGED (so the caller can offer overwrite)', async () => {
196
+ fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
197
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
198
+ const uploaded = uploadBodyFromCall(0);
199
+ const envelope: EncryptedBackupEnvelope = {
200
+ version: uploaded.version,
201
+ algorithm: uploaded.algorithm,
202
+ kdfInfo: uploaded.kdfInfo,
203
+ nonce: uploaded.nonce,
204
+ ciphertext: uploaded.ciphertext,
205
+ publicKeyHint: uploaded.publicKeyHint,
206
+ createdAt: uploaded.createdAt,
207
+ };
208
+
209
+ jest
210
+ .spyOn(KeyManager, 'importKeyPair')
211
+ .mockRejectedValue(new IdentityAlreadyExistsError('04existingkey'));
212
+ fetchMock.mockResolvedValueOnce(plainResponse(envelope));
213
+
214
+ await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toBeInstanceOf(
215
+ IdentityAlreadyExistsError,
216
+ );
217
+ });
218
+
219
+ it('forwards overwrite:true to importKeyPair', async () => {
220
+ fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
221
+ await oxy.createEncryptedBackup(FIXED_PHRASE);
222
+ const uploaded = uploadBodyFromCall(0);
223
+ const envelope: EncryptedBackupEnvelope = {
224
+ version: uploaded.version,
225
+ algorithm: uploaded.algorithm,
226
+ kdfInfo: uploaded.kdfInfo,
227
+ nonce: uploaded.nonce,
228
+ ciphertext: uploaded.ciphertext,
229
+ publicKeyHint: uploaded.publicKeyHint,
230
+ createdAt: uploaded.createdAt,
231
+ };
232
+
233
+ const importSpy = jest
234
+ .spyOn(KeyManager, 'importKeyPair')
235
+ .mockResolvedValue('restored-public-key');
236
+ fetchMock.mockResolvedValueOnce(plainResponse(envelope));
237
+
238
+ await oxy.restoreFromEncryptedBackup(FIXED_PHRASE, { overwrite: true });
239
+ expect(importSpy).toHaveBeenCalledWith(EXPECTED_PRIVATE_KEY, { overwrite: true });
240
+ });
241
+
242
+ it('getBackupStatus + deleteBackup hit the right endpoints', async () => {
243
+ fetchMock.mockResolvedValueOnce(
244
+ plainResponse({ exists: true, publicKeyHint: '04abc', createdAt: 'iso' }),
245
+ );
246
+ const status = await oxy.getBackupStatus();
247
+ expect(status).toEqual({ exists: true, publicKeyHint: '04abc', createdAt: 'iso' });
248
+ let [url, init] = fetchMock.mock.calls[0];
249
+ expect(String(url)).toBe('http://test.invalid/identity/backup/status');
250
+ expect(init?.method).toBe('GET');
251
+
252
+ fetchMock.mockResolvedValueOnce(plainResponse({ success: true }));
253
+ await expect(oxy.deleteBackup()).resolves.toEqual({ success: true });
254
+ [url, init] = fetchMock.mock.calls[1];
255
+ expect(String(url)).toBe('http://test.invalid/identity/backup');
256
+ expect(init?.method).toBe('DELETE');
257
+ });
258
+ });
@@ -9,6 +9,7 @@ import { OxyServicesBase } from '../OxyServices.base';
9
9
  import { OxyServicesAuthMixin } from './OxyServices.auth';
10
10
  import { OxyServicesUserMixin } from './OxyServices.user';
11
11
  import { OxyServicesIdentityMixin } from './OxyServices.identity';
12
+ import { OxyServicesIdentityBackupMixin } from './OxyServices.identityBackup';
12
13
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
13
14
  import { OxyServicesLanguageMixin } from './OxyServices.language';
14
15
  import { OxyServicesPaymentMixin } from './OxyServices.payment';
@@ -43,6 +44,7 @@ type AllMixinInstances =
43
44
  & InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>>
44
45
  & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
45
46
  & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
47
+ & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>>
46
48
  & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
47
49
  & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
48
50
  & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
@@ -98,6 +100,9 @@ const MIXIN_PIPELINE: MixinFunction[] = [
98
100
  OxyServicesUserMixin,
99
101
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
100
102
  OxyServicesIdentityMixin,
103
+ // Encrypted off-device identity backup (b3 Feature 1): store/restore an
104
+ // encrypted copy of the self-custody key, keyed off the recovery phrase.
105
+ OxyServicesIdentityBackupMixin,
101
106
  OxyServicesPrivacyMixin,
102
107
 
103
108
  // Feature mixins
@@ -1,12 +1,20 @@
1
1
  declare module 'elliptic' {
2
+ export interface BN {
3
+ toArray(endian?: 'be' | 'le', length?: number): number[];
4
+ toString(base?: number): string;
5
+ }
6
+
2
7
  export interface KeyPair {
3
8
  getPrivate(enc?: 'hex' | 'array' | 'bn'): string | number[] | any;
4
- getPublic(enc?: 'hex'): string;
5
- getPublic(enc?: 'array'): number[];
9
+ getPublic(): Point;
10
+ getPublic(enc: 'hex'): string;
11
+ getPublic(enc: 'array'): number[];
6
12
  getPublic(compressed: boolean, enc?: 'hex'): string;
7
13
  getPublic(compressed: boolean, enc?: 'array'): number[];
8
14
  sign(msg: string | number[]): Signature;
9
15
  verify(msg: string | number[], signature: Signature | string | { r: string | number[]; s: string | number[] }): boolean;
16
+ /** ECDH: derive the shared point's x-coordinate against another party's public point. */
17
+ derive(pub: Point): BN;
10
18
  }
11
19
 
12
20
  export interface Signature {
@@ -148,6 +148,22 @@ describe('Validation Utils', () => {
148
148
  expect(isValidDisplayName('')).toBe(true); // empty is valid; non-empty enforced elsewhere
149
149
  });
150
150
 
151
+ it.each([
152
+ ['Владимир', 'Cyrillic'],
153
+ ['مُحَمَد', 'Arabic with harakat'],
154
+ ['נתן', 'Hebrew'],
155
+ ['नमस्ते', 'Devanagari'],
156
+ ['김철수', 'Hangul'],
157
+ ['Αριστοτέλης', 'Greek'],
158
+ ['Արամ', 'Armenian'],
159
+ ['დავით', 'Georgian'],
160
+ ['สมชาย', 'Thai'],
161
+ ['ᏔᎳ', 'Cherokee'],
162
+ ['ᠮᠣᠩᠭᠣᠯ', 'Mongolian'],
163
+ ])('should return true for allowlisted-script real name %p (%s)', (name) => {
164
+ expect(isValidDisplayName(name)).toBe(true);
165
+ });
166
+
151
167
  it('should return false for emoji, symbols, digits, and punctuation', () => {
152
168
  expect(isValidDisplayName('nixCraft \u{1f427}')).toBe(false); // penguin emoji
153
169
  expect(isValidDisplayName('Agent007')).toBe(false);
@@ -155,6 +171,17 @@ describe('Validation Utils', () => {
155
171
  expect(isValidDisplayName('J.R.')).toBe(false);
156
172
  });
157
173
 
174
+ it.each([
175
+ ['ᯅ', 'Batak U+1BC5 (Limited-Use script)'],
176
+ ['ᚠ', 'Runic'],
177
+ ['Miguel de Icaza ᯅ', 'a Latin name with a trailing Batak letter'],
178
+ ])('should return false for non-allowlisted-script letter %p (%s)', (name) => {
179
+ // These characters are General_Category Lo (`\p{L}`), so the old
180
+ // all-scripts policy accepted them; the curated script allowlist rejects
181
+ // decorative / limited-use scripts a real name never uses.
182
+ expect(isValidDisplayName(name)).toBe(false);
183
+ });
184
+
158
185
  it('should return false for control whitespace (tab/newline/CR)', () => {
159
186
  // \p{Zs} (space separators only) rejects layout-breaking / multi-line
160
187
  // spoofing whitespace that \s would have admitted.
@@ -43,7 +43,11 @@ export function isValidPassword(password: string): boolean {
43
43
  * Display-name character policy.
44
44
  *
45
45
  * A clean display name is composed ONLY of:
46
- * - letters of any script (`\p{L}`),
46
+ * - letters from a curated ALLOWLIST of scripts that real names use
47
+ * ({@link DISPLAY_NAME_ALLOWED_SCRIPTS}) — NOT `\p{L}` (letters of ANY
48
+ * script), which admits decorative / historic / limited-use scripts whose
49
+ * characters are `\p{L}` yet never appear in a real name (e.g. `ᯅ` U+1BC5
50
+ * Batak, Runic, Deseret, dingbat letters),
47
51
  * - combining marks / accents (`\p{M}`, e.g. the acute accent in a decomposed
48
52
  * "é"),
49
53
  * - Unicode space separators (`\p{Zs}`: the ASCII space, NBSP, ideographic
@@ -52,34 +56,71 @@ export function isValidPassword(password: string): boolean {
52
56
  * - the straight apostrophe (`'`, e.g. "O'Brien").
53
57
  *
54
58
  * Everything else is rejected: emoji (🐧), symbols (⁂ ⏚), `:emoji:` shortcodes,
55
- * digits, hyphens, dots, control whitespace (tab/newline/CR), and any other
56
- * punctuation. The allowed set `\p{L}\p{M}\p{Zs}'` explicitly EXCLUDES `<`, `>`,
57
- * `&`, and `"`, so a value that passes this predicate can never contain an
58
- * HTML/XSS vector.
59
+ * digits, hyphens, dots, control whitespace (tab/newline/CR), letters from
60
+ * non-allowlisted scripts, and any other punctuation. The allowed set never
61
+ * includes `<`, `>`, `&`, or `"`, so a value that passes this predicate can
62
+ * never contain an HTML/XSS vector.
59
63
  *
60
- * This is the SINGLE definition of the policy, shared between the API 400-gate
61
- * (`@oxyhq/api` `displayNameSanitize.ts`) and client-side inline validation
62
- * (the RN profile editor) so the two can never drift. It is platform-agnostic
64
+ * The allowlist is expressed with Unicode Script_Extensions (`\p{scx=…}`)
65
+ * escapes so a letter shared by several scripts (e.g. a Han ideograph used in
66
+ * both Chinese and Japanese) still matches. It is the set of scripts Unicode
67
+ * UTS #39 marks "Recommended" for general interchange / identifiers, plus
68
+ * Cherokee and Mongolian (both in real modern name use). "Common" script is
69
+ * deliberately EXCLUDED — that is where ASCII digits and general punctuation
70
+ * live, and this policy excludes those; the space separators, combining marks,
71
+ * and apostrophe a name needs are added back explicitly. Limited-use / excluded
72
+ * / historic scripts (Batak, Runic, Deseret, Adlam, …) are simply absent.
73
+ *
74
+ * This is the SINGLE definition of the policy: the character-class sources below
75
+ * are the ONE source of truth, shared between the API strip/gate
76
+ * (`@oxyhq/api` `displayNameSanitize.ts` builds its global-flag patterns from
77
+ * them) and client-side inline validation (the RN profile editor via
78
+ * {@link isValidDisplayName}) so the two can never drift. It is platform-agnostic
63
79
  * (no react/react-native/expo).
64
80
  */
65
81
 
66
82
  /**
67
- * Single test for the presence of a disallowed character (non-global). The
68
- * whitespace class is `\p{Zs}` (space separators only), NOT `\s` the latter
69
- * would admit tab/newline/carriage return, which break layout and enable
70
- * multi-line spoofing.
83
+ * The curated allowlist of Unicode scripts permitted in a display name, as a
84
+ * character-class body of Script_Extensions (`scx`) property escapes. Ordered by
85
+ * rough script family for readability; order has no semantic effect.
86
+ */
87
+ export const DISPLAY_NAME_ALLOWED_SCRIPTS =
88
+ '\\p{scx=Latin}\\p{scx=Greek}\\p{scx=Cyrillic}\\p{scx=Armenian}' +
89
+ '\\p{scx=Hebrew}\\p{scx=Arabic}\\p{scx=Thaana}\\p{scx=Devanagari}' +
90
+ '\\p{scx=Bengali}\\p{scx=Gurmukhi}\\p{scx=Gujarati}\\p{scx=Oriya}' +
91
+ '\\p{scx=Tamil}\\p{scx=Telugu}\\p{scx=Kannada}\\p{scx=Malayalam}' +
92
+ '\\p{scx=Sinhala}\\p{scx=Thai}\\p{scx=Lao}\\p{scx=Tibetan}' +
93
+ '\\p{scx=Myanmar}\\p{scx=Georgian}\\p{scx=Hangul}\\p{scx=Ethiopic}' +
94
+ '\\p{scx=Cherokee}\\p{scx=Khmer}\\p{scx=Mongolian}\\p{scx=Hiragana}' +
95
+ '\\p{scx=Katakana}\\p{scx=Bopomofo}\\p{scx=Han}';
96
+
97
+ /**
98
+ * Source of the disallowed-character pattern: the negation of the full allowed
99
+ * set (allowlisted scripts + combining marks `\p{M}` + space separators `\p{Zs}`
100
+ * + the straight apostrophe). Consumers compile this with the `u` flag (and `g`
101
+ * for a global strip). The whitespace class is `\p{Zs}` (space separators only),
102
+ * NOT `\s` — the latter would admit tab/newline/carriage return, which break
103
+ * layout and enable multi-line spoofing.
71
104
  */
72
- const DISALLOWED_PROBE = /[^\p{L}\p{M}\p{Zs}']/u;
105
+ export const DISPLAY_NAME_DISALLOWED_SOURCE = `[^${DISPLAY_NAME_ALLOWED_SCRIPTS}\\p{M}\\p{Zs}']`;
73
106
 
74
107
  /**
75
- * Single test for the presence of an orphaned combining mark (non-global) a
76
- * `\p{M}` not attached to a base letter (string start, whitespace, the
77
- * apostrophe, or a position vacated by a stripped character). A mark preceded by
78
- * `\p{L}` (a base letter, e.g. the decomposed accent in "Renée") or by another
79
- * `\p{M}` (a multi-mark cluster) is NOT matched because the negative lookbehind
80
- * fails at its position.
108
+ * Source of the orphaned combining-mark pattern: a run of `\p{M}` NOT attached
109
+ * to a base letter (preceded by string start, whitespace, the apostrophe, or a
110
+ * position vacated by a stripped character). A mark preceded by `\p{L}` (a base
111
+ * letter, e.g. the decomposed accent in "Renée") or by another `\p{M}` (a
112
+ * multi-mark cluster) is NOT matched because the negative lookbehind fails at its
113
+ * position. Used both as a non-global probe (`.test`) and, with the `g` flag, to
114
+ * strip whole orphaned runs. The lookbehind intentionally still uses the broad
115
+ * `\p{L}` so that a mark riding on an allowlisted base letter is preserved.
81
116
  */
82
- const ORPHANED_MARK_PROBE = /(?<![\p{L}\p{M}])\p{M}/u;
117
+ export const DISPLAY_NAME_ORPHANED_MARK_SOURCE = '(?<![\\p{L}\\p{M}])\\p{M}+';
118
+
119
+ /** Non-global probe for the presence of a disallowed character. */
120
+ const DISALLOWED_PROBE = new RegExp(DISPLAY_NAME_DISALLOWED_SOURCE, 'u');
121
+
122
+ /** Non-global probe for the presence of an orphaned combining mark. */
123
+ const ORPHANED_MARK_PROBE = new RegExp(DISPLAY_NAME_ORPHANED_MARK_SOURCE, 'u');
83
124
 
84
125
  /**
85
126
  * Whether `raw` already satisfies the display-name policy, i.e. it contains no