@oxyhq/core 12.2.1 → 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 (62) 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 +14 -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/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/crypto/aead.js +74 -0
  18. package/dist/esm/crypto/ecdh.js +51 -0
  19. package/dist/esm/crypto/kdf.js +36 -0
  20. package/dist/esm/crypto/keyManager.js +7 -0
  21. package/dist/esm/crypto/recoveryPhrase.js +88 -0
  22. package/dist/esm/i18n/locales/en-US.json +5 -0
  23. package/dist/esm/i18n/locales/es-ES.json +5 -0
  24. package/dist/esm/i18n/locales/locales/en-US.json +5 -0
  25. package/dist/esm/i18n/locales/locales/es-ES.json +5 -0
  26. package/dist/esm/index.js +4 -0
  27. package/dist/esm/mixins/OxyServices.identity.js +166 -0
  28. package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
  29. package/dist/esm/mixins/OxyServices.user.js +43 -0
  30. package/dist/esm/mixins/index.js +4 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/crypto/aead.d.ts +56 -0
  33. package/dist/types/crypto/ecdh.d.ts +29 -0
  34. package/dist/types/crypto/kdf.d.ts +25 -0
  35. package/dist/types/crypto/keyManager.d.ts +5 -0
  36. package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
  37. package/dist/types/index.d.ts +7 -3
  38. package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
  39. package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
  40. package/dist/types/mixins/OxyServices.user.d.ts +38 -8
  41. package/dist/types/mixins/index.d.ts +2 -1
  42. package/package.json +4 -2
  43. package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
  44. package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
  45. package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
  46. package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
  47. package/src/crypto/aead.ts +97 -0
  48. package/src/crypto/ecdh.ts +60 -0
  49. package/src/crypto/kdf.ts +43 -0
  50. package/src/crypto/keyManager.ts +8 -0
  51. package/src/crypto/recoveryPhrase.ts +133 -0
  52. package/src/i18n/locales/en-US.json +5 -0
  53. package/src/i18n/locales/es-ES.json +5 -0
  54. package/src/index.ts +16 -1
  55. package/src/mixins/OxyServices.identity.ts +250 -0
  56. package/src/mixins/OxyServices.identityBackup.ts +237 -0
  57. package/src/mixins/OxyServices.user.ts +82 -4
  58. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
  59. package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
  60. package/src/mixins/__tests__/identityBackup.test.ts +258 -0
  61. package/src/mixins/index.ts +5 -0
  62. package/src/types/elliptic.d.ts +10 -2
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OxyServicesIdentityBackupMixin = OxyServicesIdentityBackupMixin;
4
+ const keyManager_1 = require("../crypto/keyManager");
5
+ const recoveryPhrase_1 = require("../crypto/recoveryPhrase");
6
+ const aead_1 = require("../crypto/aead");
7
+ /** Envelope/KDF version — bump only on a breaking scheme change. */
8
+ const BACKUP_ENVELOPE_VERSION = 1;
9
+ /** The AEAD the backup is sealed with. Pinned so a mismatched decryptor fails loudly. */
10
+ const BACKUP_ALGORITHM = 'xchacha20poly1305';
11
+ /**
12
+ * Length (hex chars) of the public-key HINT stored/echoed with a backup — enough
13
+ * to let the owner recognise WHICH identity a backup belongs to, but only a
14
+ * prefix (the full key is public anyway; a prefix keeps the record minimal).
15
+ */
16
+ const PUBLIC_KEY_HINT_LENGTH = 16;
17
+ /** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
18
+ function toHex(bytes) {
19
+ let out = '';
20
+ for (let i = 0; i < bytes.length; i += 1) {
21
+ out += bytes[i].toString(16).padStart(2, '0');
22
+ }
23
+ return out;
24
+ }
25
+ /** Decode a lowercase/uppercase hex string to bytes. Throws on malformed input. */
26
+ function fromHex(hex) {
27
+ if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) {
28
+ throw new Error('Malformed hex in encrypted backup envelope.');
29
+ }
30
+ const out = new Uint8Array(hex.length / 2);
31
+ for (let i = 0; i < out.length; i += 1) {
32
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * The AEAD associated data binds the ciphertext to its `{version, publicKeyHint}`
38
+ * context: the exact bytes must be reproduced at decrypt time, so a mismatched
39
+ * version or hint (e.g. an envelope re-stamped by a tamperer) fails the
40
+ * Poly1305 check. Deterministic: both sides build the SAME object literal, so
41
+ * `JSON.stringify` yields identical bytes.
42
+ */
43
+ function buildBackupAad(version, publicKeyHint) {
44
+ return new TextEncoder().encode(JSON.stringify({ version, publicKeyHint }));
45
+ }
46
+ function OxyServicesIdentityBackupMixin(Base) {
47
+ return class extends Base {
48
+ constructor(...args) {
49
+ super(...args);
50
+ }
51
+ /**
52
+ * Derive the backup key material from the recovery phrase, encrypt the
53
+ * identity's `{privateKey, publicKey, createdAt}` with it, and upload the
54
+ * ciphertext + raw `lookupId` (`POST /identity/backup`, bearer). The server
55
+ * stores only `sha256(lookupId)` + the ciphertext. Idempotent per user: a
56
+ * re-upload REPLACES the prior backup (upsert by user id).
57
+ *
58
+ * The identity is derived from the PHRASE (not read from SecureStore), so
59
+ * this works cross-platform and does not require an on-device key.
60
+ *
61
+ * @param phrase - The identity's BIP-39 recovery phrase.
62
+ * @returns The post-write backup status (`{ exists: true, publicKeyHint, createdAt }`).
63
+ */
64
+ async createEncryptedBackup(phrase) {
65
+ try {
66
+ const { backupKey, lookupId } = await recoveryPhrase_1.RecoveryPhraseService.deriveBackupMaterial(phrase);
67
+ const privateKey = await recoveryPhrase_1.RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
68
+ const publicKey = keyManager_1.KeyManager.derivePublicKey(privateKey);
69
+ const createdAt = new Date().toISOString();
70
+ const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
71
+ const payload = { privateKey, publicKey, createdAt };
72
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
73
+ const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
74
+ const { nonce, ciphertext } = (0, aead_1.encryptAead)(backupKey, plaintext, aad);
75
+ const body = {
76
+ version: BACKUP_ENVELOPE_VERSION,
77
+ algorithm: BACKUP_ALGORITHM,
78
+ kdfInfo: recoveryPhrase_1.BACKUP_KDF_ENCRYPTION_INFO,
79
+ nonce: toHex(nonce),
80
+ ciphertext: toHex(ciphertext),
81
+ publicKeyHint,
82
+ createdAt,
83
+ lookupId,
84
+ };
85
+ return await this.makeRequest('POST', '/identity/backup', body, { cache: false });
86
+ }
87
+ catch (error) {
88
+ throw this.handleError(error);
89
+ }
90
+ }
91
+ /**
92
+ * Whether the authenticated user has a stored encrypted backup, plus the
93
+ * non-sensitive hint + timestamp when one exists (`GET /identity/backup/status`,
94
+ * bearer). Returns no ciphertext and no locator.
95
+ */
96
+ async getBackupStatus() {
97
+ try {
98
+ return await this.makeRequest('GET', '/identity/backup/status', undefined, { cache: false });
99
+ }
100
+ catch (error) {
101
+ throw this.handleError(error);
102
+ }
103
+ }
104
+ /**
105
+ * Delete the authenticated user's stored backup (`DELETE /identity/backup`,
106
+ * bearer). Idempotent — deleting a non-existent backup still succeeds.
107
+ */
108
+ async deleteBackup() {
109
+ try {
110
+ return await this.makeRequest('DELETE', '/identity/backup', undefined, { cache: false });
111
+ }
112
+ catch (error) {
113
+ throw this.handleError(error);
114
+ }
115
+ }
116
+ /**
117
+ * Restore an identity from its encrypted off-device backup using ONLY the
118
+ * recovery phrase: re-derive `{backupKey, lookupId}`, fetch the envelope by
119
+ * `lookupId` (`GET /identity/backup/:lookupId`, PUBLIC — the 256-bit locator
120
+ * is the protection), decrypt + authenticate locally, then persist the key.
121
+ *
122
+ * NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
123
+ * refuses to clobber a DIFFERENT existing on-device identity unless
124
+ * `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
125
+ * propagates to the caller (never swallowed) so the UI can confirm before
126
+ * overwriting.
127
+ *
128
+ * @param phrase - The identity's BIP-39 recovery phrase.
129
+ * @param options.overwrite - Replace a different existing on-device identity.
130
+ * @returns The restored identity's public key.
131
+ * @throws if the phrase is invalid, no backup exists (404), the ciphertext
132
+ * fails authentication (tamper), or an existing identity blocks the import.
133
+ */
134
+ async restoreFromEncryptedBackup(phrase, options) {
135
+ try {
136
+ const { backupKey, lookupId } = await recoveryPhrase_1.RecoveryPhraseService.deriveBackupMaterial(phrase);
137
+ const envelope = await this.makeRequest('GET', `/identity/backup/${encodeURIComponent(lookupId)}`, undefined, { cache: false });
138
+ if (envelope.algorithm !== BACKUP_ALGORITHM) {
139
+ throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
140
+ }
141
+ const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
142
+ const plaintext = (0, aead_1.decryptAead)(backupKey, fromHex(envelope.nonce), fromHex(envelope.ciphertext), aad);
143
+ const payload = JSON.parse(new TextDecoder().decode(plaintext));
144
+ // Persist the recovered key. Native-only; refuses to clobber a different
145
+ // identity unless overwrite — the IdentityAlreadyExistsError propagates.
146
+ return await keyManager_1.KeyManager.importKeyPair(payload.privateKey, {
147
+ overwrite: options?.overwrite === true,
148
+ });
149
+ }
150
+ catch (error) {
151
+ // Preserve the typed "an identity already exists" signal so the caller
152
+ // can prompt for overwrite. `handleError` would flatten it to a generic
153
+ // Error and lose that discrimination.
154
+ if (error instanceof keyManager_1.IdentityAlreadyExistsError) {
155
+ throw error;
156
+ }
157
+ throw this.handleError(error);
158
+ }
159
+ }
160
+ };
161
+ }
@@ -13,6 +13,12 @@ const errorUtils_1 = require("../utils/errorUtils");
13
13
  * server-side batch cap; larger inputs are split into multiple chunked calls.
14
14
  */
15
15
  const USERS_BY_IDS_CHUNK_SIZE = 100;
16
+ /**
17
+ * Maximum number of ids sent per `POST /users/follow-status/bulk` request.
18
+ * Matches the server-side `MAX_BULK_FOLLOW` cap; larger inputs are split into
19
+ * multiple chunked calls whose result maps are merged.
20
+ */
21
+ const FOLLOW_STATUS_CHUNK_SIZE = 200;
16
22
  function OxyServicesUserMixin(Base) {
17
23
  return class extends Base {
18
24
  constructor(...args) {
@@ -604,6 +610,43 @@ function OxyServicesUserMixin(Base) {
604
610
  throw this.handleError(error);
605
611
  }
606
612
  }
613
+ /**
614
+ * Resolve the viewer's follow status for MANY users in one round-trip per
615
+ * chunk. Built for list UIs (a page of `FollowButton`s) that would otherwise
616
+ * fire one `getFollowStatus` per button (the classic N+1).
617
+ *
618
+ * Ids are deduplicated and validated (empty/blank ids dropped), split into
619
+ * chunks of {@link FOLLOW_STATUS_CHUNK_SIZE} (the server's bulk cap), and
620
+ * POSTed to `/users/follow-status/bulk` as `{ userIds }`. The per-chunk
621
+ * `{ statuses }` maps are merged into one `Record<string, boolean>` covering
622
+ * every requested id — ids the viewer does not follow come back `false`.
623
+ *
624
+ * Uncached (`{ cache: false }`): the UI store owns follow-status freshness
625
+ * and writes optimistically on every mutation, so an SDK cache here would
626
+ * serve a stale status right after a follow/unfollow. An empty/whitespace-
627
+ * only input resolves immediately with `{}` and performs no network call.
628
+ */
629
+ async getFollowStatuses(userIds) {
630
+ const uniqueIds = Array.from(new Set(userIds.filter((id) => typeof id === 'string' && id.trim().length > 0)));
631
+ if (uniqueIds.length === 0) {
632
+ return {};
633
+ }
634
+ const chunks = [];
635
+ for (let i = 0; i < uniqueIds.length; i += FOLLOW_STATUS_CHUNK_SIZE) {
636
+ chunks.push(uniqueIds.slice(i, i + FOLLOW_STATUS_CHUNK_SIZE));
637
+ }
638
+ try {
639
+ const responses = await Promise.all(chunks.map((chunk) => this.makeRequest('POST', '/users/follow-status/bulk', { userIds: chunk }, { cache: false })));
640
+ const merged = {};
641
+ for (const response of responses) {
642
+ Object.assign(merged, response?.statuses ?? {});
643
+ }
644
+ return merged;
645
+ }
646
+ catch (error) {
647
+ throw this.handleError(error);
648
+ }
649
+ }
607
650
  /**
608
651
  * Get user followers
609
652
  */
@@ -12,6 +12,7 @@ const OxyServices_base_1 = require("../OxyServices.base");
12
12
  const OxyServices_auth_1 = require("./OxyServices.auth");
13
13
  const OxyServices_user_1 = require("./OxyServices.user");
14
14
  const OxyServices_identity_1 = require("./OxyServices.identity");
15
+ const OxyServices_identityBackup_1 = require("./OxyServices.identityBackup");
15
16
  const OxyServices_privacy_1 = require("./OxyServices.privacy");
16
17
  const OxyServices_language_1 = require("./OxyServices.language");
17
18
  const OxyServices_payment_1 = require("./OxyServices.payment");
@@ -50,6 +51,9 @@ const MIXIN_PIPELINE = [
50
51
  OxyServices_user_1.OxyServicesUserMixin,
51
52
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
52
53
  OxyServices_identity_1.OxyServicesIdentityMixin,
54
+ // Encrypted off-device identity backup (b3 Feature 1): store/restore an
55
+ // encrypted copy of the self-custody key, keyed off the recovery phrase.
56
+ OxyServices_identityBackup_1.OxyServicesIdentityBackupMixin,
53
57
  OxyServices_privacy_1.OxyServicesPrivacyMixin,
54
58
  // Feature mixins
55
59
  OxyServices_language_1.OxyServicesLanguageMixin,