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