@oxyhq/core 12.2.0 → 12.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/ar-SA.json +2 -0
- package/dist/cjs/i18n/locales/ca-ES.json +2 -0
- package/dist/cjs/i18n/locales/de-DE.json +2 -0
- package/dist/cjs/i18n/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/fr-FR.json +2 -0
- package/dist/cjs/i18n/locales/it-IT.json +2 -0
- package/dist/cjs/i18n/locales/ja-JP.json +2 -0
- package/dist/cjs/i18n/locales/ko-KR.json +2 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +2 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +2 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +2 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +2 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +2 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +2 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +2 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +2 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +2 -0
- package/dist/cjs/i18n/locales/pt-PT.json +2 -0
- package/dist/cjs/i18n/locales/zh-CN.json +2 -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/ar-SA.json +2 -0
- package/dist/esm/i18n/locales/ca-ES.json +2 -0
- package/dist/esm/i18n/locales/de-DE.json +2 -0
- package/dist/esm/i18n/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/fr-FR.json +2 -0
- package/dist/esm/i18n/locales/it-IT.json +2 -0
- package/dist/esm/i18n/locales/ja-JP.json +2 -0
- package/dist/esm/i18n/locales/ko-KR.json +2 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +2 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +2 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +2 -0
- package/dist/esm/i18n/locales/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +2 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +2 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +2 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +2 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +2 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +2 -0
- package/dist/esm/i18n/locales/pt-PT.json +2 -0
- package/dist/esm/i18n/locales/zh-CN.json +2 -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/ar-SA.json +2 -0
- package/src/i18n/locales/ca-ES.json +2 -0
- package/src/i18n/locales/de-DE.json +2 -0
- package/src/i18n/locales/en-US.json +7 -0
- package/src/i18n/locales/es-ES.json +7 -0
- package/src/i18n/locales/fr-FR.json +2 -0
- package/src/i18n/locales/it-IT.json +2 -0
- package/src/i18n/locales/ja-JP.json +2 -0
- package/src/i18n/locales/ko-KR.json +2 -0
- package/src/i18n/locales/pt-PT.json +2 -0
- package/src/i18n/locales/zh-CN.json +2 -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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import * as bip39 from 'bip39';
|
|
11
11
|
import { KeyManager } from './keyManager';
|
|
12
|
+
import { hkdfSha256 } from './kdf';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Convert Uint8Array or array-like to hexadecimal string
|
|
@@ -22,12 +23,64 @@ function toHex(data: Uint8Array | ArrayLike<number>): string {
|
|
|
22
23
|
.join('');
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** UTF-8 encode an ASCII label to bytes (for HKDF salt/info). */
|
|
27
|
+
function utf8(label: string): Uint8Array {
|
|
28
|
+
return new TextEncoder().encode(label);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* HKDF context tag for the encrypted-backup key schedule (b3 Feature 1). Used as
|
|
33
|
+
* the HKDF `salt`; distinct from any other Oxy key-derivation salt so the backup
|
|
34
|
+
* key schedule is independent. Versioned so a future scheme change is a new tag.
|
|
35
|
+
*/
|
|
36
|
+
export const BACKUP_KDF_SALT = 'oxy-identity-backup-v1';
|
|
37
|
+
/** HKDF `info` label that derives the symmetric AEAD key from the seed. */
|
|
38
|
+
export const BACKUP_KDF_ENCRYPTION_INFO = 'oxy-backup-encryption-key';
|
|
39
|
+
/** HKDF `info` label that derives the (server-hashed) backup locator from the seed. */
|
|
40
|
+
export const BACKUP_KDF_LOOKUP_INFO = 'oxy-backup-lookup-id';
|
|
41
|
+
/** Byte length of both the derived backup key and the derived lookup id (256-bit). */
|
|
42
|
+
export const BACKUP_MATERIAL_LENGTH = 32;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The two pieces of key material derived from a recovery phrase for the
|
|
46
|
+
* encrypted off-device backup, kept strictly separate by HKDF domain separation.
|
|
47
|
+
*/
|
|
48
|
+
export interface BackupMaterial {
|
|
49
|
+
/**
|
|
50
|
+
* The 32-byte symmetric key handed to `encryptAead`/`decryptAead`. NEVER
|
|
51
|
+
* leaves the device — the server sees only ciphertext produced with it.
|
|
52
|
+
*/
|
|
53
|
+
backupKey: Uint8Array;
|
|
54
|
+
/**
|
|
55
|
+
* The 256-bit backup locator, hex. Sent to the server, which stores ONLY
|
|
56
|
+
* `sha256(lookupId)` — so possession of this value (which itself requires the
|
|
57
|
+
* full seed to compute) is what locates a backup, and the server can never
|
|
58
|
+
* recompute it from what it stores.
|
|
59
|
+
*/
|
|
60
|
+
lookupId: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
export interface RecoveryPhraseResult {
|
|
26
64
|
phrase: string;
|
|
27
65
|
words: string[];
|
|
28
66
|
publicKey: string;
|
|
29
67
|
}
|
|
30
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A freshly-derived identity that has NOT been persisted to secure storage.
|
|
71
|
+
*
|
|
72
|
+
* Unlike {@link RecoveryPhraseResult} this also exposes the `privateKey`, because
|
|
73
|
+
* the caller must be able to sign with (or later persist) the material itself —
|
|
74
|
+
* the whole point of a "pending" identity is that nothing is committed until an
|
|
75
|
+
* external step (e.g. a server-confirmed key rotation) succeeds.
|
|
76
|
+
*/
|
|
77
|
+
export interface PendingIdentityResult {
|
|
78
|
+
phrase: string;
|
|
79
|
+
words: string[];
|
|
80
|
+
privateKey: string;
|
|
81
|
+
publicKey: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
31
84
|
export interface GenerateIdentityOptions {
|
|
32
85
|
/**
|
|
33
86
|
* Pass `true` to allow overwriting an existing on-device identity.
|
|
@@ -103,6 +156,86 @@ export class RecoveryPhraseService {
|
|
|
103
156
|
};
|
|
104
157
|
}
|
|
105
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Derive a brand-new identity + recovery phrase WITHOUT persisting anything.
|
|
161
|
+
*
|
|
162
|
+
* Pure: same derivation as {@link generateIdentityWithRecovery} (128-bit
|
|
163
|
+
* mnemonic → seed → first 32 bytes as the secp256k1 private key) but it stops
|
|
164
|
+
* BEFORE `KeyManager.importKeyPair`, so no on-device identity is touched. The
|
|
165
|
+
* caller decides if/when to commit the material (e.g. only after a server
|
|
166
|
+
* confirms a key rotation). Works on web too — it never reads or writes secure
|
|
167
|
+
* storage.
|
|
168
|
+
*
|
|
169
|
+
* The 12-word `phrase` MUST be shown to the user before the identity is
|
|
170
|
+
* committed anywhere — if it is lost the account becomes unrecoverable.
|
|
171
|
+
*/
|
|
172
|
+
static async derivePendingIdentity(): Promise<PendingIdentityResult> {
|
|
173
|
+
const mnemonic = bip39.generateMnemonic(128);
|
|
174
|
+
const seed = await bip39.mnemonicToSeed(mnemonic);
|
|
175
|
+
const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
|
|
176
|
+
const privateKey = toHex(seedSlice);
|
|
177
|
+
const publicKey = KeyManager.derivePublicKey(privateKey);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
phrase: mnemonic,
|
|
181
|
+
words: mnemonic.split(' '),
|
|
182
|
+
privateKey,
|
|
183
|
+
publicKey,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Derive the private key from a recovery phrase WITHOUT storing it.
|
|
189
|
+
*
|
|
190
|
+
* The private-key counterpart of {@link derivePublicKeyFromPhrase}. Used to
|
|
191
|
+
* re-derive a key in memory (e.g. to sign a rotation proof with the current
|
|
192
|
+
* key when the device has no SecureStore copy). Never persists — the returned
|
|
193
|
+
* material lives only in the caller's memory.
|
|
194
|
+
*/
|
|
195
|
+
static async derivePrivateKeyFromPhrase(phrase: string): Promise<string> {
|
|
196
|
+
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
197
|
+
|
|
198
|
+
if (!bip39.validateMnemonic(normalizedPhrase)) {
|
|
199
|
+
throw new Error('Invalid recovery phrase');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const seed = await bip39.mnemonicToSeed(normalizedPhrase);
|
|
203
|
+
const seedSlice = seed.subarray ? seed.subarray(0, 32) : seed.slice(0, 32);
|
|
204
|
+
return toHex(seedSlice);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Derive the encrypted-backup key material from a recovery phrase (b3 Feature
|
|
209
|
+
* 1). PURE and additive — it does NOT touch the frozen phrase→privateKey
|
|
210
|
+
* derivation ({@link derivePrivateKeyFromPhrase} slices the first 32 seed
|
|
211
|
+
* bytes) and never reads or writes secure storage.
|
|
212
|
+
*
|
|
213
|
+
* Both outputs are derived from the FULL 64-byte BIP-39 seed via HKDF-SHA256
|
|
214
|
+
* with domain-separated `info` labels, so the domain separation is real: a
|
|
215
|
+
* device compromise that leaks only the raw 32-byte private key can compute
|
|
216
|
+
* NEITHER the backup key nor the lookup id (both need the whole seed). Locating
|
|
217
|
+
* AND decrypting a backup therefore requires the recovery phrase.
|
|
218
|
+
*
|
|
219
|
+
* @param phrase - The BIP-39 recovery phrase (validated + normalized here).
|
|
220
|
+
* @returns `{ backupKey, lookupId }` — the AEAD key (kept local) and the hex
|
|
221
|
+
* locator (uploaded; server stores only its hash).
|
|
222
|
+
* @throws if the phrase is not a valid BIP-39 mnemonic.
|
|
223
|
+
*/
|
|
224
|
+
static async deriveBackupMaterial(phrase: string): Promise<BackupMaterial> {
|
|
225
|
+
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
226
|
+
|
|
227
|
+
if (!bip39.validateMnemonic(normalizedPhrase)) {
|
|
228
|
+
throw new Error('Invalid recovery phrase. Please check the words and try again.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const seed = await bip39.mnemonicToSeed(normalizedPhrase);
|
|
232
|
+
const salt = utf8(BACKUP_KDF_SALT);
|
|
233
|
+
const backupKey = hkdfSha256(seed, salt, utf8(BACKUP_KDF_ENCRYPTION_INFO), BACKUP_MATERIAL_LENGTH);
|
|
234
|
+
const lookupId = toHex(hkdfSha256(seed, salt, utf8(BACKUP_KDF_LOOKUP_INFO), BACKUP_MATERIAL_LENGTH));
|
|
235
|
+
|
|
236
|
+
return { backupKey, lookupId };
|
|
237
|
+
}
|
|
238
|
+
|
|
106
239
|
/**
|
|
107
240
|
* Restore an identity from a recovery phrase.
|
|
108
241
|
*
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "أنشئ حسابك",
|
|
54
|
+
"subtitle": "هوية واحدة لكل النظام البيئي.",
|
|
53
55
|
"commonsHeadline": "أنشئ هويتك في Commons",
|
|
54
56
|
"commonsExplainer": "Commons هي هوية Oxy الخاصة بك بحفظ ذاتي كامل — تطبيق واحد يعمل في كل مكان.",
|
|
55
57
|
"createInCommons": "أنشئ هويتك في Commons",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "Crea el teu compte",
|
|
54
|
+
"subtitle": "Una identitat per a tot l'ecosistema.",
|
|
53
55
|
"commonsHeadline": "Crea la teva identitat a Commons",
|
|
54
56
|
"commonsExplainer": "Commons és la teva identitat d'Oxy amb autocustòdia completa: una app que funciona a tot arreu.",
|
|
55
57
|
"createInCommons": "Crea la teva identitat a Commons",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "Erstellen Sie Ihr Konto",
|
|
54
|
+
"subtitle": "Eine Identität für das gesamte Ökosystem.",
|
|
53
55
|
"commonsHeadline": "Erstellen Sie Ihre Identität in Commons",
|
|
54
56
|
"commonsExplainer": "Commons ist Ihre vollständig selbstverwaltete Oxy-Identität – eine App, überall einsetzbar.",
|
|
55
57
|
"createInCommons": "Erstellen Sie Ihre Identität in Commons",
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"signup": {
|
|
60
|
+
"title": "Create your account",
|
|
61
|
+
"subtitle": "One identity for the whole ecosystem.",
|
|
60
62
|
"commonsHeadline": "Create your identity in Commons",
|
|
61
63
|
"commonsExplainer": "Commons is your full self-custody Oxy identity — one app, works everywhere.",
|
|
62
64
|
"createInCommons": "Create your identity in Commons",
|
|
@@ -70,6 +72,9 @@
|
|
|
70
72
|
"backToSignInLink": "Already have an account? Sign in",
|
|
71
73
|
"hubExplainer": "Create your Oxy ID in a secure window, then come right back.",
|
|
72
74
|
"continueInWindow": "Continue in a new window",
|
|
75
|
+
"toasts": {
|
|
76
|
+
"passkeyCreateFailed": "We couldn't create your account. Please try again."
|
|
77
|
+
},
|
|
73
78
|
"welcome": {
|
|
74
79
|
"title": "Welcome to Oxy!",
|
|
75
80
|
"subtitle": "Let's create your account in just a few steps",
|
|
@@ -372,6 +377,8 @@
|
|
|
372
377
|
"toasts": {
|
|
373
378
|
"switchSuccess": "Account switched successfully!",
|
|
374
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.",
|
|
375
382
|
"removeSuccess": "Account removed successfully!",
|
|
376
383
|
"removeFailed": "There was a problem removing the account. Please try again.",
|
|
377
384
|
"signOutAllSuccess": "All accounts signed out successfully!",
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"signup": {
|
|
60
|
+
"title": "Crea tu cuenta",
|
|
61
|
+
"subtitle": "Una identidad para todo el ecosistema.",
|
|
60
62
|
"commonsHeadline": "Crea tu identidad en Commons",
|
|
61
63
|
"commonsExplainer": "Commons es tu identidad de Oxy con autocustodia completa: una app que funciona en todas partes.",
|
|
62
64
|
"createInCommons": "Crea tu identidad en Commons",
|
|
@@ -70,6 +72,9 @@
|
|
|
70
72
|
"backToSignInLink": "¿Ya tienes cuenta? Inicia sesión",
|
|
71
73
|
"hubExplainer": "Crea tu Oxy ID en una ventana segura y vuelve enseguida.",
|
|
72
74
|
"continueInWindow": "Continuar en una ventana nueva",
|
|
75
|
+
"toasts": {
|
|
76
|
+
"passkeyCreateFailed": "No pudimos crear tu cuenta. Inténtalo de nuevo."
|
|
77
|
+
},
|
|
73
78
|
"welcome": {
|
|
74
79
|
"title": "¡Te damos la bienvenida a Oxy!",
|
|
75
80
|
"subtitle": "Crea tu cuenta en pocos pasos",
|
|
@@ -570,6 +575,8 @@
|
|
|
570
575
|
"toasts": {
|
|
571
576
|
"switchSuccess": "¡Cuenta cambiada correctamente!",
|
|
572
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.",
|
|
573
580
|
"removeSuccess": "¡Cuenta eliminada correctamente!",
|
|
574
581
|
"removeFailed": "Hubo un problema al eliminar la cuenta. Inténtalo de nuevo.",
|
|
575
582
|
"signOutAllSuccess": "¡Todas las cuentas cerradas correctamente!",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "Créez votre compte",
|
|
54
|
+
"subtitle": "Une seule identité pour tout l'écosystème.",
|
|
53
55
|
"commonsHeadline": "Créez votre identité dans Commons",
|
|
54
56
|
"commonsExplainer": "Commons est votre identité Oxy en autoconservation complète : une seule app, partout.",
|
|
55
57
|
"createInCommons": "Créez votre identité dans Commons",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "Crea il tuo account",
|
|
54
|
+
"subtitle": "Un'unica identità per tutto l'ecosistema.",
|
|
53
55
|
"commonsHeadline": "Crea la tua identità in Commons",
|
|
54
56
|
"commonsExplainer": "Commons è la tua identità Oxy in piena autocustodia: un'unica app, ovunque.",
|
|
55
57
|
"createInCommons": "Crea la tua identità in Commons",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "アカウントを作成",
|
|
54
|
+
"subtitle": "エコシステム全体で使える1つのアイデンティティ。",
|
|
53
55
|
"commonsHeadline": "Commonsであなたのアイデンティティを作成",
|
|
54
56
|
"commonsExplainer": "Commonsは完全に自己管理できるあなたのOxy IDです。1つのアプリでどこでも使えます。",
|
|
55
57
|
"createInCommons": "Commonsであなたのアイデンティティを作成",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "계정 만들기",
|
|
54
|
+
"subtitle": "생태계 전체를 위한 하나의 아이덴티티.",
|
|
53
55
|
"commonsHeadline": "Commons에서 나의 아이덴티티 만들기",
|
|
54
56
|
"commonsExplainer": "Commons는 완전한 자기 보관 방식의 Oxy 아이덴티티입니다. 하나의 앱으로 어디서나 사용하세요.",
|
|
55
57
|
"createInCommons": "Commons에서 나의 아이덴티티 만들기",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"signup": {
|
|
53
|
+
"title": "Crie a sua conta",
|
|
54
|
+
"subtitle": "Uma identidade para todo o ecossistema.",
|
|
53
55
|
"commonsHeadline": "Crie a sua identidade no Commons",
|
|
54
56
|
"commonsExplainer": "O Commons é a sua identidade Oxy com autocustódia total: uma app que funciona em todo o lado.",
|
|
55
57
|
"createInCommons": "Crie a sua identidade no Commons",
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type {
|
|
|
53
53
|
BulkFollowResult,
|
|
54
54
|
BulkUnfollowEntry,
|
|
55
55
|
BulkUnfollowResult,
|
|
56
|
+
FollowMutationResult,
|
|
56
57
|
ViewerGraph,
|
|
57
58
|
} from './mixins/OxyServices.user';
|
|
58
59
|
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
|
|
@@ -173,6 +174,9 @@ export type {
|
|
|
173
174
|
VerifyRecordResult,
|
|
174
175
|
VerifyDomainResult,
|
|
175
176
|
RemoveDomainResult,
|
|
177
|
+
RotateKeyProof,
|
|
178
|
+
RotateKeyOptions,
|
|
179
|
+
RotateKeyResult,
|
|
176
180
|
} from './mixins/OxyServices.identity';
|
|
177
181
|
|
|
178
182
|
// ---------------------------------------------------------------------------
|
|
@@ -242,7 +246,18 @@ export type { KeyPair } from './crypto/keyManager';
|
|
|
242
246
|
export { SignatureService } from './crypto/signatureService';
|
|
243
247
|
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
244
248
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
245
|
-
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
249
|
+
export type { RecoveryPhraseResult, PendingIdentityResult, BackupMaterial } from './crypto/recoveryPhrase';
|
|
250
|
+
|
|
251
|
+
// Low-level crypto primitives (b3 Phase 0 — encrypted backup + device transfer)
|
|
252
|
+
export { hkdfSha256 } from './crypto/kdf';
|
|
253
|
+
export {
|
|
254
|
+
encryptAead,
|
|
255
|
+
decryptAead,
|
|
256
|
+
AEAD_KEY_LENGTH,
|
|
257
|
+
AEAD_NONCE_LENGTH,
|
|
258
|
+
} from './crypto/aead';
|
|
259
|
+
export type { AeadResult } from './crypto/aead';
|
|
260
|
+
export { deriveSharedSecret } from './crypto/ecdh';
|
|
246
261
|
|
|
247
262
|
// ---------------------------------------------------------------------------
|
|
248
263
|
// Devices
|
|
@@ -29,12 +29,18 @@ import type {
|
|
|
29
29
|
DomainVerificationInstructions,
|
|
30
30
|
ExportBundle,
|
|
31
31
|
OxySignedRecordType,
|
|
32
|
+
RotateKeyChallengeResponse,
|
|
33
|
+
RotateKeyCompleteResponse,
|
|
32
34
|
SignedRecordEnvelope,
|
|
33
35
|
VerifiedDomain,
|
|
34
36
|
} from '@oxyhq/contracts';
|
|
37
|
+
import { signMessage } from '@oxyhq/protocol';
|
|
35
38
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
36
39
|
import { KeyManager } from '../crypto/keyManager';
|
|
37
40
|
import { SignatureService } from '../crypto/signatureService';
|
|
41
|
+
import { RecoveryPhraseService, type PendingIdentityResult } from '../crypto/recoveryPhrase';
|
|
42
|
+
import { isWeb } from '../utils/platform';
|
|
43
|
+
import { logger } from '../logger';
|
|
38
44
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
39
45
|
|
|
40
46
|
/**
|
|
@@ -93,6 +99,57 @@ export interface RemoveDomainResult {
|
|
|
93
99
|
success: boolean;
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
/** How the caller proves control of the CURRENT key during a key rotation. */
|
|
103
|
+
export type RotateKeyProof = 'device' | 'phrase';
|
|
104
|
+
|
|
105
|
+
/** Options for {@link OxyServicesIdentityMixin.rotateKey}. */
|
|
106
|
+
export interface RotateKeyOptions {
|
|
107
|
+
/**
|
|
108
|
+
* How to prove control of the CURRENT key:
|
|
109
|
+
* - `'device'`: sign with the on-device SecureStore key (native-only).
|
|
110
|
+
* - `'phrase'`: re-derive the current key from the entered recovery `phrase`
|
|
111
|
+
* and sign with it. This works even when the device holds NO SecureStore
|
|
112
|
+
* copy of the key — it is how the LAST remaining credential is replaced.
|
|
113
|
+
*/
|
|
114
|
+
proof: RotateKeyProof;
|
|
115
|
+
/** The CURRENT identity's recovery phrase. Required when `proof: 'phrase'`. */
|
|
116
|
+
phrase?: string;
|
|
117
|
+
/**
|
|
118
|
+
* When true, all OTHER active sessions are revoked after a successful
|
|
119
|
+
* rotation (the rotating device stays signed in). Use it when the old key is
|
|
120
|
+
* presumed compromised.
|
|
121
|
+
*/
|
|
122
|
+
signOutEverywhere?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* A pre-derived NEW identity to rotate to (from
|
|
125
|
+
* {@link RecoveryPhraseService.derivePendingIdentity}). Pass it when the UI
|
|
126
|
+
* derived + SHOWED the new phrase to the user BEFORE committing, so the SAME
|
|
127
|
+
* identity is the one rotated in. When omitted, a fresh identity is derived
|
|
128
|
+
* internally and its phrase is returned in the result.
|
|
129
|
+
*/
|
|
130
|
+
pendingIdentity?: PendingIdentityResult;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Result of a successful key rotation. */
|
|
134
|
+
export interface RotateKeyResult {
|
|
135
|
+
/** The account's new (rotated) public key. */
|
|
136
|
+
newPublicKey: string;
|
|
137
|
+
/**
|
|
138
|
+
* The NEW identity's recovery phrase. It MUST be surfaced to the user so they
|
|
139
|
+
* can back up the rotated key — if lost, the new identity is unrecoverable.
|
|
140
|
+
*/
|
|
141
|
+
newPhrase: string;
|
|
142
|
+
/** The recovery phrase split into its individual words. */
|
|
143
|
+
words: string[];
|
|
144
|
+
/**
|
|
145
|
+
* Present (and `true`) only when the server rotated successfully but the new
|
|
146
|
+
* key could NOT be persisted on-device. The account key IS the new one
|
|
147
|
+
* server-side, so the user must re-import it from `newPhrase`; the caller
|
|
148
|
+
* should surface a recovery prompt. Omitted on full success.
|
|
149
|
+
*/
|
|
150
|
+
localPersistFailed?: true;
|
|
151
|
+
}
|
|
152
|
+
|
|
96
153
|
/**
|
|
97
154
|
* Derive a user's Oxy DID from their stable account id.
|
|
98
155
|
* `did:web:oxy.so:u:<userId>`.
|
|
@@ -254,6 +311,199 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
254
311
|
}
|
|
255
312
|
}
|
|
256
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Rotate the account's identity key: derive a brand-new keypair, prove
|
|
316
|
+
* control of the CURRENT key, and have the server ATOMICALLY replace the old
|
|
317
|
+
* key with the new one.
|
|
318
|
+
*
|
|
319
|
+
* The rotation is an atomic REPLACE on the server (never remove-then-add), so
|
|
320
|
+
* it never passes through a zero-auth-method state and is independent of the
|
|
321
|
+
* unlink guards. Because control of the current key is PROVEN (from
|
|
322
|
+
* SecureStore in `'device'` mode, or a recovery-phrase re-derivation in
|
|
323
|
+
* `'phrase'` mode), even the LAST remaining credential can be replaced.
|
|
324
|
+
*
|
|
325
|
+
* Ordering (safety-critical): the new key is persisted on-device ONLY AFTER
|
|
326
|
+
* the server confirms the swap. Persisting earlier would clobber the local
|
|
327
|
+
* key while the server still trusts the old one, locking the device out.
|
|
328
|
+
*
|
|
329
|
+
* Ambiguous-network-failure guard: if the `complete` response is lost
|
|
330
|
+
* (request sent, no reply), the swap may already have applied server-side.
|
|
331
|
+
* Before surfacing the error we reconcile against the derived DID document —
|
|
332
|
+
* if it already advertises the new key, the rotation is treated as done.
|
|
333
|
+
*
|
|
334
|
+
* NOTE: the UI is responsible for showing `newPhrase` to the user. For a
|
|
335
|
+
* "show-phrase-first" flow, derive the identity up front via
|
|
336
|
+
* {@link RecoveryPhraseService.derivePendingIdentity}, display it, then pass
|
|
337
|
+
* it back as `options.pendingIdentity` so the SAME identity is committed.
|
|
338
|
+
*
|
|
339
|
+
* @throws when no user is authenticated, when `proof: 'phrase'` is given
|
|
340
|
+
* without a `phrase`, when `proof: 'device'` runs with no on-device key,
|
|
341
|
+
* or when the rotation does not complete.
|
|
342
|
+
*/
|
|
343
|
+
async rotateKey(options: RotateKeyOptions): Promise<RotateKeyResult> {
|
|
344
|
+
try {
|
|
345
|
+
const userId = this.getCurrentUserId();
|
|
346
|
+
if (!userId) {
|
|
347
|
+
throw new Error('No authenticated user — sign in before rotating your key.');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 1. The NEW identity (in memory only). The UI may pre-derive + pre-show
|
|
351
|
+
// it and pass it back here so the phrase shown === the phrase committed.
|
|
352
|
+
const pending = options.pendingIdentity ?? (await RecoveryPhraseService.derivePendingIdentity());
|
|
353
|
+
const newPublicKey = pending.publicKey;
|
|
354
|
+
|
|
355
|
+
// 2. Resolve the OLD signing capability from the chosen proof mode.
|
|
356
|
+
let oldPublicKey: string;
|
|
357
|
+
let signWithOldKey: (message: string) => Promise<string>;
|
|
358
|
+
if (options.proof === 'phrase') {
|
|
359
|
+
const phrase = options.phrase?.trim();
|
|
360
|
+
if (!phrase) {
|
|
361
|
+
throw new Error('A recovery phrase is required for phrase-proof rotation.');
|
|
362
|
+
}
|
|
363
|
+
const oldPrivateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
|
|
364
|
+
oldPublicKey = KeyManager.derivePublicKey(oldPrivateKey);
|
|
365
|
+
signWithOldKey = (message) => signMessage(message, oldPrivateKey);
|
|
366
|
+
} else {
|
|
367
|
+
const currentPublicKey = await KeyManager.getPublicKey();
|
|
368
|
+
if (!currentPublicKey) {
|
|
369
|
+
throw new Error('No on-device identity found. Use the recovery-phrase option to rotate your key.');
|
|
370
|
+
}
|
|
371
|
+
oldPublicKey = currentPublicKey;
|
|
372
|
+
signWithOldKey = (message) => SignatureService.sign(message);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 3. Request a single-use rotate_key challenge (bearer).
|
|
376
|
+
const { challenge } = await this.makeRequest<RotateKeyChallengeResponse>(
|
|
377
|
+
'POST',
|
|
378
|
+
'/auth/rotate/challenge',
|
|
379
|
+
undefined,
|
|
380
|
+
{ cache: false },
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
// 4. Sign the rotation proofs. The OLD key proves control of the key being
|
|
384
|
+
// replaced; the NEW key proves possession of the key being rotated in
|
|
385
|
+
// (so the server never accepts a re-encoding of a key the caller does
|
|
386
|
+
// not control). Both signed byte strings MUST match the server's
|
|
387
|
+
// reconstruction exactly (this key order). The old key is canonicalized
|
|
388
|
+
// so legacy compressed encodings in Mongo still verify.
|
|
389
|
+
const timestamp = Date.now();
|
|
390
|
+
const canonicalOldPublicKey = KeyManager.canonicalPublicKey(oldPublicKey);
|
|
391
|
+
const message = JSON.stringify({
|
|
392
|
+
action: 'rotate_key',
|
|
393
|
+
userId,
|
|
394
|
+
oldPublicKey: canonicalOldPublicKey,
|
|
395
|
+
newPublicKey,
|
|
396
|
+
challenge,
|
|
397
|
+
timestamp,
|
|
398
|
+
});
|
|
399
|
+
const signature = await signWithOldKey(message);
|
|
400
|
+
const newKeyMessage = JSON.stringify({
|
|
401
|
+
action: 'rotate_key_new',
|
|
402
|
+
userId,
|
|
403
|
+
newPublicKey,
|
|
404
|
+
challenge,
|
|
405
|
+
timestamp,
|
|
406
|
+
});
|
|
407
|
+
const newKeyProof = await signMessage(newKeyMessage, pending.privateKey);
|
|
408
|
+
|
|
409
|
+
// 5. Complete the rotation. On an AMBIGUOUS failure, reconcile against the
|
|
410
|
+
// DID before deciding the rotation failed.
|
|
411
|
+
let applied = false;
|
|
412
|
+
try {
|
|
413
|
+
const result = await this.makeRequest<RotateKeyCompleteResponse>(
|
|
414
|
+
'POST',
|
|
415
|
+
'/auth/rotate/complete',
|
|
416
|
+
{
|
|
417
|
+
newPublicKey,
|
|
418
|
+
challenge,
|
|
419
|
+
signature,
|
|
420
|
+
newKeyProof,
|
|
421
|
+
timestamp,
|
|
422
|
+
...(options.signOutEverywhere ? { signOutEverywhere: true } : {}),
|
|
423
|
+
},
|
|
424
|
+
{ cache: false },
|
|
425
|
+
);
|
|
426
|
+
applied = result.success && result.publicKey.toLowerCase() === newPublicKey.toLowerCase();
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const reconciled = await this._rotationAlreadyApplied(userId, newPublicKey);
|
|
429
|
+
if (!reconciled) {
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
applied = true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!applied) {
|
|
436
|
+
throw new Error('Key rotation did not complete — your previous key is unchanged.');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 6. ONLY after the server confirms the swap, persist the new key locally,
|
|
440
|
+
// overwriting the old one. `importKeyPair({ overwrite: true })` uses the
|
|
441
|
+
// atomic persist path (backs the previous key up first). Native-only —
|
|
442
|
+
// on web the key never lived in SecureStore, so there is nothing to
|
|
443
|
+
// persist locally.
|
|
444
|
+
//
|
|
445
|
+
// If this local write fails the server key is ALREADY the new one, so
|
|
446
|
+
// we must NOT throw and swallow the phrase — the caller needs it to
|
|
447
|
+
// re-import the now-live key. Surface the result with
|
|
448
|
+
// `localPersistFailed: true` (mirrors the pendingIdentity
|
|
449
|
+
// show-phrase-first path, where the caller already holds the phrase).
|
|
450
|
+
let localPersistFailed = false;
|
|
451
|
+
if (!isWeb()) {
|
|
452
|
+
try {
|
|
453
|
+
await KeyManager.importKeyPair(pending.privateKey, { overwrite: true });
|
|
454
|
+
} catch (persistError) {
|
|
455
|
+
localPersistFailed = true;
|
|
456
|
+
logger.warn(
|
|
457
|
+
'Key rotated on the server but persisting the new key on-device failed; returning the new phrase so it can be re-imported.',
|
|
458
|
+
{ component: 'OxyServices.identity', method: 'rotateKey' },
|
|
459
|
+
persistError,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
this._invalidateIdentityCaches(userId);
|
|
465
|
+
|
|
466
|
+
return localPersistFailed
|
|
467
|
+
? { newPublicKey, newPhrase: pending.phrase, words: pending.words, localPersistFailed: true }
|
|
468
|
+
: { newPublicKey, newPhrase: pending.phrase, words: pending.words };
|
|
469
|
+
} catch (error) {
|
|
470
|
+
throw this.handleError(error);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Reconciliation probe for the rotation ambiguous-failure guard: fetch the
|
|
476
|
+
* account's derived DID document (uncached) and report whether it already
|
|
477
|
+
* advertises `newPublicKey` as a verification method — i.e. whether the swap
|
|
478
|
+
* already landed server-side. A failed probe returns `false` (unconfirmed),
|
|
479
|
+
* so the caller surfaces the original network error.
|
|
480
|
+
*
|
|
481
|
+
* Uses the DID document rather than `GET /auth/methods` because the latter
|
|
482
|
+
* intentionally does NOT expose raw public keys, whereas the DID's
|
|
483
|
+
* `verificationMethod[].publicKeyHex` is derived live from the account's
|
|
484
|
+
* current key — so it reflects a completed rotation immediately.
|
|
485
|
+
*
|
|
486
|
+
* Internal helper (leading underscore); public rather than `private` for the
|
|
487
|
+
* same TS4094 reason as {@link _invalidateIdentityCaches}.
|
|
488
|
+
*/
|
|
489
|
+
async _rotationAlreadyApplied(userId: string, newPublicKey: string): Promise<boolean> {
|
|
490
|
+
return this.makeRequest<DidDocument>(
|
|
491
|
+
'GET',
|
|
492
|
+
`/u/${encodeURIComponent(userId)}/did.json`,
|
|
493
|
+
undefined,
|
|
494
|
+
{ cache: false },
|
|
495
|
+
)
|
|
496
|
+
.then((doc) =>
|
|
497
|
+
doc.verificationMethod.some(
|
|
498
|
+
(vm) =>
|
|
499
|
+
'publicKeyHex' in vm &&
|
|
500
|
+
typeof vm.publicKeyHex === 'string' &&
|
|
501
|
+
vm.publicKeyHex.toLowerCase() === newPublicKey.toLowerCase(),
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
.catch(() => false);
|
|
505
|
+
}
|
|
506
|
+
|
|
257
507
|
/**
|
|
258
508
|
* Sign a record with the on-device identity key, WITHOUT publishing it.
|
|
259
509
|
* The subject is the current user's DID. NATIVE-ONLY (requires a stored
|