@oxyhq/core 12.2.0 → 12.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.
Files changed (107) 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/ar-SA.json +2 -0
  8. package/dist/cjs/i18n/locales/ca-ES.json +2 -0
  9. package/dist/cjs/i18n/locales/de-DE.json +2 -0
  10. package/dist/cjs/i18n/locales/en-US.json +7 -0
  11. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  12. package/dist/cjs/i18n/locales/fr-FR.json +2 -0
  13. package/dist/cjs/i18n/locales/it-IT.json +2 -0
  14. package/dist/cjs/i18n/locales/ja-JP.json +2 -0
  15. package/dist/cjs/i18n/locales/ko-KR.json +2 -0
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +2 -0
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +2 -0
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +2 -0
  19. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +2 -0
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +2 -0
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +2 -0
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +2 -0
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +2 -0
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +2 -0
  27. package/dist/cjs/i18n/locales/pt-PT.json +2 -0
  28. package/dist/cjs/i18n/locales/zh-CN.json +2 -0
  29. package/dist/cjs/index.js +14 -4
  30. package/dist/cjs/mixins/OxyServices.identity.js +166 -0
  31. package/dist/cjs/mixins/OxyServices.identityBackup.js +161 -0
  32. package/dist/cjs/mixins/OxyServices.user.js +43 -0
  33. package/dist/cjs/mixins/index.js +4 -0
  34. package/dist/esm/.tsbuildinfo +1 -1
  35. package/dist/esm/crypto/aead.js +74 -0
  36. package/dist/esm/crypto/ecdh.js +51 -0
  37. package/dist/esm/crypto/kdf.js +36 -0
  38. package/dist/esm/crypto/keyManager.js +7 -0
  39. package/dist/esm/crypto/recoveryPhrase.js +88 -0
  40. package/dist/esm/i18n/locales/ar-SA.json +2 -0
  41. package/dist/esm/i18n/locales/ca-ES.json +2 -0
  42. package/dist/esm/i18n/locales/de-DE.json +2 -0
  43. package/dist/esm/i18n/locales/en-US.json +7 -0
  44. package/dist/esm/i18n/locales/es-ES.json +7 -0
  45. package/dist/esm/i18n/locales/fr-FR.json +2 -0
  46. package/dist/esm/i18n/locales/it-IT.json +2 -0
  47. package/dist/esm/i18n/locales/ja-JP.json +2 -0
  48. package/dist/esm/i18n/locales/ko-KR.json +2 -0
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +2 -0
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +2 -0
  51. package/dist/esm/i18n/locales/locales/de-DE.json +2 -0
  52. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  53. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +2 -0
  55. package/dist/esm/i18n/locales/locales/it-IT.json +2 -0
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +2 -0
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +2 -0
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +2 -0
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +2 -0
  60. package/dist/esm/i18n/locales/pt-PT.json +2 -0
  61. package/dist/esm/i18n/locales/zh-CN.json +2 -0
  62. package/dist/esm/index.js +4 -0
  63. package/dist/esm/mixins/OxyServices.identity.js +166 -0
  64. package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
  65. package/dist/esm/mixins/OxyServices.user.js +43 -0
  66. package/dist/esm/mixins/index.js +4 -0
  67. package/dist/types/.tsbuildinfo +1 -1
  68. package/dist/types/crypto/aead.d.ts +56 -0
  69. package/dist/types/crypto/ecdh.d.ts +29 -0
  70. package/dist/types/crypto/kdf.d.ts +25 -0
  71. package/dist/types/crypto/keyManager.d.ts +5 -0
  72. package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
  73. package/dist/types/index.d.ts +7 -3
  74. package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
  75. package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
  76. package/dist/types/mixins/OxyServices.user.d.ts +38 -8
  77. package/dist/types/mixins/index.d.ts +2 -1
  78. package/package.json +4 -2
  79. package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
  80. package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
  81. package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
  82. package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
  83. package/src/crypto/aead.ts +97 -0
  84. package/src/crypto/ecdh.ts +60 -0
  85. package/src/crypto/kdf.ts +43 -0
  86. package/src/crypto/keyManager.ts +8 -0
  87. package/src/crypto/recoveryPhrase.ts +133 -0
  88. package/src/i18n/locales/ar-SA.json +2 -0
  89. package/src/i18n/locales/ca-ES.json +2 -0
  90. package/src/i18n/locales/de-DE.json +2 -0
  91. package/src/i18n/locales/en-US.json +7 -0
  92. package/src/i18n/locales/es-ES.json +7 -0
  93. package/src/i18n/locales/fr-FR.json +2 -0
  94. package/src/i18n/locales/it-IT.json +2 -0
  95. package/src/i18n/locales/ja-JP.json +2 -0
  96. package/src/i18n/locales/ko-KR.json +2 -0
  97. package/src/i18n/locales/pt-PT.json +2 -0
  98. package/src/i18n/locales/zh-CN.json +2 -0
  99. package/src/index.ts +16 -1
  100. package/src/mixins/OxyServices.identity.ts +250 -0
  101. package/src/mixins/OxyServices.identityBackup.ts +237 -0
  102. package/src/mixins/OxyServices.user.ts +82 -4
  103. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
  104. package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
  105. package/src/mixins/__tests__/identityBackup.test.ts +258 -0
  106. package/src/mixins/index.ts +5 -0
  107. package/src/types/elliptic.d.ts +10 -2
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Encrypted off-device identity backup mixin (b3 Feature 1).
3
+ *
4
+ * Lets a self-custody identity store an ENCRYPTED copy of its key off-device so
5
+ * a lost/wiped device can be recovered from the recovery phrase alone — while the
6
+ * platform never sees the phrase, the derived key, or the plaintext private key.
7
+ *
8
+ * Key schedule (from the recovery phrase; see
9
+ * {@link RecoveryPhraseService.deriveBackupMaterial}):
10
+ * seed = bip39.mnemonicToSeed(phrase) // 64 bytes, UNCHANGED
11
+ * backupKey = HKDF(seed, 'oxy-identity-backup-v1', 'oxy-backup-encryption-key')
12
+ * lookupId = HKDF(seed, 'oxy-identity-backup-v1', 'oxy-backup-lookup-id') // hex
13
+ *
14
+ * The two derivations require the FULL seed, so a device compromise that leaks
15
+ * only the raw 32-byte private key can neither locate nor decrypt the backup.
16
+ *
17
+ * Wire shapes come from `@oxyhq/contracts` (`EncryptedBackupEnvelope`,
18
+ * `BackupUploadRequest`, `BackupStatusResponse`) — the API validates its
19
+ * request/response against the same schemas, so producer and consumer cannot
20
+ * drift.
21
+ *
22
+ * Encryption/derivation are cross-platform (pure `@noble/*`), but persisting a
23
+ * restored key is NATIVE-ONLY: `restoreFromEncryptedBackup` ends in
24
+ * `KeyManager.importKeyPair`, which throws on web (SecureStore does not exist
25
+ * there) — decryption still succeeds, only the local write is native-only.
26
+ */
27
+ import type {
28
+ BackupStatusResponse,
29
+ BackupUploadRequest,
30
+ EncryptedBackupEnvelope,
31
+ } from '@oxyhq/contracts';
32
+ import type { OxyServicesBase } from '../OxyServices.base';
33
+ import { KeyManager, IdentityAlreadyExistsError } from '../crypto/keyManager';
34
+ import { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from '../crypto/recoveryPhrase';
35
+ import { encryptAead, decryptAead } from '../crypto/aead';
36
+
37
+ /** Envelope/KDF version — bump only on a breaking scheme change. */
38
+ const BACKUP_ENVELOPE_VERSION = 1;
39
+ /** The AEAD the backup is sealed with. Pinned so a mismatched decryptor fails loudly. */
40
+ const BACKUP_ALGORITHM = 'xchacha20poly1305' as const;
41
+ /**
42
+ * Length (hex chars) of the public-key HINT stored/echoed with a backup — enough
43
+ * to let the owner recognise WHICH identity a backup belongs to, but only a
44
+ * prefix (the full key is public anyway; a prefix keeps the record minimal).
45
+ */
46
+ const PUBLIC_KEY_HINT_LENGTH = 16;
47
+
48
+ /** The decrypted backup payload. */
49
+ interface BackupPayload {
50
+ privateKey: string;
51
+ publicKey: string;
52
+ createdAt: string;
53
+ }
54
+
55
+ /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
56
+ function toHex(bytes: Uint8Array): string {
57
+ let out = '';
58
+ for (let i = 0; i < bytes.length; i += 1) {
59
+ out += bytes[i].toString(16).padStart(2, '0');
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Decode a lowercase/uppercase hex string to bytes. Throws on malformed input. */
65
+ function fromHex(hex: string): Uint8Array {
66
+ if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) {
67
+ throw new Error('Malformed hex in encrypted backup envelope.');
68
+ }
69
+ const out = new Uint8Array(hex.length / 2);
70
+ for (let i = 0; i < out.length; i += 1) {
71
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * The AEAD associated data binds the ciphertext to its `{version, publicKeyHint}`
78
+ * context: the exact bytes must be reproduced at decrypt time, so a mismatched
79
+ * version or hint (e.g. an envelope re-stamped by a tamperer) fails the
80
+ * Poly1305 check. Deterministic: both sides build the SAME object literal, so
81
+ * `JSON.stringify` yields identical bytes.
82
+ */
83
+ function buildBackupAad(version: number, publicKeyHint: string): Uint8Array {
84
+ return new TextEncoder().encode(JSON.stringify({ version, publicKeyHint }));
85
+ }
86
+
87
+ export function OxyServicesIdentityBackupMixin<T extends typeof OxyServicesBase>(Base: T) {
88
+ return class extends Base {
89
+ constructor(...args: any[]) {
90
+ super(...(args as [any]));
91
+ }
92
+
93
+ /**
94
+ * Derive the backup key material from the recovery phrase, encrypt the
95
+ * identity's `{privateKey, publicKey, createdAt}` with it, and upload the
96
+ * ciphertext + raw `lookupId` (`POST /identity/backup`, bearer). The server
97
+ * stores only `sha256(lookupId)` + the ciphertext. Idempotent per user: a
98
+ * re-upload REPLACES the prior backup (upsert by user id).
99
+ *
100
+ * The identity is derived from the PHRASE (not read from SecureStore), so
101
+ * this works cross-platform and does not require an on-device key.
102
+ *
103
+ * @param phrase - The identity's BIP-39 recovery phrase.
104
+ * @returns The post-write backup status (`{ exists: true, publicKeyHint, createdAt }`).
105
+ */
106
+ async createEncryptedBackup(phrase: string): Promise<BackupStatusResponse> {
107
+ try {
108
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
109
+ const privateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
110
+ const publicKey = KeyManager.derivePublicKey(privateKey);
111
+ const createdAt = new Date().toISOString();
112
+ const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
113
+
114
+ const payload: BackupPayload = { privateKey, publicKey, createdAt };
115
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
116
+ const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
117
+ const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
118
+
119
+ const body: BackupUploadRequest = {
120
+ version: BACKUP_ENVELOPE_VERSION,
121
+ algorithm: BACKUP_ALGORITHM,
122
+ kdfInfo: BACKUP_KDF_ENCRYPTION_INFO,
123
+ nonce: toHex(nonce),
124
+ ciphertext: toHex(ciphertext),
125
+ publicKeyHint,
126
+ createdAt,
127
+ lookupId,
128
+ };
129
+
130
+ return await this.makeRequest<BackupStatusResponse>(
131
+ 'POST',
132
+ '/identity/backup',
133
+ body,
134
+ { cache: false },
135
+ );
136
+ } catch (error) {
137
+ throw this.handleError(error);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Whether the authenticated user has a stored encrypted backup, plus the
143
+ * non-sensitive hint + timestamp when one exists (`GET /identity/backup/status`,
144
+ * bearer). Returns no ciphertext and no locator.
145
+ */
146
+ async getBackupStatus(): Promise<BackupStatusResponse> {
147
+ try {
148
+ return await this.makeRequest<BackupStatusResponse>(
149
+ 'GET',
150
+ '/identity/backup/status',
151
+ undefined,
152
+ { cache: false },
153
+ );
154
+ } catch (error) {
155
+ throw this.handleError(error);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Delete the authenticated user's stored backup (`DELETE /identity/backup`,
161
+ * bearer). Idempotent — deleting a non-existent backup still succeeds.
162
+ */
163
+ async deleteBackup(): Promise<{ success: boolean }> {
164
+ try {
165
+ return await this.makeRequest<{ success: boolean }>(
166
+ 'DELETE',
167
+ '/identity/backup',
168
+ undefined,
169
+ { cache: false },
170
+ );
171
+ } catch (error) {
172
+ throw this.handleError(error);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Restore an identity from its encrypted off-device backup using ONLY the
178
+ * recovery phrase: re-derive `{backupKey, lookupId}`, fetch the envelope by
179
+ * `lookupId` (`GET /identity/backup/:lookupId`, PUBLIC — the 256-bit locator
180
+ * is the protection), decrypt + authenticate locally, then persist the key.
181
+ *
182
+ * NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
183
+ * refuses to clobber a DIFFERENT existing on-device identity unless
184
+ * `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
185
+ * propagates to the caller (never swallowed) so the UI can confirm before
186
+ * overwriting.
187
+ *
188
+ * @param phrase - The identity's BIP-39 recovery phrase.
189
+ * @param options.overwrite - Replace a different existing on-device identity.
190
+ * @returns The restored identity's public key.
191
+ * @throws if the phrase is invalid, no backup exists (404), the ciphertext
192
+ * fails authentication (tamper), or an existing identity blocks the import.
193
+ */
194
+ async restoreFromEncryptedBackup(
195
+ phrase: string,
196
+ options?: { overwrite?: boolean },
197
+ ): Promise<string> {
198
+ try {
199
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
200
+
201
+ const envelope = await this.makeRequest<EncryptedBackupEnvelope>(
202
+ 'GET',
203
+ `/identity/backup/${encodeURIComponent(lookupId)}`,
204
+ undefined,
205
+ { cache: false },
206
+ );
207
+
208
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
209
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
210
+ }
211
+
212
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
213
+ const plaintext = decryptAead(
214
+ backupKey,
215
+ fromHex(envelope.nonce),
216
+ fromHex(envelope.ciphertext),
217
+ aad,
218
+ );
219
+ const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
220
+
221
+ // Persist the recovered key. Native-only; refuses to clobber a different
222
+ // identity unless overwrite — the IdentityAlreadyExistsError propagates.
223
+ return await KeyManager.importKeyPair(payload.privateKey, {
224
+ overwrite: options?.overwrite === true,
225
+ });
226
+ } catch (error) {
227
+ // Preserve the typed "an identity already exists" signal so the caller
228
+ // can prompt for overwrite. `handleError` would flatten it to a generic
229
+ // Error and lose that discrimination.
230
+ if (error instanceof IdentityAlreadyExistsError) {
231
+ throw error;
232
+ }
233
+ throw this.handleError(error);
234
+ }
235
+ }
236
+ };
237
+ }
@@ -31,6 +31,33 @@ import { extractErrorStatus } from '../utils/errorUtils';
31
31
  */
32
32
  const USERS_BY_IDS_CHUNK_SIZE = 100;
33
33
 
34
+ /**
35
+ * Maximum number of ids sent per `POST /users/follow-status/bulk` request.
36
+ * Matches the server-side `MAX_BULK_FOLLOW` cap; larger inputs are split into
37
+ * multiple chunked calls whose result maps are merged.
38
+ */
39
+ const FOLLOW_STATUS_CHUNK_SIZE = 200;
40
+
41
+ /**
42
+ * Response of the single follow/unfollow toggle route
43
+ * (`POST /users/:id/follow` and `DELETE /users/:id/follow`). The route reports a
44
+ * status message, which side of the toggle was applied, and the post-write
45
+ * follower/following counts for the affected users.
46
+ */
47
+ export interface FollowMutationResult {
48
+ /** Human-readable status message. */
49
+ message: string;
50
+ /** Which side of the toggle was applied, when reported by the route. */
51
+ action?: 'follow' | 'unfollow';
52
+ /** Post-write counts, when reported by the route. */
53
+ counts?: {
54
+ /** The target user's follower count after the write. */
55
+ followers: number;
56
+ /** The viewer's following count after the write. */
57
+ following: number;
58
+ };
59
+ }
60
+
34
61
  /** Per-user outcome returned by `POST /users/follow/bulk`. */
35
62
  export interface BulkFollowEntry {
36
63
  /** The user ID that was processed. */
@@ -679,9 +706,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
679
706
  * UI (the "follow resets after navigating away and back" bug).
680
707
  * `clearCacheEntry` deletes every identity-scoped variant of the key.
681
708
  */
682
- async followUser(userId: string): Promise<{ success: boolean; message: string }> {
709
+ async followUser(userId: string): Promise<FollowMutationResult> {
683
710
  try {
684
- const result = await this.makeRequest<{ success: boolean; message: string }>('POST', `/users/${userId}/follow`, undefined, { cache: false });
711
+ const result = await this.makeRequest<FollowMutationResult>('POST', `/users/${userId}/follow`, undefined, { cache: false });
685
712
  this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
686
713
  // The follow changed the viewer's graph — bust the cached consolidated
687
714
  // `GET /users/me/graph` so the next read reflects the new following/
@@ -748,9 +775,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
748
775
  /**
749
776
  * Unfollow a user
750
777
  */
751
- async unfollowUser(userId: string): Promise<{ success: boolean; message: string }> {
778
+ async unfollowUser(userId: string): Promise<FollowMutationResult> {
752
779
  try {
753
- const result = await this.makeRequest<{ success: boolean; message: string }>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
780
+ const result = await this.makeRequest<FollowMutationResult>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
754
781
  // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
755
782
  this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
756
783
  // The unfollow changed the viewer's graph — bust the consolidated cache.
@@ -775,6 +802,57 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
775
802
  }
776
803
  }
777
804
 
805
+ /**
806
+ * Resolve the viewer's follow status for MANY users in one round-trip per
807
+ * chunk. Built for list UIs (a page of `FollowButton`s) that would otherwise
808
+ * fire one `getFollowStatus` per button (the classic N+1).
809
+ *
810
+ * Ids are deduplicated and validated (empty/blank ids dropped), split into
811
+ * chunks of {@link FOLLOW_STATUS_CHUNK_SIZE} (the server's bulk cap), and
812
+ * POSTed to `/users/follow-status/bulk` as `{ userIds }`. The per-chunk
813
+ * `{ statuses }` maps are merged into one `Record<string, boolean>` covering
814
+ * every requested id — ids the viewer does not follow come back `false`.
815
+ *
816
+ * Uncached (`{ cache: false }`): the UI store owns follow-status freshness
817
+ * and writes optimistically on every mutation, so an SDK cache here would
818
+ * serve a stale status right after a follow/unfollow. An empty/whitespace-
819
+ * only input resolves immediately with `{}` and performs no network call.
820
+ */
821
+ async getFollowStatuses(userIds: string[]): Promise<Record<string, boolean>> {
822
+ const uniqueIds = Array.from(
823
+ new Set(userIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
824
+ );
825
+ if (uniqueIds.length === 0) {
826
+ return {};
827
+ }
828
+
829
+ const chunks: string[][] = [];
830
+ for (let i = 0; i < uniqueIds.length; i += FOLLOW_STATUS_CHUNK_SIZE) {
831
+ chunks.push(uniqueIds.slice(i, i + FOLLOW_STATUS_CHUNK_SIZE));
832
+ }
833
+
834
+ try {
835
+ const responses = await Promise.all(
836
+ chunks.map((chunk) =>
837
+ this.makeRequest<{ statuses: Record<string, boolean> }>(
838
+ 'POST',
839
+ '/users/follow-status/bulk',
840
+ { userIds: chunk },
841
+ { cache: false },
842
+ ),
843
+ ),
844
+ );
845
+
846
+ const merged: Record<string, boolean> = {};
847
+ for (const response of responses) {
848
+ Object.assign(merged, response?.statuses ?? {});
849
+ }
850
+ return merged;
851
+ } catch (error) {
852
+ throw this.handleError(error);
853
+ }
854
+ }
855
+
778
856
  /**
779
857
  * Get user followers
780
858
  */
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Key-rotation mixin tests (b3 Feature 3).
3
+ *
4
+ * Cover the client orchestration of `oxy.identity.rotateKey(...)`:
5
+ * - the EXACT signed rotation payload (must match the server's reconstruction
6
+ * byte-for-byte);
7
+ * - device-proof signs with the on-device key; phrase-proof re-derives and
8
+ * signs with the OLD key (proving the last credential can be replaced);
9
+ * - BOTH proofs are sent: the OLD key signs `rotate_key`, the NEW key signs the
10
+ * `rotate_key_new` proof-of-possession;
11
+ * - the safety ordering (local key persisted ONLY after server confirmation);
12
+ * - the local-persist failure path still surfaces the new phrase;
13
+ * - the ambiguous-network-failure reconciliation against the DID document.
14
+ *
15
+ * `makeRequest` is stubbed so the tests run with no network. The real
16
+ * secp256k1 signing runs in the phrase-proof test so we can cryptographically
17
+ * assert which key produced each signature.
18
+ */
19
+
20
+ import type { DidDocument } from '@oxyhq/contracts';
21
+ import * as protocol from '@oxyhq/protocol';
22
+ import { ec as EC } from 'elliptic';
23
+ import { OxyServices } from '../../OxyServices';
24
+ import { KeyManager } from '../../crypto/keyManager';
25
+ import { SignatureService } from '../../crypto/signatureService';
26
+ import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
27
+ import { setPlatformOS } from '../../utils/platform';
28
+
29
+ const ec = new EC('secp256k1');
30
+ const oldKeyPair = ec.genKeyPair();
31
+ const newKeyPair = ec.genKeyPair();
32
+ const OLD_PUBLIC = oldKeyPair.getPublic('hex');
33
+ const NEW_PUBLIC = newKeyPair.getPublic('hex');
34
+ const NEW_PRIVATE = newKeyPair.getPrivate('hex');
35
+
36
+ const pendingFixture = {
37
+ phrase: 'alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima',
38
+ words: ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima'],
39
+ privateKey: NEW_PRIVATE,
40
+ publicKey: NEW_PUBLIC,
41
+ };
42
+
43
+ function didDocWithKey(publicKeyHex: string): DidDocument {
44
+ return {
45
+ '@context': ['https://www.w3.org/ns/did/v1'],
46
+ id: 'did:web:oxy.so:u:user-123',
47
+ controller: ['did:web:oxy.so:u:user-123', 'did:web:oxy.so'],
48
+ verificationMethod: [
49
+ {
50
+ id: 'did:web:oxy.so:u:user-123#key-1',
51
+ type: 'EcdsaSecp256k1VerificationKey2019',
52
+ controller: 'did:web:oxy.so:u:user-123',
53
+ publicKeyHex,
54
+ },
55
+ ],
56
+ authentication: ['did:web:oxy.so:u:user-123#key-1'],
57
+ assertionMethod: ['did:web:oxy.so:u:user-123#key-1'],
58
+ alsoKnownAs: [],
59
+ service: [],
60
+ };
61
+ }
62
+
63
+ describe('OxyServices.rotateKey', () => {
64
+ let oxy: OxyServices;
65
+ let makeRequestSpy: jest.SpyInstance;
66
+ let clearEntrySpy: jest.SpyInstance;
67
+
68
+ beforeEach(() => {
69
+ setPlatformOS('ios'); // native → the new key is persisted locally after success
70
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
71
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
72
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
73
+ clearEntrySpy = jest.spyOn(oxy, 'clearCacheEntry').mockReturnValue(undefined);
74
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue('user-123');
75
+ });
76
+
77
+ afterEach(() => {
78
+ jest.restoreAllMocks();
79
+ });
80
+
81
+ describe('device proof', () => {
82
+ it('signs the exact rotation payload, POSTs challenge→complete, then persists the new key locally', async () => {
83
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
84
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
85
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
86
+ const signSpy = jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
87
+ // The new-key proof is signed with the pending private key via protocol.signMessage.
88
+ const newKeySignSpy = jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
89
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
90
+ makeRequestSpy
91
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
92
+ .mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
93
+
94
+ const result = await oxy.rotateKey({ proof: 'device' });
95
+
96
+ expect(result).toEqual({ newPublicKey: NEW_PUBLIC, newPhrase: pendingFixture.phrase, words: pendingFixture.words });
97
+
98
+ // The OLD-key signed message MUST match the server's reconstruction byte-for-byte.
99
+ const expectedMessage = JSON.stringify({
100
+ action: 'rotate_key',
101
+ userId: 'user-123',
102
+ oldPublicKey: KeyManager.canonicalPublicKey(OLD_PUBLIC),
103
+ newPublicKey: NEW_PUBLIC,
104
+ challenge: 'chal-1',
105
+ timestamp: 1700000000000,
106
+ });
107
+ expect(signSpy).toHaveBeenCalledWith(expectedMessage);
108
+ // The NEW-key proof is signed with the pending private key over rotate_key_new.
109
+ const expectedNewKeyMessage = JSON.stringify({
110
+ action: 'rotate_key_new',
111
+ userId: 'user-123',
112
+ newPublicKey: NEW_PUBLIC,
113
+ challenge: 'chal-1',
114
+ timestamp: 1700000000000,
115
+ });
116
+ expect(newKeySignSpy).toHaveBeenCalledWith(expectedNewKeyMessage, NEW_PRIVATE);
117
+
118
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(1, 'POST', '/auth/rotate/challenge', undefined, expect.objectContaining({ cache: false }));
119
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
120
+ 2,
121
+ 'POST',
122
+ '/auth/rotate/complete',
123
+ { newPublicKey: NEW_PUBLIC, challenge: 'chal-1', signature: 'sig-hex', newKeyProof: 'newkeyproof-hex', timestamp: 1700000000000 },
124
+ expect.objectContaining({ cache: false }),
125
+ );
126
+
127
+ // Persisted ONLY after the server confirmed the swap.
128
+ expect(importSpy).toHaveBeenCalledWith(NEW_PRIVATE, { overwrite: true });
129
+ // DID + auth-method caches are swept.
130
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
131
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
132
+ });
133
+
134
+ it('forwards signOutEverywhere in the complete body', async () => {
135
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
136
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
137
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
138
+ jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
139
+ jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
140
+ makeRequestSpy
141
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
142
+ .mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
143
+
144
+ await oxy.rotateKey({ proof: 'device', signOutEverywhere: true });
145
+
146
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
147
+ 2,
148
+ 'POST',
149
+ '/auth/rotate/complete',
150
+ expect.objectContaining({ signOutEverywhere: true, newKeyProof: 'newkeyproof-hex' }),
151
+ expect.anything(),
152
+ );
153
+ });
154
+
155
+ it('does NOT throw and surfaces the new phrase when the local key persist fails after a server rotation', async () => {
156
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
157
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
158
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
159
+ jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
160
+ // The server rotated successfully, but the on-device persist fails.
161
+ jest.spyOn(KeyManager, 'importKeyPair').mockRejectedValue(new Error('secure store write failed'));
162
+ makeRequestSpy
163
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
164
+ .mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
165
+
166
+ const result = await oxy.rotateKey({ proof: 'device' });
167
+
168
+ // The phrase for the now-live key is surfaced, with a failure flag — never lost.
169
+ expect(result).toEqual({
170
+ newPublicKey: NEW_PUBLIC,
171
+ newPhrase: pendingFixture.phrase,
172
+ words: pendingFixture.words,
173
+ localPersistFailed: true,
174
+ });
175
+ // Caches are still swept (the server key is the new one).
176
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
177
+ });
178
+
179
+ it('throws (no network) when the device holds no identity', async () => {
180
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
181
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
182
+
183
+ await expect(oxy.rotateKey({ proof: 'device' })).rejects.toThrow(/No on-device identity/);
184
+ expect(makeRequestSpy).not.toHaveBeenCalled();
185
+ });
186
+ });
187
+
188
+ describe('phrase proof (replace the LAST credential)', () => {
189
+ it('re-derives the CURRENT key from the entered phrase and signs the proof with it', async () => {
190
+ // A real "current" identity whose phrase the user re-enters, and a real new one.
191
+ const current = await RecoveryPhraseService.derivePendingIdentity();
192
+ const next = await RecoveryPhraseService.derivePendingIdentity();
193
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(next);
194
+ jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(next.publicKey);
195
+
196
+ let completeBody: Record<string, unknown> | undefined;
197
+ makeRequestSpy.mockImplementation((_m: string, path: string, body?: unknown) => {
198
+ if (path === '/auth/rotate/challenge') return Promise.resolve({ challenge: 'chal-x', expiresAt: '2999-01-01T00:00:00.000Z' });
199
+ if (path === '/auth/rotate/complete') {
200
+ completeBody = body as Record<string, unknown>;
201
+ return Promise.resolve({ success: true, publicKey: next.publicKey, message: 'ok' });
202
+ }
203
+ return Promise.reject(new Error(`unexpected ${path}`));
204
+ });
205
+
206
+ const result = await oxy.rotateKey({ proof: 'phrase', phrase: current.phrase });
207
+ expect(result.newPublicKey).toBe(next.publicKey);
208
+
209
+ // The OLD-key signature MUST verify against the current key over the exact message.
210
+ const message = JSON.stringify({
211
+ action: 'rotate_key',
212
+ userId: 'user-123',
213
+ oldPublicKey: KeyManager.canonicalPublicKey(current.publicKey),
214
+ newPublicKey: next.publicKey,
215
+ challenge: 'chal-x',
216
+ timestamp: completeBody?.timestamp,
217
+ });
218
+ expect(await protocol.verifySignature(message, completeBody?.signature as string, current.publicKey)).toBe(true);
219
+ // …and must NOT verify against the new key (proves it was signed by the current key).
220
+ expect(await protocol.verifySignature(message, completeBody?.signature as string, next.publicKey)).toBe(false);
221
+
222
+ // The NEW-key proof-of-possession MUST verify against the NEW key.
223
+ const newKeyMessage = JSON.stringify({
224
+ action: 'rotate_key_new',
225
+ userId: 'user-123',
226
+ newPublicKey: next.publicKey,
227
+ challenge: 'chal-x',
228
+ timestamp: completeBody?.timestamp,
229
+ });
230
+ expect(await protocol.verifySignature(newKeyMessage, completeBody?.newKeyProof as string, next.publicKey)).toBe(true);
231
+ // …and must NOT verify against the old key (proves possession of the new key).
232
+ expect(await protocol.verifySignature(newKeyMessage, completeBody?.newKeyProof as string, current.publicKey)).toBe(false);
233
+ });
234
+
235
+ it('throws when proof is phrase but no phrase is supplied', async () => {
236
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
237
+ await expect(oxy.rotateKey({ proof: 'phrase' })).rejects.toThrow(/recovery phrase is required/);
238
+ expect(makeRequestSpy).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ describe('ambiguous network failure', () => {
243
+ it('reconciles a lost complete-response against the DID and treats a landed swap as done', async () => {
244
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
245
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
246
+ jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
247
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
248
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
249
+ makeRequestSpy
250
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
251
+ .mockRejectedValueOnce(new Error('network lost'))
252
+ .mockResolvedValueOnce(didDocWithKey(NEW_PUBLIC)); // DID already advertises the new key
253
+
254
+ const result = await oxy.rotateKey({ proof: 'device' });
255
+
256
+ expect(result.newPublicKey).toBe(NEW_PUBLIC);
257
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(3, 'GET', '/u/user-123/did.json', undefined, expect.objectContaining({ cache: false }));
258
+ // The swap landed server-side, so the new key is persisted locally.
259
+ expect(importSpy).toHaveBeenCalledWith(NEW_PRIVATE, { overwrite: true });
260
+ });
261
+
262
+ it('surfaces the error and does NOT persist locally when the DID shows the swap did not land', async () => {
263
+ jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
264
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
265
+ jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
266
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
267
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
268
+ makeRequestSpy
269
+ .mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
270
+ .mockRejectedValueOnce(new Error('network lost'))
271
+ .mockResolvedValueOnce(didDocWithKey(OLD_PUBLIC)); // DID still shows the OLD key
272
+
273
+ await expect(oxy.rotateKey({ proof: 'device' })).rejects.toBeDefined();
274
+ expect(importSpy).not.toHaveBeenCalled();
275
+ });
276
+ });
277
+ });