@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,56 @@
1
+ /**
2
+ * Authenticated Encryption with Associated Data (XChaCha20-Poly1305)
3
+ *
4
+ * Pure-JS/TS AEAD via `@noble/ciphers` — identical behaviour on web, Node, and
5
+ * React Native with zero WebCrypto / native-module dependency. This replaces
6
+ * the `crypto.subtle`-based path (unreliable on React Native) for the Commons
7
+ * encrypted backup and device-to-device transfer flows.
8
+ *
9
+ * XChaCha20-Poly1305 is chosen over AES-GCM specifically for its 24-byte
10
+ * (192-bit) random nonce: the nonce space is large enough that random nonces
11
+ * never collide in practice, so callers do not need to maintain a per-key
12
+ * counter. The 16-byte Poly1305 tag is appended to the ciphertext by the
13
+ * underlying library and validated on decrypt.
14
+ *
15
+ * The optional Associated Data (AAD) is authenticated but NOT encrypted: it
16
+ * binds the ciphertext to its context (e.g. a backup version, a device id, a
17
+ * DID). Decryption fails if the key, nonce, ciphertext, OR aad differ from
18
+ * those used at encryption time.
19
+ *
20
+ * ESM/CJS safe: static `import` only, no `require()`.
21
+ */
22
+ import './polyfill';
23
+ /** Key length for XChaCha20-Poly1305, in bytes (256-bit). */
24
+ export declare const AEAD_KEY_LENGTH = 32;
25
+ /** Nonce length for XChaCha20-Poly1305, in bytes (192-bit). */
26
+ export declare const AEAD_NONCE_LENGTH = 24;
27
+ /** Ciphertext (Poly1305 tag appended) plus the random nonce used to produce it. */
28
+ export interface AeadResult {
29
+ /** The 24-byte random nonce. Store/transmit alongside the ciphertext. */
30
+ nonce: Uint8Array;
31
+ /** Ciphertext with the 16-byte Poly1305 authentication tag appended. */
32
+ ciphertext: Uint8Array;
33
+ }
34
+ /**
35
+ * Encrypt `plaintext` under `key` with a fresh random nonce, authenticating the
36
+ * optional `aad`.
37
+ *
38
+ * @param key 32-byte symmetric key (e.g. from `hkdfSha256`).
39
+ * @param plaintext Bytes to encrypt.
40
+ * @param aad Optional associated data authenticated but not encrypted.
41
+ * @returns `{ nonce, ciphertext }` — both are required to decrypt.
42
+ */
43
+ export declare function encryptAead(key: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): AeadResult;
44
+ /**
45
+ * Decrypt and authenticate `ciphertext` produced by {@link encryptAead}.
46
+ *
47
+ * Throws if the key, nonce, ciphertext, or aad differ from those used at
48
+ * encryption time (tamper detection), or if the tag is invalid.
49
+ *
50
+ * @param key 32-byte symmetric key.
51
+ * @param nonce The 24-byte nonce returned by `encryptAead`.
52
+ * @param ciphertext Ciphertext with the appended Poly1305 tag.
53
+ * @param aad The same associated data supplied at encryption time.
54
+ * @returns The recovered plaintext bytes.
55
+ */
56
+ export declare function decryptAead(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ECDH shared-secret derivation (secp256k1)
3
+ *
4
+ * Derives a raw 32-byte ECDH shared secret from a local private key and a
5
+ * remote public key, using the SAME `elliptic` `EC('secp256k1')` primitive the
6
+ * rest of core's identity layer uses (`keyManager.ts`). This is the key-exchange
7
+ * step for the Commons device-to-device transfer flow: each side computes the
8
+ * same shared secret, which is then run through `hkdfSha256` to derive the
9
+ * symmetric key handed to `encryptAead` / `decryptAead`.
10
+ *
11
+ * The returned value is the raw x-coordinate of the ECDH point, big-endian,
12
+ * zero-padded to 32 bytes. It is NOT itself a symmetric key — always pass it
13
+ * through a KDF (HKDF) with a context-binding `info` before use.
14
+ *
15
+ * ESM/CJS safe: static `import` only, no `require()`.
16
+ */
17
+ /**
18
+ * Compute the ECDH shared secret between a local private key and a remote
19
+ * public key on secp256k1.
20
+ *
21
+ * Symmetric by construction:
22
+ * `deriveSharedSecret(privA, pubB) === deriveSharedSecret(privB, pubA)`.
23
+ *
24
+ * @param privateKeyHex Local private key, hex (up to 64 chars; canonicalized).
25
+ * @param otherPublicKeyHex Remote public key, hex — compressed (`02`/`03` + 32
26
+ * bytes) or uncompressed (`04` + 64 bytes).
27
+ * @returns The 32-byte big-endian shared secret.
28
+ */
29
+ export declare function deriveSharedSecret(privateKeyHex: string, otherPublicKeyHex: string): Uint8Array;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Key Derivation Function (HKDF-SHA256)
3
+ *
4
+ * Pure-JS/TS HKDF via `@noble/hashes` — identical behaviour on web, Node, and
5
+ * React Native with zero WebCrypto / native-module dependency. Used to derive
6
+ * fixed-length symmetric keys from higher-entropy input keying material (an
7
+ * ECDH shared secret, a recovery-phrase seed, etc.) for the Commons encrypted
8
+ * backup and device-to-device transfer flows.
9
+ *
10
+ * ESM/CJS safe: static `import` only, no `require()`.
11
+ */
12
+ /**
13
+ * Derive `length` bytes of keying material from `ikm` using HKDF-SHA256
14
+ * (RFC 5869 — extract-then-expand).
15
+ *
16
+ * @param ikm Input keying material (the raw secret; NOT necessarily uniform).
17
+ * @param salt Non-secret random salt. An empty array is treated by HKDF as a
18
+ * zero-filled salt of the hash length — pass a real salt whenever
19
+ * one is available so derivations for different contexts diverge.
20
+ * @param info Context/application-binding string ("what is this key for").
21
+ * Distinct `info` values yield independent keys from the same ikm.
22
+ * @param length Number of output bytes. Must be in (0, 255 * 32].
23
+ * @returns Exactly `length` bytes of derived keying material.
24
+ */
25
+ export declare function hkdfSha256(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, length: number): Uint8Array;
@@ -299,6 +299,11 @@ export declare class KeyManager {
299
299
  * Derive public key from a private key (without storing)
300
300
  */
301
301
  static derivePublicKey(privateKey: string): string;
302
+ /**
303
+ * Normalize a public key to uncompressed, lowercased hex. Used when building
304
+ * signed rotation payloads so legacy compressed/cased encodings still verify.
305
+ */
306
+ static canonicalPublicKey(publicKey: string): string;
302
307
  /**
303
308
  * Validate that a string is a valid public key
304
309
  *
@@ -6,11 +6,55 @@
6
6
  *
7
7
  * Note: This module requires the polyfill to be loaded first (done via crypto/index.ts)
8
8
  */
9
+ /**
10
+ * HKDF context tag for the encrypted-backup key schedule (b3 Feature 1). Used as
11
+ * the HKDF `salt`; distinct from any other Oxy key-derivation salt so the backup
12
+ * key schedule is independent. Versioned so a future scheme change is a new tag.
13
+ */
14
+ export declare const BACKUP_KDF_SALT = "oxy-identity-backup-v1";
15
+ /** HKDF `info` label that derives the symmetric AEAD key from the seed. */
16
+ export declare const BACKUP_KDF_ENCRYPTION_INFO = "oxy-backup-encryption-key";
17
+ /** HKDF `info` label that derives the (server-hashed) backup locator from the seed. */
18
+ export declare const BACKUP_KDF_LOOKUP_INFO = "oxy-backup-lookup-id";
19
+ /** Byte length of both the derived backup key and the derived lookup id (256-bit). */
20
+ export declare const BACKUP_MATERIAL_LENGTH = 32;
21
+ /**
22
+ * The two pieces of key material derived from a recovery phrase for the
23
+ * encrypted off-device backup, kept strictly separate by HKDF domain separation.
24
+ */
25
+ export interface BackupMaterial {
26
+ /**
27
+ * The 32-byte symmetric key handed to `encryptAead`/`decryptAead`. NEVER
28
+ * leaves the device — the server sees only ciphertext produced with it.
29
+ */
30
+ backupKey: Uint8Array;
31
+ /**
32
+ * The 256-bit backup locator, hex. Sent to the server, which stores ONLY
33
+ * `sha256(lookupId)` — so possession of this value (which itself requires the
34
+ * full seed to compute) is what locates a backup, and the server can never
35
+ * recompute it from what it stores.
36
+ */
37
+ lookupId: string;
38
+ }
9
39
  export interface RecoveryPhraseResult {
10
40
  phrase: string;
11
41
  words: string[];
12
42
  publicKey: string;
13
43
  }
44
+ /**
45
+ * A freshly-derived identity that has NOT been persisted to secure storage.
46
+ *
47
+ * Unlike {@link RecoveryPhraseResult} this also exposes the `privateKey`, because
48
+ * the caller must be able to sign with (or later persist) the material itself —
49
+ * the whole point of a "pending" identity is that nothing is committed until an
50
+ * external step (e.g. a server-confirmed key rotation) succeeds.
51
+ */
52
+ export interface PendingIdentityResult {
53
+ phrase: string;
54
+ words: string[];
55
+ privateKey: string;
56
+ publicKey: string;
57
+ }
14
58
  export interface GenerateIdentityOptions {
15
59
  /**
16
60
  * Pass `true` to allow overwriting an existing on-device identity.
@@ -40,6 +84,47 @@ export declare class RecoveryPhraseService {
40
84
  * Same overwrite-protection semantics as `generateIdentityWithRecovery`.
41
85
  */
42
86
  static generateIdentityWithRecovery24(options?: GenerateIdentityOptions): Promise<RecoveryPhraseResult>;
87
+ /**
88
+ * Derive a brand-new identity + recovery phrase WITHOUT persisting anything.
89
+ *
90
+ * Pure: same derivation as {@link generateIdentityWithRecovery} (128-bit
91
+ * mnemonic → seed → first 32 bytes as the secp256k1 private key) but it stops
92
+ * BEFORE `KeyManager.importKeyPair`, so no on-device identity is touched. The
93
+ * caller decides if/when to commit the material (e.g. only after a server
94
+ * confirms a key rotation). Works on web too — it never reads or writes secure
95
+ * storage.
96
+ *
97
+ * The 12-word `phrase` MUST be shown to the user before the identity is
98
+ * committed anywhere — if it is lost the account becomes unrecoverable.
99
+ */
100
+ static derivePendingIdentity(): Promise<PendingIdentityResult>;
101
+ /**
102
+ * Derive the private key from a recovery phrase WITHOUT storing it.
103
+ *
104
+ * The private-key counterpart of {@link derivePublicKeyFromPhrase}. Used to
105
+ * re-derive a key in memory (e.g. to sign a rotation proof with the current
106
+ * key when the device has no SecureStore copy). Never persists — the returned
107
+ * material lives only in the caller's memory.
108
+ */
109
+ static derivePrivateKeyFromPhrase(phrase: string): Promise<string>;
110
+ /**
111
+ * Derive the encrypted-backup key material from a recovery phrase (b3 Feature
112
+ * 1). PURE and additive — it does NOT touch the frozen phrase→privateKey
113
+ * derivation ({@link derivePrivateKeyFromPhrase} slices the first 32 seed
114
+ * bytes) and never reads or writes secure storage.
115
+ *
116
+ * Both outputs are derived from the FULL 64-byte BIP-39 seed via HKDF-SHA256
117
+ * with domain-separated `info` labels, so the domain separation is real: a
118
+ * device compromise that leaks only the raw 32-byte private key can compute
119
+ * NEITHER the backup key nor the lookup id (both need the whole seed). Locating
120
+ * AND decrypting a backup therefore requires the recovery phrase.
121
+ *
122
+ * @param phrase - The BIP-39 recovery phrase (validated + normalized here).
123
+ * @returns `{ backupKey, lookupId }` — the AEAD key (kept local) and the hex
124
+ * locator (uploaded; server stores only its hash).
125
+ * @throws if the phrase is not a valid BIP-39 mnemonic.
126
+ */
127
+ static deriveBackupMaterial(phrase: string): Promise<BackupMaterial>;
43
128
  /**
44
129
  * Restore an identity from a recovery phrase.
45
130
  *
@@ -26,7 +26,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
26
26
  export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
27
27
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
28
28
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
29
- export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, ViewerGraph, } from './mixins/OxyServices.user';
29
+ export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, FollowMutationResult, ViewerGraph, } from './mixins/OxyServices.user';
30
30
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
31
31
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
32
32
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
@@ -38,7 +38,7 @@ export type { AccountKind, OrganizationCategory, AccountRelationship, AccountRol
38
38
  export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts';
39
39
  export type { ReputationCategory, TrustTier, ReputationTransactionStatus, ReputationTargetEntityType, ReputationDisputeStatus, ReputationInfluenceContext, ReputationTransaction, ReputationBalanceBreakdown, ReputationInfluence, ReputationReliability, ReputationBalance, ReputationDispute, ReputationRule, ReputationLeaderboardEntry, ReputationInfluenceResult, ReverseReputationTransactionResult, AwardReputationInput, CreateReputationDisputeInput, ResolveReputationDisputeInput, UpsertReputationRuleInput, ReverseReputationTransactionInput, } from './mixins/OxyServices.reputation';
40
40
  export { buildUserDid } from './mixins/OxyServices.identity';
41
- export type { IdentityRecordType, UnlinkableAuthMethodType, LinkAuthMethodResult, PublishRecordResult, VerifyRecordResult, VerifyDomainResult, RemoveDomainResult, } from './mixins/OxyServices.identity';
41
+ export type { IdentityRecordType, UnlinkableAuthMethodType, LinkAuthMethodResult, PublishRecordResult, VerifyRecordResult, VerifyDomainResult, RemoveDomainResult, RotateKeyProof, RotateKeyOptions, RotateKeyResult, } from './mixins/OxyServices.identity';
42
42
  export { parseIdPayload, parseAttestPayload, verifyPublicCardAttestation, } from './mixins/OxyServices.civic';
43
43
  export type { CivicCardResult, IdCardRef, AttestQrPayload, ParsedAttestPayload, SubmitRealLifeAttestationInput, DenyValidationResult, VouchForPersonInput, WithdrawVouchResult, IssueCredentialInput, RevokeCredentialResult, } from './mixins/OxyServices.civic';
44
44
  export type { UserNodeStatus, UserNodeMode, UserNodeController, UserNodeLivenessStatus, RegisterNodeInput, RemoveNodeResult } from './mixins/OxyServices.nodes';
@@ -51,7 +51,11 @@ export type { KeyPair } from './crypto/keyManager';
51
51
  export { SignatureService } from './crypto/signatureService';
52
52
  export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
53
53
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
54
- export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
54
+ export type { RecoveryPhraseResult, PendingIdentityResult, BackupMaterial } from './crypto/recoveryPhrase';
55
+ export { hkdfSha256 } from './crypto/kdf';
56
+ export { encryptAead, decryptAead, AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH, } from './crypto/aead';
57
+ export type { AeadResult } from './crypto/aead';
58
+ export { deriveSharedSecret } from './crypto/ecdh';
55
59
  export { DeviceManager } from './utils/deviceManager';
56
60
  export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
57
61
  export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceLinkedSession, DeviceLinkedSessionsResponse, DeviceLinkedSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
@@ -73,7 +77,7 @@ export { buildSearchParams, buildUrl, buildPaginationParams, safeJsonParse, } fr
73
77
  export type { PaginationParams, ApiResponse, ErrorResponse, } from './utils/apiUtils';
74
78
  export { ErrorCodes, createApiError, handleHttpError, validateRequiredFields, } from './utils/errorUtils';
75
79
  export { retryAsync } from './utils/asyncUtils';
76
- export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
80
+ export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
77
81
  export { normalizeInlineText, normalizeMultilineText, } from './utils/textNormalization';
78
82
  export { logger, createLogger, configureLogger, getLoggerConfig, resetLoggerConfig, consoleSink, isDev, } from './logger';
79
83
  export type { Logger, LogLevel, EmittableLogLevel, LogContext, LogEntry, LogSink, LoggerConfig, } from './logger';
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import type { AuthMethodsResponse, DidDocument, DomainVerificationInstructions, ExportBundle, OxySignedRecordType, SignedRecordEnvelope, VerifiedDomain } from '@oxyhq/contracts';
27
27
  import type { OxyServicesBase } from '../OxyServices.base';
28
+ import { type PendingIdentityResult } from '../crypto/recoveryPhrase';
28
29
  /**
29
30
  * Record categories a client may sign and publish to the Oxy store. The base
30
31
  * envelope `type` is now an open string (any app may define its own records on
@@ -67,6 +68,54 @@ export interface VerifyDomainResult {
67
68
  export interface RemoveDomainResult {
68
69
  success: boolean;
69
70
  }
71
+ /** How the caller proves control of the CURRENT key during a key rotation. */
72
+ export type RotateKeyProof = 'device' | 'phrase';
73
+ /** Options for {@link OxyServicesIdentityMixin.rotateKey}. */
74
+ export interface RotateKeyOptions {
75
+ /**
76
+ * How to prove control of the CURRENT key:
77
+ * - `'device'`: sign with the on-device SecureStore key (native-only).
78
+ * - `'phrase'`: re-derive the current key from the entered recovery `phrase`
79
+ * and sign with it. This works even when the device holds NO SecureStore
80
+ * copy of the key — it is how the LAST remaining credential is replaced.
81
+ */
82
+ proof: RotateKeyProof;
83
+ /** The CURRENT identity's recovery phrase. Required when `proof: 'phrase'`. */
84
+ phrase?: string;
85
+ /**
86
+ * When true, all OTHER active sessions are revoked after a successful
87
+ * rotation (the rotating device stays signed in). Use it when the old key is
88
+ * presumed compromised.
89
+ */
90
+ signOutEverywhere?: boolean;
91
+ /**
92
+ * A pre-derived NEW identity to rotate to (from
93
+ * {@link RecoveryPhraseService.derivePendingIdentity}). Pass it when the UI
94
+ * derived + SHOWED the new phrase to the user BEFORE committing, so the SAME
95
+ * identity is the one rotated in. When omitted, a fresh identity is derived
96
+ * internally and its phrase is returned in the result.
97
+ */
98
+ pendingIdentity?: PendingIdentityResult;
99
+ }
100
+ /** Result of a successful key rotation. */
101
+ export interface RotateKeyResult {
102
+ /** The account's new (rotated) public key. */
103
+ newPublicKey: string;
104
+ /**
105
+ * The NEW identity's recovery phrase. It MUST be surfaced to the user so they
106
+ * can back up the rotated key — if lost, the new identity is unrecoverable.
107
+ */
108
+ newPhrase: string;
109
+ /** The recovery phrase split into its individual words. */
110
+ words: string[];
111
+ /**
112
+ * Present (and `true`) only when the server rotated successfully but the new
113
+ * key could NOT be persisted on-device. The account key IS the new one
114
+ * server-side, so the user must re-import it from `newPhrase`; the caller
115
+ * should surface a recovery prompt. Omitted on full success.
116
+ */
117
+ localPersistFailed?: true;
118
+ }
70
119
  /**
71
120
  * Derive a user's Oxy DID from their stable account id.
72
121
  * `did:web:oxy.so:u:<userId>`.
@@ -126,6 +175,52 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
126
175
  * (`AuthMethodEntry.credentialId`).
127
176
  */
128
177
  removePasskey(credentialId: string): Promise<LinkAuthMethodResult>;
178
+ /**
179
+ * Rotate the account's identity key: derive a brand-new keypair, prove
180
+ * control of the CURRENT key, and have the server ATOMICALLY replace the old
181
+ * key with the new one.
182
+ *
183
+ * The rotation is an atomic REPLACE on the server (never remove-then-add), so
184
+ * it never passes through a zero-auth-method state and is independent of the
185
+ * unlink guards. Because control of the current key is PROVEN (from
186
+ * SecureStore in `'device'` mode, or a recovery-phrase re-derivation in
187
+ * `'phrase'` mode), even the LAST remaining credential can be replaced.
188
+ *
189
+ * Ordering (safety-critical): the new key is persisted on-device ONLY AFTER
190
+ * the server confirms the swap. Persisting earlier would clobber the local
191
+ * key while the server still trusts the old one, locking the device out.
192
+ *
193
+ * Ambiguous-network-failure guard: if the `complete` response is lost
194
+ * (request sent, no reply), the swap may already have applied server-side.
195
+ * Before surfacing the error we reconcile against the derived DID document —
196
+ * if it already advertises the new key, the rotation is treated as done.
197
+ *
198
+ * NOTE: the UI is responsible for showing `newPhrase` to the user. For a
199
+ * "show-phrase-first" flow, derive the identity up front via
200
+ * {@link RecoveryPhraseService.derivePendingIdentity}, display it, then pass
201
+ * it back as `options.pendingIdentity` so the SAME identity is committed.
202
+ *
203
+ * @throws when no user is authenticated, when `proof: 'phrase'` is given
204
+ * without a `phrase`, when `proof: 'device'` runs with no on-device key,
205
+ * or when the rotation does not complete.
206
+ */
207
+ rotateKey(options: RotateKeyOptions): Promise<RotateKeyResult>;
208
+ /**
209
+ * Reconciliation probe for the rotation ambiguous-failure guard: fetch the
210
+ * account's derived DID document (uncached) and report whether it already
211
+ * advertises `newPublicKey` as a verification method — i.e. whether the swap
212
+ * already landed server-side. A failed probe returns `false` (unconfirmed),
213
+ * so the caller surfaces the original network error.
214
+ *
215
+ * Uses the DID document rather than `GET /auth/methods` because the latter
216
+ * intentionally does NOT expose raw public keys, whereas the DID's
217
+ * `verificationMethod[].publicKeyHex` is derived live from the account's
218
+ * current key — so it reflects a completed rotation immediately.
219
+ *
220
+ * Internal helper (leading underscore); public rather than `private` for the
221
+ * same TS4094 reason as {@link _invalidateIdentityCaches}.
222
+ */
223
+ _rotationAlreadyApplied(userId: string, newPublicKey: string): Promise<boolean>;
129
224
  /**
130
225
  * Sign a record with the on-device identity key, WITHOUT publishing it.
131
226
  * The subject is the current user's DID. NATIVE-ONLY (requires a stored
@@ -0,0 +1,129 @@
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 { BackupStatusResponse } from '@oxyhq/contracts';
28
+ import type { OxyServicesBase } from '../OxyServices.base';
29
+ export declare function OxyServicesIdentityBackupMixin<T extends typeof OxyServicesBase>(Base: T): {
30
+ new (...args: any[]): {
31
+ /**
32
+ * Derive the backup key material from the recovery phrase, encrypt the
33
+ * identity's `{privateKey, publicKey, createdAt}` with it, and upload the
34
+ * ciphertext + raw `lookupId` (`POST /identity/backup`, bearer). The server
35
+ * stores only `sha256(lookupId)` + the ciphertext. Idempotent per user: a
36
+ * re-upload REPLACES the prior backup (upsert by user id).
37
+ *
38
+ * The identity is derived from the PHRASE (not read from SecureStore), so
39
+ * this works cross-platform and does not require an on-device key.
40
+ *
41
+ * @param phrase - The identity's BIP-39 recovery phrase.
42
+ * @returns The post-write backup status (`{ exists: true, publicKeyHint, createdAt }`).
43
+ */
44
+ createEncryptedBackup(phrase: string): Promise<BackupStatusResponse>;
45
+ /**
46
+ * Whether the authenticated user has a stored encrypted backup, plus the
47
+ * non-sensitive hint + timestamp when one exists (`GET /identity/backup/status`,
48
+ * bearer). Returns no ciphertext and no locator.
49
+ */
50
+ getBackupStatus(): Promise<BackupStatusResponse>;
51
+ /**
52
+ * Delete the authenticated user's stored backup (`DELETE /identity/backup`,
53
+ * bearer). Idempotent — deleting a non-existent backup still succeeds.
54
+ */
55
+ deleteBackup(): Promise<{
56
+ success: boolean;
57
+ }>;
58
+ /**
59
+ * Restore an identity from its encrypted off-device backup using ONLY the
60
+ * recovery phrase: re-derive `{backupKey, lookupId}`, fetch the envelope by
61
+ * `lookupId` (`GET /identity/backup/:lookupId`, PUBLIC — the 256-bit locator
62
+ * is the protection), decrypt + authenticate locally, then persist the key.
63
+ *
64
+ * NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
65
+ * refuses to clobber a DIFFERENT existing on-device identity unless
66
+ * `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
67
+ * propagates to the caller (never swallowed) so the UI can confirm before
68
+ * overwriting.
69
+ *
70
+ * @param phrase - The identity's BIP-39 recovery phrase.
71
+ * @param options.overwrite - Replace a different existing on-device identity.
72
+ * @returns The restored identity's public key.
73
+ * @throws if the phrase is invalid, no backup exists (404), the ciphertext
74
+ * fails authentication (tamper), or an existing identity blocks the import.
75
+ */
76
+ restoreFromEncryptedBackup(phrase: string, options?: {
77
+ overwrite?: boolean;
78
+ }): Promise<string>;
79
+ httpService: import("../HttpService").HttpService;
80
+ cloudURL: string;
81
+ config: import("../OxyServices.base").OxyConfig;
82
+ __resetTokensForTests(): void;
83
+ makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
84
+ getBaseURL(): string;
85
+ getClient(): import("../HttpService").HttpService;
86
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
87
+ getMetrics(): {
88
+ totalRequests: number;
89
+ successfulRequests: number;
90
+ failedRequests: number;
91
+ cacheHits: number;
92
+ cacheMisses: number;
93
+ averageResponseTime: number;
94
+ };
95
+ clearCache(): void;
96
+ clearCacheEntry(key: string): void;
97
+ clearCacheByPrefix(prefix: string): number;
98
+ getCacheStats(): {
99
+ size: number;
100
+ hits: number;
101
+ misses: number;
102
+ hitRate: number;
103
+ };
104
+ getCloudURL(): string;
105
+ setTokens(accessToken: string): void;
106
+ clearTokens(): void;
107
+ onTokensChanged(listener: (accessToken: string | null) => void): () => void;
108
+ _cachedUserId: string | null | undefined;
109
+ _cachedAccessToken: string | null;
110
+ getCurrentUserId(): string | null;
111
+ hasValidToken(): boolean;
112
+ getAccessToken(): string | null;
113
+ getAccessTokenExpiry(): number | null;
114
+ waitForAuth(timeoutMs?: number): Promise<boolean>;
115
+ withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
116
+ maxRetries?: number;
117
+ retryDelay?: number;
118
+ authTimeoutMs?: number;
119
+ }): Promise<T_1>;
120
+ validate(): Promise<boolean>;
121
+ handleError(error: unknown): Error;
122
+ healthCheck(): Promise<{
123
+ status: string;
124
+ users?: number;
125
+ timestamp?: string;
126
+ [key: string]: any;
127
+ }>;
128
+ };
129
+ } & T;
@@ -5,6 +5,25 @@ import type { User, Notification, NotificationPreferences, UserPreferences, Sear
5
5
  import type { UserNameResponse, UserProfileUpdate, RecommendationRequest, RecommendationItem } from '@oxyhq/contracts';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
7
  import { type PaginationParams } from '../utils/apiUtils';
8
+ /**
9
+ * Response of the single follow/unfollow toggle route
10
+ * (`POST /users/:id/follow` and `DELETE /users/:id/follow`). The route reports a
11
+ * status message, which side of the toggle was applied, and the post-write
12
+ * follower/following counts for the affected users.
13
+ */
14
+ export interface FollowMutationResult {
15
+ /** Human-readable status message. */
16
+ message: string;
17
+ /** Which side of the toggle was applied, when reported by the route. */
18
+ action?: 'follow' | 'unfollow';
19
+ /** Post-write counts, when reported by the route. */
20
+ counts?: {
21
+ /** The target user's follower count after the write. */
22
+ followers: number;
23
+ /** The viewer's following count after the write. */
24
+ following: number;
25
+ };
26
+ }
8
27
  /** Per-user outcome returned by `POST /users/follow/bulk`. */
9
28
  export interface BulkFollowEntry {
10
29
  /** The user ID that was processed. */
@@ -309,10 +328,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
309
328
  * UI (the "follow resets after navigating away and back" bug).
310
329
  * `clearCacheEntry` deletes every identity-scoped variant of the key.
311
330
  */
312
- followUser(userId: string): Promise<{
313
- success: boolean;
314
- message: string;
315
- }>;
331
+ followUser(userId: string): Promise<FollowMutationResult>;
316
332
  /**
317
333
  * Follow multiple users in a single request.
318
334
  *
@@ -334,16 +350,30 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
334
350
  /**
335
351
  * Unfollow a user
336
352
  */
337
- unfollowUser(userId: string): Promise<{
338
- success: boolean;
339
- message: string;
340
- }>;
353
+ unfollowUser(userId: string): Promise<FollowMutationResult>;
341
354
  /**
342
355
  * Get follow status
343
356
  */
344
357
  getFollowStatus(userId: string): Promise<{
345
358
  isFollowing: boolean;
346
359
  }>;
360
+ /**
361
+ * Resolve the viewer's follow status for MANY users in one round-trip per
362
+ * chunk. Built for list UIs (a page of `FollowButton`s) that would otherwise
363
+ * fire one `getFollowStatus` per button (the classic N+1).
364
+ *
365
+ * Ids are deduplicated and validated (empty/blank ids dropped), split into
366
+ * chunks of {@link FOLLOW_STATUS_CHUNK_SIZE} (the server's bulk cap), and
367
+ * POSTed to `/users/follow-status/bulk` as `{ userIds }`. The per-chunk
368
+ * `{ statuses }` maps are merged into one `Record<string, boolean>` covering
369
+ * every requested id — ids the viewer does not follow come back `false`.
370
+ *
371
+ * Uncached (`{ cache: false }`): the UI store owns follow-status freshness
372
+ * and writes optimistically on every mutation, so an SDK cache here would
373
+ * serve a stale status right after a follow/unfollow. An empty/whitespace-
374
+ * only input resolves immediately with `{}` and performs no network call.
375
+ */
376
+ getFollowStatuses(userIds: string[]): Promise<Record<string, boolean>>;
347
377
  /**
348
378
  * Get user followers
349
379
  */
@@ -8,6 +8,7 @@ import { OxyServicesBase } from '../OxyServices.base';
8
8
  import { OxyServicesAuthMixin } from './OxyServices.auth';
9
9
  import { OxyServicesUserMixin } from './OxyServices.user';
10
10
  import { OxyServicesIdentityMixin } from './OxyServices.identity';
11
+ import { OxyServicesIdentityBackupMixin } from './OxyServices.identityBackup';
11
12
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
12
13
  import { OxyServicesLanguageMixin } from './OxyServices.language';
13
14
  import { OxyServicesPaymentMixin } from './OxyServices.payment';
@@ -37,7 +38,7 @@ import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
37
38
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
38
39
  * are visible without a cast.
39
40
  */
40
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
41
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
41
42
  /**
42
43
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
43
44
  * a new constructor that augments its input; reducing across the pipeline