@oxy.so/core 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityMove.js +123 -0
  3. package/dist/cjs/crypto/keyManager.js +60 -21
  4. package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
  5. package/dist/cjs/i18n/locales/ar-SA.json +16 -13
  6. package/dist/cjs/i18n/locales/ca-ES.json +16 -13
  7. package/dist/cjs/i18n/locales/de-DE.json +16 -13
  8. package/dist/cjs/i18n/locales/en-US.json +16 -17
  9. package/dist/cjs/i18n/locales/es-ES.json +16 -17
  10. package/dist/cjs/i18n/locales/fr-FR.json +16 -13
  11. package/dist/cjs/i18n/locales/it-IT.json +16 -13
  12. package/dist/cjs/i18n/locales/ja-JP.json +16 -13
  13. package/dist/cjs/i18n/locales/ko-KR.json +16 -13
  14. package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
  15. package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
  16. package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
  17. package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
  18. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
  19. package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
  20. package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
  21. package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
  22. package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
  23. package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
  24. package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
  25. package/dist/cjs/i18n/locales/pt-PT.json +16 -13
  26. package/dist/cjs/i18n/locales/zh-CN.json +16 -13
  27. package/dist/cjs/index.js +38 -4
  28. package/dist/cjs/mixins/OxyServices.auth.js +14 -4
  29. package/dist/cjs/mixins/OxyServices.identity.js +14 -0
  30. package/dist/cjs/session/accountDialogController.js +333 -142
  31. package/dist/cjs/utils/authWebUrl.js +8 -1
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/crypto/identityMove.js +110 -0
  34. package/dist/esm/crypto/keyManager.js +60 -21
  35. package/dist/esm/crypto/webIdentityCarrier.js +243 -0
  36. package/dist/esm/i18n/locales/ar-SA.json +16 -13
  37. package/dist/esm/i18n/locales/ca-ES.json +16 -13
  38. package/dist/esm/i18n/locales/de-DE.json +16 -13
  39. package/dist/esm/i18n/locales/en-US.json +16 -17
  40. package/dist/esm/i18n/locales/es-ES.json +16 -17
  41. package/dist/esm/i18n/locales/fr-FR.json +16 -13
  42. package/dist/esm/i18n/locales/it-IT.json +16 -13
  43. package/dist/esm/i18n/locales/ja-JP.json +16 -13
  44. package/dist/esm/i18n/locales/ko-KR.json +16 -13
  45. package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
  46. package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
  47. package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
  48. package/dist/esm/i18n/locales/locales/en-US.json +16 -17
  49. package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
  50. package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
  51. package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
  52. package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
  53. package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
  54. package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
  55. package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
  56. package/dist/esm/i18n/locales/pt-PT.json +16 -13
  57. package/dist/esm/i18n/locales/zh-CN.json +16 -13
  58. package/dist/esm/index.js +5 -1
  59. package/dist/esm/mixins/OxyServices.auth.js +14 -4
  60. package/dist/esm/mixins/OxyServices.identity.js +14 -0
  61. package/dist/esm/session/accountDialogController.js +334 -143
  62. package/dist/esm/utils/authWebUrl.js +7 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/crypto/identityMove.d.ts +59 -0
  65. package/dist/types/crypto/keyManager.d.ts +35 -5
  66. package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
  67. package/dist/types/index.d.ts +5 -2
  68. package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
  69. package/dist/types/models/interfaces.d.ts +15 -0
  70. package/dist/types/session/accountDialogController.d.ts +169 -17
  71. package/dist/types/utils/authWebUrl.d.ts +7 -0
  72. package/package.json +2 -2
  73. package/src/crypto/__tests__/identityMove.test.ts +104 -0
  74. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
  75. package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
  76. package/src/crypto/identityMove.ts +133 -0
  77. package/src/crypto/keyManager.ts +71 -21
  78. package/src/crypto/webIdentityCarrier.ts +326 -0
  79. package/src/i18n/locales/ar-SA.json +16 -13
  80. package/src/i18n/locales/ca-ES.json +16 -13
  81. package/src/i18n/locales/de-DE.json +16 -13
  82. package/src/i18n/locales/en-US.json +16 -17
  83. package/src/i18n/locales/es-ES.json +16 -17
  84. package/src/i18n/locales/fr-FR.json +16 -13
  85. package/src/i18n/locales/it-IT.json +16 -13
  86. package/src/i18n/locales/ja-JP.json +16 -13
  87. package/src/i18n/locales/ko-KR.json +16 -13
  88. package/src/i18n/locales/pt-PT.json +16 -13
  89. package/src/i18n/locales/zh-CN.json +16 -13
  90. package/src/index.ts +38 -1
  91. package/src/mixins/OxyServices.auth.ts +16 -3
  92. package/src/mixins/OxyServices.identity.ts +14 -0
  93. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
  94. package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
  95. package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
  96. package/src/models/interfaces.ts +15 -0
  97. package/src/session/__tests__/accountDialogController.test.ts +407 -10
  98. package/src/session/accountDialogController.ts +406 -145
  99. package/src/utils/__tests__/oauthPkce.test.ts +50 -26
  100. package/src/utils/authWebUrl.ts +8 -0
@@ -991,6 +991,49 @@ export class KeyManager {
991
991
  }
992
992
  }
993
993
 
994
+ /**
995
+ * Which identity key this device is actually acting as, per slot.
996
+ *
997
+ * There are two slots and they can disagree. The shared slot is what every
998
+ * cross-app reader takes first — `deriveScopedSeed` (so: the FairCoin wallet
999
+ * Peable derives) and `getSharedPrivateKey` — while the primary slot is what
1000
+ * signing and the server-facing identity use. A disagreement is invisible to
1001
+ * both sides on its own, and it is money: a wallet derived from a stale
1002
+ * shared key watches addresses that the key published in the DID will never
1003
+ * produce, so payments land where the recipient cannot see them.
1004
+ *
1005
+ * `activePublicKey` is the one money derives from — the same `shared ?? primary`
1006
+ * order `deriveScopedSeed` uses, so a caller can compare it against the key
1007
+ * published for the account and refuse to receive rather than watch the wrong
1008
+ * addresses.
1009
+ *
1010
+ * Public keys are canonicalized, so two encodings of the same key compare
1011
+ * equal. On web both slots are null and `inSync` is true: nothing is stored,
1012
+ * so nothing can disagree.
1013
+ */
1014
+ static async getIdentityKeyState(): Promise<{
1015
+ primaryPublicKey: string | null;
1016
+ sharedPublicKey: string | null;
1017
+ activePublicKey: string | null;
1018
+ inSync: boolean;
1019
+ }> {
1020
+ const [primaryRaw, sharedRaw] = await Promise.all([
1021
+ KeyManager.getPublicKey(),
1022
+ KeyManager.getSharedPublicKey(),
1023
+ ]);
1024
+ const primaryPublicKey = primaryRaw ? KeyManager.canonicalPublicKey(primaryRaw) : null;
1025
+ const sharedPublicKey = sharedRaw ? KeyManager.canonicalPublicKey(sharedRaw) : null;
1026
+ return {
1027
+ primaryPublicKey,
1028
+ sharedPublicKey,
1029
+ activePublicKey: sharedPublicKey ?? primaryPublicKey,
1030
+ // Only a real disagreement is out of sync. A device with just one slot
1031
+ // populated has nothing to contradict.
1032
+ inSync:
1033
+ primaryPublicKey === null || sharedPublicKey === null || primaryPublicKey === sharedPublicKey,
1034
+ };
1035
+ }
1036
+
994
1037
  /**
995
1038
  * Check if a shared identity exists (accessible across all Oxy apps)
996
1039
  *
@@ -1201,54 +1244,61 @@ export class KeyManager {
1201
1244
  }
1202
1245
 
1203
1246
  /**
1204
- * Migrate local identity to shared identity
1247
+ * Make the shared slot hold THIS device's identity key.
1205
1248
  *
1206
- * Call this when upgrading existing apps to use shared identities.
1207
- * Copies the device-specific identity to shared storage so it can
1208
- * be accessed by other Oxy apps.
1249
+ * The shared slot is the one every Oxy app on the device reads, so it has to
1250
+ * agree with the primary slot. This creates it when it is empty (the original
1251
+ * migration, for apps that predate the shared identity) and repairs it when it
1252
+ * holds a different key.
1209
1253
  *
1210
- * @returns True if migration was successful, false if no local identity exists
1254
+ * @returns True when the shared slot ends up holding the device's key, false
1255
+ * when there is no identity to share.
1211
1256
  */
1212
- static async migrateToSharedIdentity(): Promise<boolean> {
1257
+ static async syncSharedIdentity(): Promise<boolean> {
1213
1258
  if (isWebPlatform()) {
1214
1259
  return false;
1215
1260
  }
1216
1261
 
1217
1262
  try {
1218
- // Check if we already have a shared identity
1219
- const hasShared = await KeyManager.hasSharedIdentity();
1220
- if (hasShared) {
1221
- if (isDev()) {
1222
- logger.debug('Shared identity already exists, skipping migration', { component: 'KeyManager' });
1223
- }
1263
+ const state = await KeyManager.getIdentityKeyState();
1264
+ if (state.sharedPublicKey !== null && (state.inSync || state.primaryPublicKey === null)) {
1224
1265
  return true;
1225
1266
  }
1226
1267
 
1227
- // Get local identity
1228
1268
  const privateKey = await KeyManager.getPrivateKey();
1229
1269
  if (!privateKey) {
1230
- if (isDev()) {
1231
- logger.debug('No local identity to migrate', { component: 'KeyManager' });
1232
- }
1233
- return false;
1270
+ // Nothing of this device's own to share. A shared slot that is already
1271
+ // populated stays as it is: it is the only identity here.
1272
+ return state.sharedPublicKey !== null;
1234
1273
  }
1235
1274
 
1236
- // Import to shared storage
1237
1275
  await KeyManager.importSharedIdentity(privateKey);
1238
1276
 
1239
- if (isDev()) {
1240
- logger.debug('Successfully migrated local identity to shared identity', { component: 'KeyManager' });
1277
+ // A slot that held a DIFFERENT key is the dangerous case, so it is said
1278
+ // out loud rather than debug-logged: until this write, cross-app readers
1279
+ // (`deriveScopedSeed`, so Peable's wallet) were deriving from one key
1280
+ // while signing and the server used another.
1281
+ if (state.sharedPublicKey !== null) {
1282
+ logger.warn(
1283
+ 'Shared identity held a different key than this device: repaired it from the primary slot.',
1284
+ { component: 'KeyManager', method: 'syncSharedIdentity' },
1285
+ );
1241
1286
  }
1242
1287
 
1243
1288
  return true;
1244
1289
  } catch (error) {
1245
1290
  if (isDev()) {
1246
- logger.error('Failed to migrate to shared identity', error, { component: 'KeyManager' });
1291
+ logger.error('Failed to sync the shared identity', error, { component: 'KeyManager' });
1247
1292
  }
1248
1293
  return false;
1249
1294
  }
1250
1295
  }
1251
1296
 
1297
+ /** @deprecated Renamed to {@link KeyManager.syncSharedIdentity}. */
1298
+ static async migrateToSharedIdentity(): Promise<boolean> {
1299
+ return KeyManager.syncSharedIdentity();
1300
+ }
1301
+
1252
1302
  // ==================== END SHARED IDENTITY METHODS ====================
1253
1303
 
1254
1304
  /**
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Web identity carrier — the crypto of "one identity, two carriers".
3
+ *
4
+ * An Oxy identity is a BIP-39 mnemonic; its seed's first 32 bytes are the
5
+ * secp256k1 key (`recoveryPhrase.ts`). Commons keeps that key in the device
6
+ * keychain. This module lets a browser carry the SAME identity without Oxy ever
7
+ * being able to use it:
8
+ *
9
+ * entropy ──AEAD(DEK)──▶ sealedEntropy
10
+ * DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
11
+ *
12
+ * The PRF output is produced inside the user's authenticator, behind user
13
+ * verification, and never leaves the page that asked for it. The envelope is
14
+ * therefore safe to store anywhere — the identity origin's IndexedDB and the
15
+ * server copy alike — and opens only with a registered passkey or the phrase.
16
+ *
17
+ * PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
18
+ * CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
19
+ * PRF output in; everything here is deterministic given its inputs, so it is
20
+ * identical on web, Node and React Native, and fully unit-testable.
21
+ *
22
+ * Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
23
+ */
24
+
25
+ import './polyfill';
26
+ import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
27
+ import { wordlist } from '@scure/bip39/wordlists/english';
28
+ import { sha256 } from '@noble/hashes/sha256';
29
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
30
+ import { signMessage } from '@oxy.so/protocol';
31
+ import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
32
+ import {
33
+ WEB_IDENTITY_ENVELOPE_VERSION,
34
+ type WebIdentityEnvelope,
35
+ type WebIdentityWrap,
36
+ } from '@oxy.so/contracts';
37
+ import { AEAD_KEY_LENGTH, decryptAead, encryptAead } from './aead';
38
+ import { hkdfSha256 } from './kdf';
39
+
40
+ /**
41
+ * The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
42
+ *
43
+ * Fixed rather than per-wrap: discoverable sign-in has no credential list to key
44
+ * `evalByCredential` by, and each passkey's output is already distinct. A future
45
+ * scheme change bumps the label — and with it every KEK — instead of mutating it.
46
+ */
47
+ export const WEB_IDENTITY_PRF_INPUT: Uint8Array = sha256(utf8ToBytes('oxy-web-identity-carrier/prf/v1'));
48
+
49
+ /** HKDF salt of the key-encryption-key schedule. Versioned with the envelope. */
50
+ const KEK_SALT = utf8ToBytes('oxy-web-identity-carrier/kek/v1');
51
+
52
+ /** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
53
+ const PRF_OUTPUT_LENGTH = 32;
54
+
55
+ /** Why an envelope did not open. Every value is a normal outcome a UI can explain. */
56
+ export type WebIdentityUnlockFailure =
57
+ /** The passkey used is not one of the envelope's wraps. */
58
+ | 'unknown-credential'
59
+ /**
60
+ * The passkey IS listed but its PRF output does not open its wrap — a synced
61
+ * copy returning a different PRF value, or a provider that silently changed it.
62
+ * Recoverable with another passkey or the phrase.
63
+ */
64
+ | 'prf-mismatch'
65
+ /** The envelope itself is malformed or tampered with. */
66
+ | 'corrupt';
67
+
68
+ export class WebIdentityUnlockError extends Error {
69
+ constructor(readonly failure: WebIdentityUnlockFailure, message: string) {
70
+ super(message);
71
+ this.name = 'WebIdentityUnlockError';
72
+ }
73
+ }
74
+
75
+ /** The identity in usable form. Hold it for one operation, then {@link wipeBytes}. */
76
+ export interface OpenedWebIdentity {
77
+ mnemonic: string;
78
+ /** 32-byte secp256k1 private key, lowercase hex. */
79
+ privateKey: string;
80
+ /** Uncompressed SEC1 public key, lowercase hex (Oxy's canonical form). */
81
+ publicKey: string;
82
+ }
83
+
84
+ function aadFor(parts: Record<string, string | number>): Uint8Array {
85
+ return utf8ToBytes(JSON.stringify(parts));
86
+ }
87
+
88
+ function entropyAad(publicKey: string): Uint8Array {
89
+ return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
90
+ }
91
+
92
+ function wrapAad(publicKey: string, credentialId: string): Uint8Array {
93
+ return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
94
+ }
95
+
96
+ /** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
97
+ export function wipeBytes(bytes: Uint8Array): void {
98
+ bytes.fill(0);
99
+ }
100
+
101
+ /**
102
+ * Derive the identity from a mnemonic exactly as Commons does
103
+ * (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
104
+ */
105
+ export function deriveIdentityFromMnemonic(mnemonic: string): OpenedWebIdentity {
106
+ const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
107
+ if (!validateMnemonic(normalized, wordlist)) {
108
+ throw new Error('Invalid recovery phrase');
109
+ }
110
+ const seed = mnemonicToSeedSync(normalized);
111
+ const privateKeyBytes = seed.slice(0, 32);
112
+ wipeBytes(seed);
113
+ const privateKey = bytesToHex(privateKeyBytes);
114
+ wipeBytes(privateKeyBytes);
115
+ return { mnemonic: normalized, privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
116
+ }
117
+
118
+ /** A brand-new 12-word identity. Nothing is persisted. */
119
+ export function generateWebIdentity(): OpenedWebIdentity {
120
+ return deriveIdentityFromMnemonic(generateMnemonic(wordlist, 128));
121
+ }
122
+
123
+ /** A fresh random data key for a new envelope. */
124
+ export function generateDataKey(): Uint8Array {
125
+ const key = new Uint8Array(AEAD_KEY_LENGTH);
126
+ globalThis.crypto.getRandomValues(key);
127
+ return key;
128
+ }
129
+
130
+ /** The key-encryption key a passkey's PRF output yields for its own wrap. */
131
+ export function deriveKeyEncryptionKey(prfOutput: Uint8Array, credentialId: string): Uint8Array {
132
+ if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
133
+ throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
134
+ }
135
+ return hkdfSha256(prfOutput, KEK_SALT, utf8ToBytes(credentialId), AEAD_KEY_LENGTH);
136
+ }
137
+
138
+ /** Wrap `dataKey` for one passkey. */
139
+ export function wrapDataKey(
140
+ dataKey: Uint8Array,
141
+ prfOutput: Uint8Array,
142
+ credentialId: string,
143
+ publicKey: string,
144
+ now: Date = new Date(),
145
+ ): WebIdentityWrap {
146
+ const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
147
+ try {
148
+ const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, credentialId));
149
+ return {
150
+ credentialId,
151
+ nonce: bytesToHex(nonce),
152
+ wrappedKey: bytesToHex(ciphertext),
153
+ createdAt: now.toISOString(),
154
+ };
155
+ } finally {
156
+ wipeBytes(kek);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Seal an identity into a new envelope that opens with the given passkey.
162
+ *
163
+ * Returns the data key too, so the caller can add further wraps in the same
164
+ * session; wipe it when done.
165
+ */
166
+ export function sealWebIdentity(
167
+ identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>,
168
+ firstWrap: { prfOutput: Uint8Array; credentialId: string },
169
+ now: Date = new Date(),
170
+ ): { envelope: WebIdentityEnvelope; dataKey: Uint8Array } {
171
+ const derived = deriveIdentityFromMnemonic(identity.mnemonic);
172
+ if (derived.publicKey !== identity.publicKey.toLowerCase()) {
173
+ throw new Error('The recovery phrase does not belong to this identity');
174
+ }
175
+ const dataKey = generateDataKey();
176
+ const entropy = mnemonicToEntropy(derived.mnemonic, wordlist);
177
+ try {
178
+ const { nonce, ciphertext } = encryptAead(dataKey, entropy, entropyAad(derived.publicKey));
179
+ const envelope: WebIdentityEnvelope = {
180
+ version: WEB_IDENTITY_ENVELOPE_VERSION,
181
+ algorithm: 'xchacha20poly1305',
182
+ publicKey: derived.publicKey,
183
+ entropyNonce: bytesToHex(nonce),
184
+ sealedEntropy: bytesToHex(ciphertext),
185
+ wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
186
+ };
187
+ return { envelope, dataKey };
188
+ } finally {
189
+ wipeBytes(entropy);
190
+ }
191
+ }
192
+
193
+ /** Recover the data key with one passkey's PRF output. */
194
+ export function unwrapDataKey(
195
+ envelope: WebIdentityEnvelope,
196
+ prfOutput: Uint8Array,
197
+ credentialId: string,
198
+ ): Uint8Array {
199
+ const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
200
+ if (!wrap) {
201
+ throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
202
+ }
203
+ const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
204
+ try {
205
+ return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
206
+ } catch {
207
+ throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
208
+ } finally {
209
+ wipeBytes(kek);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Open an envelope with its data key.
215
+ *
216
+ * The derived public key is checked against the envelope's own: a sealed entropy
217
+ * that decrypts into a different identity is treated as corruption, never used.
218
+ */
219
+ export function openWebIdentity(envelope: WebIdentityEnvelope, dataKey: Uint8Array): OpenedWebIdentity {
220
+ let entropy: Uint8Array;
221
+ try {
222
+ entropy = decryptAead(
223
+ dataKey,
224
+ hexToBytes(envelope.entropyNonce),
225
+ hexToBytes(envelope.sealedEntropy),
226
+ entropyAad(envelope.publicKey),
227
+ );
228
+ } catch {
229
+ throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
230
+ }
231
+ try {
232
+ const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
233
+ if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
234
+ throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
235
+ }
236
+ return identity;
237
+ } finally {
238
+ wipeBytes(entropy);
239
+ }
240
+ }
241
+
242
+ /** Unwrap and open in one step. */
243
+ export function unlockWebIdentity(
244
+ envelope: WebIdentityEnvelope,
245
+ prfOutput: Uint8Array,
246
+ credentialId: string,
247
+ ): OpenedWebIdentity {
248
+ const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
249
+ try {
250
+ return openWebIdentity(envelope, dataKey);
251
+ } finally {
252
+ wipeBytes(dataKey);
253
+ }
254
+ }
255
+
256
+ /** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
257
+ export function addWrap(
258
+ envelope: WebIdentityEnvelope,
259
+ dataKey: Uint8Array,
260
+ prfOutput: Uint8Array,
261
+ credentialId: string,
262
+ now: Date = new Date(),
263
+ ): WebIdentityEnvelope {
264
+ // Proves `dataKey` belongs to THIS envelope before anything new can open it.
265
+ openWebIdentity(envelope, dataKey);
266
+ const others = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
267
+ return {
268
+ ...envelope,
269
+ wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
270
+ };
271
+ }
272
+
273
+ /** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
274
+ export function removeWrap(envelope: WebIdentityEnvelope, credentialId: string): WebIdentityEnvelope {
275
+ const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
276
+ if (remaining.length === 0) {
277
+ throw new Error('An identity envelope must keep at least one passkey');
278
+ }
279
+ return { ...envelope, wraps: remaining };
280
+ }
281
+
282
+ /**
283
+ * The message an identity key signs to authorize an account action
284
+ * (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
285
+ * timestamp })`, byte-identical to what the API reconstructs.
286
+ */
287
+ export function buildIdentityActionMessage(action: string, userId: string, timestamp: number): string {
288
+ return JSON.stringify({ action, userId, timestamp });
289
+ }
290
+
291
+ /** Sign an identity-action message with an opened identity's key. */
292
+ export async function signIdentityAction(
293
+ identity: Pick<OpenedWebIdentity, 'privateKey'>,
294
+ action: string,
295
+ userId: string,
296
+ timestamp: number = Date.now(),
297
+ ): Promise<{ signature: string; timestamp: number }> {
298
+ const signature = await signMessage(buildIdentityActionMessage(action, userId, timestamp), identity.privateKey);
299
+ return { signature, timestamp };
300
+ }
301
+
302
+ /**
303
+ * The 6-digit short authentication string both sides of an identity transfer show.
304
+ *
305
+ * Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
306
+ * that substitutes either key produces a different code on each screen. The user
307
+ * comparing the two codes is what makes the transfer safe against the relay itself.
308
+ */
309
+ export function deriveTransferSas(input: {
310
+ pairingId: string;
311
+ initiatorEphemeralPublicKey: string;
312
+ responderEphemeralPublicKey: string;
313
+ }): string {
314
+ const digest = sha256(
315
+ utf8ToBytes(
316
+ JSON.stringify({
317
+ v: 'oxy-identity-transfer-sas-v1',
318
+ pairingId: input.pairingId.toLowerCase(),
319
+ initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
320
+ responder: input.responderEphemeralPublicKey.toLowerCase(),
321
+ }),
322
+ ),
323
+ );
324
+ const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
325
+ return String(value % 1_000_000).padStart(6, '0');
326
+ }
@@ -52,19 +52,9 @@
52
52
  "signup": {
53
53
  "title": "أنشئ حسابك",
54
54
  "subtitle": "هوية واحدة لكل النظام البيئي.",
55
- "commonsHeadline": "أنشئ هويتك في Commons",
56
- "commonsExplainer": "‏Commons هي هوية Oxy الخاصة بك بحفظ ذاتي كامل — تطبيق واحد يعمل في كل مكان.",
57
55
  "createInCommons": "أنشئ هويتك في Commons",
58
- "passkeyAlternative": "لا تملك Commons؟ أنشئ مفتاح مرور على هذا الجهاز بدلاً من ذلك",
59
56
  "createAccount": "إنشاء حساب",
60
- "creating": "جارٍ الإنشاء…",
61
- "usernamePlaceholder": "اختر اسم مستخدم",
62
- "usernameChecking": "جارٍ التحقق…",
63
- "usernameAvailable": "متاح",
64
- "usernameTaken": "اسم المستخدم هذا مستخدم بالفعل",
65
57
  "backToSignInLink": "لديك حساب بالفعل؟ سجّل الدخول",
66
- "hubExplainer": "أنشئ هوية Oxy الخاصة بك في نافذة آمنة، ثم عد إلى هنا مباشرة.",
67
- "continueInWindow": "المتابعة في نافذة جديدة",
68
58
  "welcome": {
69
59
  "title": "مرحبًا بك في Oxy!",
70
60
  "subtitle": "أنشئ حسابك في خطوات قليلة",
@@ -119,7 +109,6 @@
119
109
  "qrHeadline": "سجّل الدخول بهوية Oxy الخاصة بك",
120
110
  "signInWithOxy": "تسجيل الدخول باستخدام Oxy",
121
111
  "useIdentityOnDevice": "لا تملك Commons؟ استخدم مفتاح مرور على هذا الجهاز بدلاً من ذلك",
122
- "passkeySigningIn": "جارٍ تسجيل الدخول…",
123
112
  "getCommons": "احصل على Commons",
124
113
  "commonsNotInstalled": "لا تملك Commons؟ احصل على التطبيق لتسجيل الدخول بمعرّف Oxy الخاص بك.",
125
114
  "showQrAnyway": "لدي Commons على جهاز آخر",
@@ -136,7 +125,20 @@
136
125
  "openedInCommons": "تم الفتح في Commons",
137
126
  "confirming": "جارٍ تأكيد الهوية",
138
127
  "confirmed": "تم تأكيد الهوية"
139
- }
128
+ },
129
+ "signInFailures": {
130
+ "denied": "تم رفض تسجيل الدخول في Commons.",
131
+ "expired": "انتهت صلاحية طلب تسجيل الدخول. يُرجى المحاولة مرة أخرى.",
132
+ "network": "تعذّر الاتصال بـ Oxy. تحقّق من اتصالك وحاول مرة أخرى.",
133
+ "notConfigured": "هذا التطبيق غير مُعدّ لتسجيل الدخول بعد.",
134
+ "unsupportedFlow": "لا يمكن إكمال تسجيل الدخول هنا.",
135
+ "claimFailed": "تمت الموافقة على تسجيل الدخول، لكن تعذّر إكماله. يُرجى المحاولة مرة أخرى.",
136
+ "generic": "تعذّر تسجيل الدخول. يُرجى المحاولة مرة أخرى."
137
+ },
138
+ "linkOpenFailed": "تعذّر فتح الرابط. يُرجى المحاولة مرة أخرى.",
139
+ "continueWithPasskey": "متابعة",
140
+ "passkeyHint": "استخدم بصمتك أو وجهك أو رمز PIN للجهاز.",
141
+ "otherDeviceCommons": "حسابك على جهاز آخر؟ امسح الرمز باستخدام Commons"
140
142
  },
141
143
  "common": {
142
144
  "actions": {
@@ -277,6 +279,7 @@
277
279
  "help": "المساعدة والملاحظات",
278
280
  "signOut": "تسجيل الخروج",
279
281
  "privacy": "سياسة الخصوصية",
280
- "terms": "شروط الخدمة"
282
+ "terms": "شروط الخدمة",
283
+ "identity": "هويتك"
281
284
  }
282
285
  }
@@ -52,19 +52,9 @@
52
52
  "signup": {
53
53
  "title": "Crea el teu compte",
54
54
  "subtitle": "Una identitat per a tot l'ecosistema.",
55
- "commonsHeadline": "Crea la teva identitat a Commons",
56
- "commonsExplainer": "Commons és la teva identitat d'Oxy amb autocustòdia completa: una app que funciona a tot arreu.",
57
55
  "createInCommons": "Crea la teva identitat a Commons",
58
- "passkeyAlternative": "No tens Commons? Crea una clau d'accés en aquest dispositiu",
59
56
  "createAccount": "Crear compte",
60
- "creating": "Creant…",
61
- "usernamePlaceholder": "Tria un nom d'usuari",
62
- "usernameChecking": "Comprovant…",
63
- "usernameAvailable": "Disponible",
64
- "usernameTaken": "Aquest nom d'usuari ja està en ús",
65
57
  "backToSignInLink": "Ja tens compte? Inicia sessió",
66
- "hubExplainer": "Crea la teva Oxy ID en una finestra segura i torna de seguida.",
67
- "continueInWindow": "Continua en una finestra nova",
68
58
  "welcome": {
69
59
  "title": "Benvingut a Oxy!",
70
60
  "subtitle": "Crea el teu compte en pocs passos",
@@ -119,7 +109,6 @@
119
109
  "qrHeadline": "Inicia sessió amb la teva identitat d'Oxy",
120
110
  "signInWithOxy": "Inicia sessió amb Oxy",
121
111
  "useIdentityOnDevice": "No tens Commons? Fes servir una clau d'accés en aquest dispositiu",
122
- "passkeySigningIn": "Iniciant sessió…",
123
112
  "getCommons": "Descarrega Commons",
124
113
  "commonsNotInstalled": "No tens Commons? Descarrega l'app per iniciar sessió amb el teu Oxy ID.",
125
114
  "showQrAnyway": "Tinc Commons en un altre dispositiu",
@@ -136,7 +125,20 @@
136
125
  "openedInCommons": "Obert a Commons",
137
126
  "confirming": "Confirmant la identitat",
138
127
  "confirmed": "Identitat confirmada"
139
- }
128
+ },
129
+ "signInFailures": {
130
+ "denied": "S'ha rebutjat l'inici de sessió a Commons.",
131
+ "expired": "La sol·licitud d'inici de sessió ha caducat. Torna-ho a provar.",
132
+ "network": "No s'ha pogut connectar amb Oxy. Comprova la connexió i torna-ho a provar.",
133
+ "notConfigured": "Aquesta app encara no està preparada per iniciar sessió.",
134
+ "unsupportedFlow": "Aquest inici de sessió no es pot completar aquí.",
135
+ "claimFailed": "S'ha aprovat l'inici de sessió, però no l'hem pogut completar. Torna-ho a provar.",
136
+ "generic": "No s'ha pogut iniciar la sessió. Torna-ho a provar."
137
+ },
138
+ "linkOpenFailed": "No s'ha pogut obrir l'enllaç. Torna-ho a provar.",
139
+ "continueWithPasskey": "Continua",
140
+ "passkeyHint": "Fes servir l'empremta, la cara o el PIN del dispositiu.",
141
+ "otherDeviceCommons": "El teu compte és en un altre dispositiu? Escaneja amb Commons"
140
142
  },
141
143
  "common": {
142
144
  "actions": {
@@ -277,6 +279,7 @@
277
279
  "help": "Ajuda i comentaris",
278
280
  "signOut": "Tancar sessió",
279
281
  "privacy": "Política de privacitat",
280
- "terms": "Condicions del servei"
282
+ "terms": "Condicions del servei",
283
+ "identity": "La teva identitat"
281
284
  }
282
285
  }
@@ -52,19 +52,9 @@
52
52
  "signup": {
53
53
  "title": "Erstellen Sie Ihr Konto",
54
54
  "subtitle": "Eine Identität für das gesamte Ökosystem.",
55
- "commonsHeadline": "Erstellen Sie Ihre Identität in Commons",
56
- "commonsExplainer": "Commons ist Ihre vollständig selbstverwaltete Oxy-Identität – eine App, überall einsetzbar.",
57
55
  "createInCommons": "Erstellen Sie Ihre Identität in Commons",
58
- "passkeyAlternative": "Kein Commons? Erstellen Sie stattdessen einen Passkey auf diesem Gerät",
59
56
  "createAccount": "Konto erstellen",
60
- "creating": "Wird erstellt…",
61
- "usernamePlaceholder": "Benutzernamen wählen",
62
- "usernameChecking": "Wird geprüft…",
63
- "usernameAvailable": "Verfügbar",
64
- "usernameTaken": "Dieser Benutzername ist bereits vergeben",
65
57
  "backToSignInLink": "Sie haben bereits ein Konto? Anmelden",
66
- "hubExplainer": "Erstellen Sie Ihre Oxy-ID in einem sicheren Fenster und kehren Sie dann direkt zurück.",
67
- "continueInWindow": "In einem neuen Fenster fortfahren",
68
58
  "welcome": {
69
59
  "title": "Willkommen bei Oxy!",
70
60
  "subtitle": "Erstellen Sie Ihr Konto in nur wenigen Schritten",
@@ -119,7 +109,6 @@
119
109
  "qrHeadline": "Melden Sie sich mit Ihrer Oxy-Identität an",
120
110
  "signInWithOxy": "Mit Oxy anmelden",
121
111
  "useIdentityOnDevice": "Kein Commons? Verwenden Sie stattdessen einen Passkey auf diesem Gerät",
122
- "passkeySigningIn": "Anmeldung läuft…",
123
112
  "getCommons": "Commons herunterladen",
124
113
  "commonsNotInstalled": "Kein Commons? Holen Sie sich die App, um sich mit Ihrer Oxy-ID anzumelden.",
125
114
  "showQrAnyway": "Ich habe Commons auf einem anderen Gerät",
@@ -136,7 +125,20 @@
136
125
  "openedInCommons": "In Commons geöffnet",
137
126
  "confirming": "Identität wird bestätigt",
138
127
  "confirmed": "Identität bestätigt"
139
- }
128
+ },
129
+ "signInFailures": {
130
+ "denied": "Die Anmeldung wurde in Commons abgelehnt.",
131
+ "expired": "Die Anmeldeanfrage ist abgelaufen. Bitte versuchen Sie es erneut.",
132
+ "network": "Oxy ist nicht erreichbar. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
133
+ "notConfigured": "Diese App ist noch nicht für die Anmeldung eingerichtet.",
134
+ "unsupportedFlow": "Diese Anmeldung kann hier nicht abgeschlossen werden.",
135
+ "claimFailed": "Die Anmeldung wurde genehmigt, konnte aber nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
136
+ "generic": "Die Anmeldung ist fehlgeschlagen. Bitte versuchen Sie es erneut."
137
+ },
138
+ "linkOpenFailed": "Der Link konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.",
139
+ "continueWithPasskey": "Weiter",
140
+ "passkeyHint": "Nutzen Sie Fingerabdruck, Gesicht oder die Geräte-PIN.",
141
+ "otherDeviceCommons": "Konto auf einem anderen Gerät? Mit Commons scannen"
140
142
  },
141
143
  "common": {
142
144
  "actions": {
@@ -277,6 +279,7 @@
277
279
  "help": "Hilfe & Feedback",
278
280
  "signOut": "Abmelden",
279
281
  "privacy": "Datenschutzerklärung",
280
- "terms": "Nutzungsbedingungen"
282
+ "terms": "Nutzungsbedingungen",
283
+ "identity": "Ihre Identität"
281
284
  }
282
285
  }