@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,79 @@
1
+ "use strict";
2
+ /**
3
+ * Authenticated Encryption with Associated Data (XChaCha20-Poly1305)
4
+ *
5
+ * Pure-JS/TS AEAD via `@noble/ciphers` — identical behaviour on web, Node, and
6
+ * React Native with zero WebCrypto / native-module dependency. This replaces
7
+ * the `crypto.subtle`-based path (unreliable on React Native) for the Commons
8
+ * encrypted backup and device-to-device transfer flows.
9
+ *
10
+ * XChaCha20-Poly1305 is chosen over AES-GCM specifically for its 24-byte
11
+ * (192-bit) random nonce: the nonce space is large enough that random nonces
12
+ * never collide in practice, so callers do not need to maintain a per-key
13
+ * counter. The 16-byte Poly1305 tag is appended to the ciphertext by the
14
+ * underlying library and validated on decrypt.
15
+ *
16
+ * The optional Associated Data (AAD) is authenticated but NOT encrypted: it
17
+ * binds the ciphertext to its context (e.g. a backup version, a device id, a
18
+ * DID). Decryption fails if the key, nonce, ciphertext, OR aad differ from
19
+ * those used at encryption time.
20
+ *
21
+ * ESM/CJS safe: static `import` only, no `require()`.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = void 0;
25
+ exports.encryptAead = encryptAead;
26
+ exports.decryptAead = decryptAead;
27
+ // Loading the polyfill guarantees `globalThis.crypto.getRandomValues` exists on
28
+ // every platform (native crypto on web/Node; expo-crypto-backed shim on RN).
29
+ require("./polyfill");
30
+ const chacha_1 = require("@noble/ciphers/chacha");
31
+ /** Key length for XChaCha20-Poly1305, in bytes (256-bit). */
32
+ exports.AEAD_KEY_LENGTH = 32;
33
+ /** Nonce length for XChaCha20-Poly1305, in bytes (192-bit). */
34
+ exports.AEAD_NONCE_LENGTH = 24;
35
+ function assertKey(key) {
36
+ if (key.length !== exports.AEAD_KEY_LENGTH) {
37
+ throw new Error(`AEAD key must be ${exports.AEAD_KEY_LENGTH} bytes, got ${key.length}`);
38
+ }
39
+ }
40
+ /** Generate a fresh 24-byte random nonce via the platform CSPRNG. */
41
+ function randomNonce() {
42
+ const nonce = new Uint8Array(exports.AEAD_NONCE_LENGTH);
43
+ globalThis.crypto.getRandomValues(nonce);
44
+ return nonce;
45
+ }
46
+ /**
47
+ * Encrypt `plaintext` under `key` with a fresh random nonce, authenticating the
48
+ * optional `aad`.
49
+ *
50
+ * @param key 32-byte symmetric key (e.g. from `hkdfSha256`).
51
+ * @param plaintext Bytes to encrypt.
52
+ * @param aad Optional associated data authenticated but not encrypted.
53
+ * @returns `{ nonce, ciphertext }` — both are required to decrypt.
54
+ */
55
+ function encryptAead(key, plaintext, aad) {
56
+ assertKey(key);
57
+ const nonce = randomNonce();
58
+ const ciphertext = (0, chacha_1.xchacha20poly1305)(key, nonce, aad).encrypt(plaintext);
59
+ return { nonce, ciphertext };
60
+ }
61
+ /**
62
+ * Decrypt and authenticate `ciphertext` produced by {@link encryptAead}.
63
+ *
64
+ * Throws if the key, nonce, ciphertext, or aad differ from those used at
65
+ * encryption time (tamper detection), or if the tag is invalid.
66
+ *
67
+ * @param key 32-byte symmetric key.
68
+ * @param nonce The 24-byte nonce returned by `encryptAead`.
69
+ * @param ciphertext Ciphertext with the appended Poly1305 tag.
70
+ * @param aad The same associated data supplied at encryption time.
71
+ * @returns The recovered plaintext bytes.
72
+ */
73
+ function decryptAead(key, nonce, ciphertext, aad) {
74
+ assertKey(key);
75
+ if (nonce.length !== exports.AEAD_NONCE_LENGTH) {
76
+ throw new Error(`AEAD nonce must be ${exports.AEAD_NONCE_LENGTH} bytes, got ${nonce.length}`);
77
+ }
78
+ return (0, chacha_1.xchacha20poly1305)(key, nonce, aad).decrypt(ciphertext);
79
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /**
3
+ * ECDH shared-secret derivation (secp256k1)
4
+ *
5
+ * Derives a raw 32-byte ECDH shared secret from a local private key and a
6
+ * remote public key, using the SAME `elliptic` `EC('secp256k1')` primitive the
7
+ * rest of core's identity layer uses (`keyManager.ts`). This is the key-exchange
8
+ * step for the Commons device-to-device transfer flow: each side computes the
9
+ * same shared secret, which is then run through `hkdfSha256` to derive the
10
+ * symmetric key handed to `encryptAead` / `decryptAead`.
11
+ *
12
+ * The returned value is the raw x-coordinate of the ECDH point, big-endian,
13
+ * zero-padded to 32 bytes. It is NOT itself a symmetric key — always pass it
14
+ * through a KDF (HKDF) with a context-binding `info` before use.
15
+ *
16
+ * ESM/CJS safe: static `import` only, no `require()`.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.deriveSharedSecret = deriveSharedSecret;
20
+ const elliptic_1 = require("elliptic");
21
+ const ec = new elliptic_1.ec('secp256k1');
22
+ /** Lowercase and left-pad a private-key hex string to canonical 64-char form. */
23
+ function canonicalPrivateKey(key) {
24
+ return key.toLowerCase().padStart(64, '0');
25
+ }
26
+ const HEX_RE = /^[0-9a-fA-F]+$/;
27
+ /**
28
+ * Compute the ECDH shared secret between a local private key and a remote
29
+ * public key on secp256k1.
30
+ *
31
+ * Symmetric by construction:
32
+ * `deriveSharedSecret(privA, pubB) === deriveSharedSecret(privB, pubA)`.
33
+ *
34
+ * @param privateKeyHex Local private key, hex (up to 64 chars; canonicalized).
35
+ * @param otherPublicKeyHex Remote public key, hex — compressed (`02`/`03` + 32
36
+ * bytes) or uncompressed (`04` + 64 bytes).
37
+ * @returns The 32-byte big-endian shared secret.
38
+ */
39
+ function deriveSharedSecret(privateKeyHex, otherPublicKeyHex) {
40
+ if (typeof privateKeyHex !== 'string' || !HEX_RE.test(privateKeyHex)) {
41
+ throw new Error('deriveSharedSecret: privateKeyHex must be a hex string');
42
+ }
43
+ if (typeof otherPublicKeyHex !== 'string' || !HEX_RE.test(otherPublicKeyHex)) {
44
+ throw new Error('deriveSharedSecret: otherPublicKeyHex must be a hex string');
45
+ }
46
+ const keyPair = ec.keyFromPrivate(canonicalPrivateKey(privateKeyHex));
47
+ const otherKey = ec.keyFromPublic(otherPublicKeyHex, 'hex');
48
+ // `derive` returns a BN (the shared point's x-coordinate). Serialize it
49
+ // big-endian, fixed 32 bytes, so both sides agree byte-for-byte regardless of
50
+ // any leading-zero stripping.
51
+ const sharedBytes = keyPair.derive(otherKey.getPublic()).toArray('be', 32);
52
+ return Uint8Array.from(sharedBytes);
53
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ /**
3
+ * Key Derivation Function (HKDF-SHA256)
4
+ *
5
+ * Pure-JS/TS HKDF via `@noble/hashes` — identical behaviour on web, Node, and
6
+ * React Native with zero WebCrypto / native-module dependency. Used to derive
7
+ * fixed-length symmetric keys from higher-entropy input keying material (an
8
+ * ECDH shared secret, a recovery-phrase seed, etc.) for the Commons encrypted
9
+ * backup and device-to-device transfer flows.
10
+ *
11
+ * ESM/CJS safe: static `import` only, no `require()`.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.hkdfSha256 = hkdfSha256;
15
+ const hkdf_1 = require("@noble/hashes/hkdf");
16
+ const sha256_1 = require("@noble/hashes/sha256");
17
+ /**
18
+ * Derive `length` bytes of keying material from `ikm` using HKDF-SHA256
19
+ * (RFC 5869 — extract-then-expand).
20
+ *
21
+ * @param ikm Input keying material (the raw secret; NOT necessarily uniform).
22
+ * @param salt Non-secret random salt. An empty array is treated by HKDF as a
23
+ * zero-filled salt of the hash length — pass a real salt whenever
24
+ * one is available so derivations for different contexts diverge.
25
+ * @param info Context/application-binding string ("what is this key for").
26
+ * Distinct `info` values yield independent keys from the same ikm.
27
+ * @param length Number of output bytes. Must be in (0, 255 * 32].
28
+ * @returns Exactly `length` bytes of derived keying material.
29
+ */
30
+ function hkdfSha256(ikm, salt, info, length) {
31
+ if (!Number.isInteger(length) || length <= 0) {
32
+ throw new Error('hkdfSha256: length must be a positive integer');
33
+ }
34
+ // HKDF-Expand is defined for at most 255 * HashLen bytes of output.
35
+ if (length > 255 * 32) {
36
+ throw new Error('hkdfSha256: length must not exceed 8160 bytes (255 * 32)');
37
+ }
38
+ return (0, hkdf_1.hkdf)(sha256_1.sha256, ikm, salt, info, length);
39
+ }
@@ -1295,6 +1295,13 @@ class KeyManager {
1295
1295
  const keyPair = ec.keyFromPrivate(KeyManager.canonicalPrivateKey(privateKey));
1296
1296
  return keyPair.getPublic('hex');
1297
1297
  }
1298
+ /**
1299
+ * Normalize a public key to uncompressed, lowercased hex. Used when building
1300
+ * signed rotation payloads so legacy compressed/cased encodings still verify.
1301
+ */
1302
+ static canonicalPublicKey(publicKey) {
1303
+ return ec.keyFromPublic(publicKey, 'hex').getPublic(false, 'hex').toLowerCase();
1304
+ }
1298
1305
  /**
1299
1306
  * Validate that a string is a valid public key
1300
1307
  *
@@ -41,9 +41,10 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  };
42
42
  })();
43
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
- exports.RecoveryPhraseService = void 0;
44
+ exports.RecoveryPhraseService = exports.BACKUP_MATERIAL_LENGTH = exports.BACKUP_KDF_LOOKUP_INFO = exports.BACKUP_KDF_ENCRYPTION_INFO = exports.BACKUP_KDF_SALT = void 0;
45
45
  const bip39 = __importStar(require("bip39"));
46
46
  const keyManager_1 = require("./keyManager");
47
+ const kdf_1 = require("./kdf");
47
48
  /**
48
49
  * Convert Uint8Array or array-like to hexadecimal string
49
50
  * Works in both Node.js and React Native without depending on Buffer
@@ -55,6 +56,22 @@ function toHex(data) {
55
56
  .map(b => b.toString(16).padStart(2, '0'))
56
57
  .join('');
57
58
  }
59
+ /** UTF-8 encode an ASCII label to bytes (for HKDF salt/info). */
60
+ function utf8(label) {
61
+ return new TextEncoder().encode(label);
62
+ }
63
+ /**
64
+ * HKDF context tag for the encrypted-backup key schedule (b3 Feature 1). Used as
65
+ * the HKDF `salt`; distinct from any other Oxy key-derivation salt so the backup
66
+ * key schedule is independent. Versioned so a future scheme change is a new tag.
67
+ */
68
+ exports.BACKUP_KDF_SALT = 'oxy-identity-backup-v1';
69
+ /** HKDF `info` label that derives the symmetric AEAD key from the seed. */
70
+ exports.BACKUP_KDF_ENCRYPTION_INFO = 'oxy-backup-encryption-key';
71
+ /** HKDF `info` label that derives the (server-hashed) backup locator from the seed. */
72
+ exports.BACKUP_KDF_LOOKUP_INFO = 'oxy-backup-lookup-id';
73
+ /** Byte length of both the derived backup key and the derived lookup id (256-bit). */
74
+ exports.BACKUP_MATERIAL_LENGTH = 32;
58
75
  class RecoveryPhraseService {
59
76
  /**
60
77
  * Generate a new identity with a recovery phrase.
@@ -105,6 +122,77 @@ class RecoveryPhraseService {
105
122
  publicKey,
106
123
  };
107
124
  }
125
+ /**
126
+ * Derive a brand-new identity + recovery phrase WITHOUT persisting anything.
127
+ *
128
+ * Pure: same derivation as {@link generateIdentityWithRecovery} (128-bit
129
+ * mnemonic → seed → first 32 bytes as the secp256k1 private key) but it stops
130
+ * BEFORE `KeyManager.importKeyPair`, so no on-device identity is touched. The
131
+ * caller decides if/when to commit the material (e.g. only after a server
132
+ * confirms a key rotation). Works on web too — it never reads or writes secure
133
+ * storage.
134
+ *
135
+ * The 12-word `phrase` MUST be shown to the user before the identity is
136
+ * committed anywhere — if it is lost the account becomes unrecoverable.
137
+ */
138
+ static async derivePendingIdentity() {
139
+ const mnemonic = bip39.generateMnemonic(128);
140
+ const seed = await bip39.mnemonicToSeed(mnemonic);
141
+ const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
142
+ const privateKey = toHex(seedSlice);
143
+ const publicKey = keyManager_1.KeyManager.derivePublicKey(privateKey);
144
+ return {
145
+ phrase: mnemonic,
146
+ words: mnemonic.split(' '),
147
+ privateKey,
148
+ publicKey,
149
+ };
150
+ }
151
+ /**
152
+ * Derive the private key from a recovery phrase WITHOUT storing it.
153
+ *
154
+ * The private-key counterpart of {@link derivePublicKeyFromPhrase}. Used to
155
+ * re-derive a key in memory (e.g. to sign a rotation proof with the current
156
+ * key when the device has no SecureStore copy). Never persists — the returned
157
+ * material lives only in the caller's memory.
158
+ */
159
+ static async derivePrivateKeyFromPhrase(phrase) {
160
+ const normalizedPhrase = phrase.trim().toLowerCase();
161
+ if (!bip39.validateMnemonic(normalizedPhrase)) {
162
+ throw new Error('Invalid recovery phrase');
163
+ }
164
+ const seed = await bip39.mnemonicToSeed(normalizedPhrase);
165
+ const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
166
+ return toHex(seedSlice);
167
+ }
168
+ /**
169
+ * Derive the encrypted-backup key material from a recovery phrase (b3 Feature
170
+ * 1). PURE and additive — it does NOT touch the frozen phrase→privateKey
171
+ * derivation ({@link derivePrivateKeyFromPhrase} slices the first 32 seed
172
+ * bytes) and never reads or writes secure storage.
173
+ *
174
+ * Both outputs are derived from the FULL 64-byte BIP-39 seed via HKDF-SHA256
175
+ * with domain-separated `info` labels, so the domain separation is real: a
176
+ * device compromise that leaks only the raw 32-byte private key can compute
177
+ * NEITHER the backup key nor the lookup id (both need the whole seed). Locating
178
+ * AND decrypting a backup therefore requires the recovery phrase.
179
+ *
180
+ * @param phrase - The BIP-39 recovery phrase (validated + normalized here).
181
+ * @returns `{ backupKey, lookupId }` — the AEAD key (kept local) and the hex
182
+ * locator (uploaded; server stores only its hash).
183
+ * @throws if the phrase is not a valid BIP-39 mnemonic.
184
+ */
185
+ static async deriveBackupMaterial(phrase) {
186
+ const normalizedPhrase = phrase.trim().toLowerCase();
187
+ if (!bip39.validateMnemonic(normalizedPhrase)) {
188
+ throw new Error('Invalid recovery phrase. Please check the words and try again.');
189
+ }
190
+ const seed = await bip39.mnemonicToSeed(normalizedPhrase);
191
+ const salt = utf8(exports.BACKUP_KDF_SALT);
192
+ const backupKey = (0, kdf_1.hkdfSha256)(seed, salt, utf8(exports.BACKUP_KDF_ENCRYPTION_INFO), exports.BACKUP_MATERIAL_LENGTH);
193
+ const lookupId = toHex((0, kdf_1.hkdfSha256)(seed, salt, utf8(exports.BACKUP_KDF_LOOKUP_INFO), exports.BACKUP_MATERIAL_LENGTH));
194
+ return { backupKey, lookupId };
195
+ }
108
196
  /**
109
197
  * Restore an identity from a recovery phrase.
110
198
  *
@@ -72,6 +72,9 @@
72
72
  "backToSignInLink": "Already have an account? Sign in",
73
73
  "hubExplainer": "Create your Oxy ID in a secure window, then come right back.",
74
74
  "continueInWindow": "Continue in a new window",
75
+ "toasts": {
76
+ "passkeyCreateFailed": "We couldn't create your account. Please try again."
77
+ },
75
78
  "welcome": {
76
79
  "title": "Welcome to Oxy!",
77
80
  "subtitle": "Let's create your account in just a few steps",
@@ -374,6 +377,8 @@
374
377
  "toasts": {
375
378
  "switchSuccess": "Account switched successfully!",
376
379
  "switchFailed": "There was a problem switching accounts. Please try again.",
380
+ "signInFailed": "Sign-in failed. Please try again.",
381
+ "passkeySignInFailed": "Passkey sign-in failed. Please try again.",
377
382
  "removeSuccess": "Account removed successfully!",
378
383
  "removeFailed": "There was a problem removing the account. Please try again.",
379
384
  "signOutAllSuccess": "All accounts signed out successfully!",
@@ -72,6 +72,9 @@
72
72
  "backToSignInLink": "¿Ya tienes cuenta? Inicia sesión",
73
73
  "hubExplainer": "Crea tu Oxy ID en una ventana segura y vuelve enseguida.",
74
74
  "continueInWindow": "Continuar en una ventana nueva",
75
+ "toasts": {
76
+ "passkeyCreateFailed": "No pudimos crear tu cuenta. Inténtalo de nuevo."
77
+ },
75
78
  "welcome": {
76
79
  "title": "¡Te damos la bienvenida a Oxy!",
77
80
  "subtitle": "Crea tu cuenta en pocos pasos",
@@ -572,6 +575,8 @@
572
575
  "toasts": {
573
576
  "switchSuccess": "¡Cuenta cambiada correctamente!",
574
577
  "switchFailed": "Hubo un problema al cambiar de cuenta. Inténtalo de nuevo.",
578
+ "signInFailed": "No se pudo iniciar sesión. Inténtalo de nuevo.",
579
+ "passkeySignInFailed": "No se pudo iniciar sesión con la llave de acceso. Inténtalo de nuevo.",
575
580
  "removeSuccess": "¡Cuenta eliminada correctamente!",
576
581
  "removeFailed": "Hubo un problema al eliminar la cuenta. Inténtalo de nuevo.",
577
582
  "signOutAllSuccess": "¡Todas las cuentas cerradas correctamente!",
@@ -72,6 +72,9 @@
72
72
  "backToSignInLink": "Already have an account? Sign in",
73
73
  "hubExplainer": "Create your Oxy ID in a secure window, then come right back.",
74
74
  "continueInWindow": "Continue in a new window",
75
+ "toasts": {
76
+ "passkeyCreateFailed": "We couldn't create your account. Please try again."
77
+ },
75
78
  "welcome": {
76
79
  "title": "Welcome to Oxy!",
77
80
  "subtitle": "Let's create your account in just a few steps",
@@ -374,6 +377,8 @@
374
377
  "toasts": {
375
378
  "switchSuccess": "Account switched successfully!",
376
379
  "switchFailed": "There was a problem switching accounts. Please try again.",
380
+ "signInFailed": "Sign-in failed. Please try again.",
381
+ "passkeySignInFailed": "Passkey sign-in failed. Please try again.",
377
382
  "removeSuccess": "Account removed successfully!",
378
383
  "removeFailed": "There was a problem removing the account. Please try again.",
379
384
  "signOutAllSuccess": "All accounts signed out successfully!",
@@ -72,6 +72,9 @@
72
72
  "backToSignInLink": "¿Ya tienes cuenta? Inicia sesión",
73
73
  "hubExplainer": "Crea tu Oxy ID en una ventana segura y vuelve enseguida.",
74
74
  "continueInWindow": "Continuar en una ventana nueva",
75
+ "toasts": {
76
+ "passkeyCreateFailed": "No pudimos crear tu cuenta. Inténtalo de nuevo."
77
+ },
75
78
  "welcome": {
76
79
  "title": "¡Te damos la bienvenida a Oxy!",
77
80
  "subtitle": "Crea tu cuenta en pocos pasos",
@@ -572,6 +575,8 @@
572
575
  "toasts": {
573
576
  "switchSuccess": "¡Cuenta cambiada correctamente!",
574
577
  "switchFailed": "Hubo un problema al cambiar de cuenta. Inténtalo de nuevo.",
578
+ "signInFailed": "No se pudo iniciar sesión. Inténtalo de nuevo.",
579
+ "passkeySignInFailed": "No se pudo iniciar sesión con la llave de acceso. Inténtalo de nuevo.",
575
580
  "removeSuccess": "¡Cuenta eliminada correctamente!",
576
581
  "removeFailed": "Hubo un problema al eliminar la cuenta. Inténtalo de nuevo.",
577
582
  "signOutAllSuccess": "¡Todas las cuentas cerradas correctamente!",
package/dist/cjs/index.js CHANGED
@@ -18,10 +18,10 @@
18
18
  * If a symbol does not appear here, it is NOT part of the public API.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.ORGANIZATION_CATEGORIES = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
- exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = void 0;
23
- exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidDisplayName = exports.isValidPassword = void 0;
24
- exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = exports.buildIdpHubOrigin = void 0;
21
+ exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.ORGANIZATION_CATEGORIES = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
+ exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = void 0;
23
+ exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.isValidDisplayName = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = void 0;
24
+ exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.redeemHubTicketOnHub = exports.syncHubAfterSignIn = exports.parseHubSyncReturnUrl = exports.normalizeOfficialReturnOrigin = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.isIdpHubOrigin = exports.buildHubSyncUrl = exports.buildIdpHubOrigin = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.normalizeOAuthRedirectUri = exports.OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = exports.OXY_SILENT_OAUTH_ATTEMPTED_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = void 0;
25
25
  // Ensure crypto polyfills are loaded before anything else
26
26
  require("./crypto/polyfill");
27
27
  // ---------------------------------------------------------------------------
@@ -103,6 +103,16 @@ var signatureService_1 = require("./crypto/signatureService");
103
103
  Object.defineProperty(exports, "SignatureService", { enumerable: true, get: function () { return signatureService_1.SignatureService; } });
104
104
  var recoveryPhrase_1 = require("./crypto/recoveryPhrase");
105
105
  Object.defineProperty(exports, "RecoveryPhraseService", { enumerable: true, get: function () { return recoveryPhrase_1.RecoveryPhraseService; } });
106
+ // Low-level crypto primitives (b3 Phase 0 — encrypted backup + device transfer)
107
+ var kdf_1 = require("./crypto/kdf");
108
+ Object.defineProperty(exports, "hkdfSha256", { enumerable: true, get: function () { return kdf_1.hkdfSha256; } });
109
+ var aead_1 = require("./crypto/aead");
110
+ Object.defineProperty(exports, "encryptAead", { enumerable: true, get: function () { return aead_1.encryptAead; } });
111
+ Object.defineProperty(exports, "decryptAead", { enumerable: true, get: function () { return aead_1.decryptAead; } });
112
+ Object.defineProperty(exports, "AEAD_KEY_LENGTH", { enumerable: true, get: function () { return aead_1.AEAD_KEY_LENGTH; } });
113
+ Object.defineProperty(exports, "AEAD_NONCE_LENGTH", { enumerable: true, get: function () { return aead_1.AEAD_NONCE_LENGTH; } });
114
+ var ecdh_1 = require("./crypto/ecdh");
115
+ Object.defineProperty(exports, "deriveSharedSecret", { enumerable: true, get: function () { return ecdh_1.deriveSharedSecret; } });
106
116
  // ---------------------------------------------------------------------------
107
117
  // Devices
108
118
  // ---------------------------------------------------------------------------
@@ -212,6 +222,9 @@ Object.defineProperty(exports, "isValidEmail", { enumerable: true, get: function
212
222
  Object.defineProperty(exports, "isValidUsername", { enumerable: true, get: function () { return validationUtils_1.isValidUsername; } });
213
223
  Object.defineProperty(exports, "isValidPassword", { enumerable: true, get: function () { return validationUtils_1.isValidPassword; } });
214
224
  Object.defineProperty(exports, "isValidDisplayName", { enumerable: true, get: function () { return validationUtils_1.isValidDisplayName; } });
225
+ Object.defineProperty(exports, "DISPLAY_NAME_ALLOWED_SCRIPTS", { enumerable: true, get: function () { return validationUtils_1.DISPLAY_NAME_ALLOWED_SCRIPTS; } });
226
+ Object.defineProperty(exports, "DISPLAY_NAME_DISALLOWED_SOURCE", { enumerable: true, get: function () { return validationUtils_1.DISPLAY_NAME_DISALLOWED_SOURCE; } });
227
+ Object.defineProperty(exports, "DISPLAY_NAME_ORPHANED_MARK_SOURCE", { enumerable: true, get: function () { return validationUtils_1.DISPLAY_NAME_ORPHANED_MARK_SOURCE; } });
215
228
  Object.defineProperty(exports, "isRequiredString", { enumerable: true, get: function () { return validationUtils_1.isRequiredString; } });
216
229
  Object.defineProperty(exports, "isRequiredNumber", { enumerable: true, get: function () { return validationUtils_1.isRequiredNumber; } });
217
230
  Object.defineProperty(exports, "isRequiredBoolean", { enumerable: true, get: function () { return validationUtils_1.isRequiredBoolean; } });
@@ -2,8 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildUserDid = buildUserDid;
4
4
  exports.OxyServicesIdentityMixin = OxyServicesIdentityMixin;
5
+ const protocol_1 = require("@oxyhq/protocol");
5
6
  const keyManager_1 = require("../crypto/keyManager");
6
7
  const signatureService_1 = require("../crypto/signatureService");
8
+ const recoveryPhrase_1 = require("../crypto/recoveryPhrase");
9
+ const platform_1 = require("../utils/platform");
10
+ const logger_1 = require("../logger");
7
11
  const mixinHelpers_1 = require("./mixinHelpers");
8
12
  /**
9
13
  * Registrable apex the Oxy DID method is anchored on. A user's DID is
@@ -141,6 +145,168 @@ function OxyServicesIdentityMixin(Base) {
141
145
  throw this.handleError(error);
142
146
  }
143
147
  }
148
+ /**
149
+ * Rotate the account's identity key: derive a brand-new keypair, prove
150
+ * control of the CURRENT key, and have the server ATOMICALLY replace the old
151
+ * key with the new one.
152
+ *
153
+ * The rotation is an atomic REPLACE on the server (never remove-then-add), so
154
+ * it never passes through a zero-auth-method state and is independent of the
155
+ * unlink guards. Because control of the current key is PROVEN (from
156
+ * SecureStore in `'device'` mode, or a recovery-phrase re-derivation in
157
+ * `'phrase'` mode), even the LAST remaining credential can be replaced.
158
+ *
159
+ * Ordering (safety-critical): the new key is persisted on-device ONLY AFTER
160
+ * the server confirms the swap. Persisting earlier would clobber the local
161
+ * key while the server still trusts the old one, locking the device out.
162
+ *
163
+ * Ambiguous-network-failure guard: if the `complete` response is lost
164
+ * (request sent, no reply), the swap may already have applied server-side.
165
+ * Before surfacing the error we reconcile against the derived DID document —
166
+ * if it already advertises the new key, the rotation is treated as done.
167
+ *
168
+ * NOTE: the UI is responsible for showing `newPhrase` to the user. For a
169
+ * "show-phrase-first" flow, derive the identity up front via
170
+ * {@link RecoveryPhraseService.derivePendingIdentity}, display it, then pass
171
+ * it back as `options.pendingIdentity` so the SAME identity is committed.
172
+ *
173
+ * @throws when no user is authenticated, when `proof: 'phrase'` is given
174
+ * without a `phrase`, when `proof: 'device'` runs with no on-device key,
175
+ * or when the rotation does not complete.
176
+ */
177
+ async rotateKey(options) {
178
+ try {
179
+ const userId = this.getCurrentUserId();
180
+ if (!userId) {
181
+ throw new Error('No authenticated user — sign in before rotating your key.');
182
+ }
183
+ // 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
184
+ // it and pass it back here so the phrase shown === the phrase committed.
185
+ const pending = options.pendingIdentity ?? (await recoveryPhrase_1.RecoveryPhraseService.derivePendingIdentity());
186
+ const newPublicKey = pending.publicKey;
187
+ // 2. Resolve the OLD signing capability from the chosen proof mode.
188
+ let oldPublicKey;
189
+ let signWithOldKey;
190
+ if (options.proof === 'phrase') {
191
+ const phrase = options.phrase?.trim();
192
+ if (!phrase) {
193
+ throw new Error('A recovery phrase is required for phrase-proof rotation.');
194
+ }
195
+ const oldPrivateKey = await recoveryPhrase_1.RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
196
+ oldPublicKey = keyManager_1.KeyManager.derivePublicKey(oldPrivateKey);
197
+ signWithOldKey = (message) => (0, protocol_1.signMessage)(message, oldPrivateKey);
198
+ }
199
+ else {
200
+ const currentPublicKey = await keyManager_1.KeyManager.getPublicKey();
201
+ if (!currentPublicKey) {
202
+ throw new Error('No on-device identity found. Use the recovery-phrase option to rotate your key.');
203
+ }
204
+ oldPublicKey = currentPublicKey;
205
+ signWithOldKey = (message) => signatureService_1.SignatureService.sign(message);
206
+ }
207
+ // 3. Request a single-use rotate_key challenge (bearer).
208
+ const { challenge } = await this.makeRequest('POST', '/auth/rotate/challenge', undefined, { cache: false });
209
+ // 4. Sign the rotation proofs. The OLD key proves control of the key being
210
+ // replaced; the NEW key proves possession of the key being rotated in
211
+ // (so the server never accepts a re-encoding of a key the caller does
212
+ // not control). Both signed byte strings MUST match the server's
213
+ // reconstruction exactly (this key order). The old key is canonicalized
214
+ // so legacy compressed encodings in Mongo still verify.
215
+ const timestamp = Date.now();
216
+ const canonicalOldPublicKey = keyManager_1.KeyManager.canonicalPublicKey(oldPublicKey);
217
+ const message = JSON.stringify({
218
+ action: 'rotate_key',
219
+ userId,
220
+ oldPublicKey: canonicalOldPublicKey,
221
+ newPublicKey,
222
+ challenge,
223
+ timestamp,
224
+ });
225
+ const signature = await signWithOldKey(message);
226
+ const newKeyMessage = JSON.stringify({
227
+ action: 'rotate_key_new',
228
+ userId,
229
+ newPublicKey,
230
+ challenge,
231
+ timestamp,
232
+ });
233
+ const newKeyProof = await (0, protocol_1.signMessage)(newKeyMessage, pending.privateKey);
234
+ // 5. Complete the rotation. On an AMBIGUOUS failure, reconcile against the
235
+ // DID before deciding the rotation failed.
236
+ let applied = false;
237
+ try {
238
+ const result = await this.makeRequest('POST', '/auth/rotate/complete', {
239
+ newPublicKey,
240
+ challenge,
241
+ signature,
242
+ newKeyProof,
243
+ timestamp,
244
+ ...(options.signOutEverywhere ? { signOutEverywhere: true } : {}),
245
+ }, { cache: false });
246
+ applied = result.success && result.publicKey.toLowerCase() === newPublicKey.toLowerCase();
247
+ }
248
+ catch (error) {
249
+ const reconciled = await this._rotationAlreadyApplied(userId, newPublicKey);
250
+ if (!reconciled) {
251
+ throw error;
252
+ }
253
+ applied = true;
254
+ }
255
+ if (!applied) {
256
+ throw new Error('Key rotation did not complete — your previous key is unchanged.');
257
+ }
258
+ // 6. ONLY after the server confirms the swap, persist the new key locally,
259
+ // overwriting the old one. `importKeyPair({ overwrite: true })` uses the
260
+ // atomic persist path (backs the previous key up first). Native-only —
261
+ // on web the key never lived in SecureStore, so there is nothing to
262
+ // persist locally.
263
+ //
264
+ // If this local write fails the server key is ALREADY the new one, so
265
+ // we must NOT throw and swallow the phrase — the caller needs it to
266
+ // re-import the now-live key. Surface the result with
267
+ // `localPersistFailed: true` (mirrors the pendingIdentity
268
+ // show-phrase-first path, where the caller already holds the phrase).
269
+ let localPersistFailed = false;
270
+ if (!(0, platform_1.isWeb)()) {
271
+ try {
272
+ await keyManager_1.KeyManager.importKeyPair(pending.privateKey, { overwrite: true });
273
+ }
274
+ catch (persistError) {
275
+ localPersistFailed = true;
276
+ logger_1.logger.warn('Key rotated on the server but persisting the new key on-device failed; returning the new phrase so it can be re-imported.', { component: 'OxyServices.identity', method: 'rotateKey' }, persistError);
277
+ }
278
+ }
279
+ this._invalidateIdentityCaches(userId);
280
+ return localPersistFailed
281
+ ? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
282
+ : { newPublicKey, newPhrase: pending.phrase, words: pending.words };
283
+ }
284
+ catch (error) {
285
+ throw this.handleError(error);
286
+ }
287
+ }
288
+ /**
289
+ * Reconciliation probe for the rotation ambiguous-failure guard: fetch the
290
+ * account's derived DID document (uncached) and report whether it already
291
+ * advertises `newPublicKey` as a verification method — i.e. whether the swap
292
+ * already landed server-side. A failed probe returns `false` (unconfirmed),
293
+ * so the caller surfaces the original network error.
294
+ *
295
+ * Uses the DID document rather than `GET /auth/methods` because the latter
296
+ * intentionally does NOT expose raw public keys, whereas the DID's
297
+ * `verificationMethod[].publicKeyHex` is derived live from the account's
298
+ * current key — so it reflects a completed rotation immediately.
299
+ *
300
+ * Internal helper (leading underscore); public rather than `private` for the
301
+ * same TS4094 reason as {@link _invalidateIdentityCaches}.
302
+ */
303
+ async _rotationAlreadyApplied(userId, newPublicKey) {
304
+ return this.makeRequest('GET', `/u/${encodeURIComponent(userId)}/did.json`, undefined, { cache: false })
305
+ .then((doc) => doc.verificationMethod.some((vm) => 'publicKeyHex' in vm &&
306
+ typeof vm.publicKeyHex === 'string' &&
307
+ vm.publicKeyHex.toLowerCase() === newPublicKey.toLowerCase()))
308
+ .catch(() => false);
309
+ }
144
310
  /**
145
311
  * Sign a record with the on-device identity key, WITHOUT publishing it.
146
312
  * The subject is the current user's DID. NATIVE-ONLY (requires a stored