@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
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Identity move — the crypto of taking a web identity into Commons.
3
+ *
4
+ * Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
5
+ * Commons (the responder, receiving it). The relay in between sees two ephemeral
6
+ * public keys and ciphertext; the 6-digit SAS both screens show is what makes a
7
+ * relay that swapped a key visible to the person.
8
+ *
9
+ * PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
10
+ */
11
+ import './polyfill.js';
12
+ import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
13
+ import { wordlist } from '@scure/bip39/wordlists/english';
14
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
15
+ import { signMessage, verifySignature } from '@oxy.so/protocol';
16
+ import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
17
+ import { IDENTITY_MOVE_QR_PREFIX } from '@oxy.so/contracts';
18
+ import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead.js';
19
+ import { deriveSharedSecret } from './ecdh.js';
20
+ import { hkdfSha256 } from './kdf.js';
21
+ import { deriveIdentityFromMnemonic, deriveTransferSas, wipeBytes } from './webIdentityCarrier.js';
22
+ const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
23
+ /** Actions the identity key signs during a move. */
24
+ export const IDENTITY_MOVE_ACTIONS = {
25
+ seal: 'identity_move_seal',
26
+ received: 'identity_move_received',
27
+ };
28
+ /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
29
+ export function generateMoveEphemeralKeyPair() {
30
+ return generateSecp256k1KeyPair();
31
+ }
32
+ /** The symmetric key both sides derive: HKDF(ECDH, salt = moveId). */
33
+ export function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId) {
34
+ const shared = deriveSharedSecret(ownEphemeralPrivateKey, otherEphemeralPublicKey);
35
+ try {
36
+ return hkdfSha256(shared, utf8ToBytes(moveId.toLowerCase()), MOVE_KDF_INFO, AEAD_KEY_LENGTH);
37
+ }
38
+ finally {
39
+ wipeBytes(shared);
40
+ }
41
+ }
42
+ /** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
43
+ export function deriveMoveSas(moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey) {
44
+ return deriveTransferSas({ pairingId: moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey });
45
+ }
46
+ function moveAad(moveId, publicKey) {
47
+ return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
48
+ }
49
+ /** Initiator: seal the identity's entropy for the responder. */
50
+ export function sealIdentityForMove(identity, moveKey, moveId) {
51
+ const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
52
+ try {
53
+ const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
54
+ return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(ciphertext) };
55
+ }
56
+ finally {
57
+ wipeBytes(entropy);
58
+ }
59
+ }
60
+ /**
61
+ * Responder: open the sealed identity. The derived public key must be the one
62
+ * the move declared — anything else is refused, never imported.
63
+ */
64
+ export function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
65
+ let entropy;
66
+ try {
67
+ entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
68
+ }
69
+ catch {
70
+ throw new Error('The identity could not be opened. The codes may not have matched — start again.');
71
+ }
72
+ try {
73
+ const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
74
+ if (identity.publicKey !== expectedPublicKey.toLowerCase()) {
75
+ throw new Error('The received identity is not the one being moved');
76
+ }
77
+ return identity;
78
+ }
79
+ finally {
80
+ wipeBytes(entropy);
81
+ }
82
+ }
83
+ /** The exact bytes a move proof or receipt signs. */
84
+ export function buildMoveMessage(action, moveId, timestamp) {
85
+ return JSON.stringify({ action, moveId: moveId.toLowerCase(), timestamp });
86
+ }
87
+ /** Sign a move action with the identity key. */
88
+ export async function signMoveAction(identity, action, moveId, timestamp = Date.now()) {
89
+ return { signature: await signMessage(buildMoveMessage(action, moveId, timestamp), identity.privateKey), timestamp };
90
+ }
91
+ /**
92
+ * Initiator: is this receipt really from the identity? Checked locally with the
93
+ * identity's public key, so a server that claims "completed" without Commons
94
+ * having the key cannot make the web destroy its copy.
95
+ */
96
+ export function verifyMoveReceipt(publicKey, moveId, receipt) {
97
+ return verifySignature(buildMoveMessage(IDENTITY_MOVE_ACTIONS.received, moveId, receipt.timestamp), receipt.signature, publicKey);
98
+ }
99
+ /** The QR payload for a move. */
100
+ export function buildMoveQrPayload(moveId) {
101
+ return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
102
+ }
103
+ /** The move id in a scanned payload, or `null` when it is not a move QR. */
104
+ export function parseMoveQrPayload(raw) {
105
+ const value = raw.trim();
106
+ if (!value.toLowerCase().startsWith(IDENTITY_MOVE_QR_PREFIX))
107
+ return null;
108
+ const id = value.slice(IDENTITY_MOVE_QR_PREFIX.length).toLowerCase();
109
+ return /^[0-9a-f]{32}$/.test(id) ? id : null;
110
+ }
@@ -746,6 +746,42 @@ export class KeyManager {
746
746
  return null;
747
747
  }
748
748
  }
749
+ /**
750
+ * Which identity key this device is actually acting as, per slot.
751
+ *
752
+ * There are two slots and they can disagree. The shared slot is what every
753
+ * cross-app reader takes first — `deriveScopedSeed` (so: the FairCoin wallet
754
+ * Peable derives) and `getSharedPrivateKey` — while the primary slot is what
755
+ * signing and the server-facing identity use. A disagreement is invisible to
756
+ * both sides on its own, and it is money: a wallet derived from a stale
757
+ * shared key watches addresses that the key published in the DID will never
758
+ * produce, so payments land where the recipient cannot see them.
759
+ *
760
+ * `activePublicKey` is the one money derives from — the same `shared ?? primary`
761
+ * order `deriveScopedSeed` uses, so a caller can compare it against the key
762
+ * published for the account and refuse to receive rather than watch the wrong
763
+ * addresses.
764
+ *
765
+ * Public keys are canonicalized, so two encodings of the same key compare
766
+ * equal. On web both slots are null and `inSync` is true: nothing is stored,
767
+ * so nothing can disagree.
768
+ */
769
+ static async getIdentityKeyState() {
770
+ const [primaryRaw, sharedRaw] = await Promise.all([
771
+ KeyManager.getPublicKey(),
772
+ KeyManager.getSharedPublicKey(),
773
+ ]);
774
+ const primaryPublicKey = primaryRaw ? KeyManager.canonicalPublicKey(primaryRaw) : null;
775
+ const sharedPublicKey = sharedRaw ? KeyManager.canonicalPublicKey(sharedRaw) : null;
776
+ return {
777
+ primaryPublicKey,
778
+ sharedPublicKey,
779
+ activePublicKey: sharedPublicKey ?? primaryPublicKey,
780
+ // Only a real disagreement is out of sync. A device with just one slot
781
+ // populated has nothing to contradict.
782
+ inSync: primaryPublicKey === null || sharedPublicKey === null || primaryPublicKey === sharedPublicKey,
783
+ };
784
+ }
749
785
  /**
750
786
  * Check if a shared identity exists (accessible across all Oxy apps)
751
787
  *
@@ -939,49 +975,52 @@ export class KeyManager {
939
975
  }
940
976
  }
941
977
  /**
942
- * Migrate local identity to shared identity
978
+ * Make the shared slot hold THIS device's identity key.
943
979
  *
944
- * Call this when upgrading existing apps to use shared identities.
945
- * Copies the device-specific identity to shared storage so it can
946
- * be accessed by other Oxy apps.
980
+ * The shared slot is the one every Oxy app on the device reads, so it has to
981
+ * agree with the primary slot. This creates it when it is empty (the original
982
+ * migration, for apps that predate the shared identity) and repairs it when it
983
+ * holds a different key.
947
984
  *
948
- * @returns True if migration was successful, false if no local identity exists
985
+ * @returns True when the shared slot ends up holding the device's key, false
986
+ * when there is no identity to share.
949
987
  */
950
- static async migrateToSharedIdentity() {
988
+ static async syncSharedIdentity() {
951
989
  if (isWebPlatform()) {
952
990
  return false;
953
991
  }
954
992
  try {
955
- // Check if we already have a shared identity
956
- const hasShared = await KeyManager.hasSharedIdentity();
957
- if (hasShared) {
958
- if (isDev()) {
959
- logger.debug('Shared identity already exists, skipping migration', { component: 'KeyManager' });
960
- }
993
+ const state = await KeyManager.getIdentityKeyState();
994
+ if (state.sharedPublicKey !== null && (state.inSync || state.primaryPublicKey === null)) {
961
995
  return true;
962
996
  }
963
- // Get local identity
964
997
  const privateKey = await KeyManager.getPrivateKey();
965
998
  if (!privateKey) {
966
- if (isDev()) {
967
- logger.debug('No local identity to migrate', { component: 'KeyManager' });
968
- }
969
- return false;
999
+ // Nothing of this device's own to share. A shared slot that is already
1000
+ // populated stays as it is: it is the only identity here.
1001
+ return state.sharedPublicKey !== null;
970
1002
  }
971
- // Import to shared storage
972
1003
  await KeyManager.importSharedIdentity(privateKey);
973
- if (isDev()) {
974
- logger.debug('Successfully migrated local identity to shared identity', { component: 'KeyManager' });
1004
+ // A slot that held a DIFFERENT key is the dangerous case, so it is said
1005
+ // out loud rather than debug-logged: until this write, cross-app readers
1006
+ // (`deriveScopedSeed`, so Peable's wallet) were deriving from one key
1007
+ // while signing and the server used another.
1008
+ if (state.sharedPublicKey !== null) {
1009
+ logger.warn('Shared identity held a different key than this device: repaired it from the primary slot.', { component: 'KeyManager', method: 'syncSharedIdentity' });
975
1010
  }
976
1011
  return true;
977
1012
  }
978
1013
  catch (error) {
979
1014
  if (isDev()) {
980
- logger.error('Failed to migrate to shared identity', error, { component: 'KeyManager' });
1015
+ logger.error('Failed to sync the shared identity', error, { component: 'KeyManager' });
981
1016
  }
982
1017
  return false;
983
1018
  }
984
1019
  }
1020
+ /** @deprecated Renamed to {@link KeyManager.syncSharedIdentity}. */
1021
+ static async migrateToSharedIdentity() {
1022
+ return KeyManager.syncSharedIdentity();
1023
+ }
985
1024
  // ==================== END SHARED IDENTITY METHODS ====================
986
1025
  /**
987
1026
  * Atomically persist a key pair to secure storage with verification + backup.
@@ -0,0 +1,243 @@
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
+ import './polyfill.js';
25
+ import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
26
+ import { wordlist } from '@scure/bip39/wordlists/english';
27
+ import { sha256 } from '@noble/hashes/sha256';
28
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
29
+ import { signMessage } from '@oxy.so/protocol';
30
+ import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
31
+ import { WEB_IDENTITY_ENVELOPE_VERSION, } from '@oxy.so/contracts';
32
+ import { AEAD_KEY_LENGTH, decryptAead, encryptAead } from './aead.js';
33
+ import { hkdfSha256 } from './kdf.js';
34
+ /**
35
+ * The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
36
+ *
37
+ * Fixed rather than per-wrap: discoverable sign-in has no credential list to key
38
+ * `evalByCredential` by, and each passkey's output is already distinct. A future
39
+ * scheme change bumps the label — and with it every KEK — instead of mutating it.
40
+ */
41
+ export const WEB_IDENTITY_PRF_INPUT = sha256(utf8ToBytes('oxy-web-identity-carrier/prf/v1'));
42
+ /** HKDF salt of the key-encryption-key schedule. Versioned with the envelope. */
43
+ const KEK_SALT = utf8ToBytes('oxy-web-identity-carrier/kek/v1');
44
+ /** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
45
+ const PRF_OUTPUT_LENGTH = 32;
46
+ export class WebIdentityUnlockError extends Error {
47
+ constructor(failure, message) {
48
+ super(message);
49
+ this.failure = failure;
50
+ this.name = 'WebIdentityUnlockError';
51
+ }
52
+ }
53
+ function aadFor(parts) {
54
+ return utf8ToBytes(JSON.stringify(parts));
55
+ }
56
+ function entropyAad(publicKey) {
57
+ return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
58
+ }
59
+ function wrapAad(publicKey, credentialId) {
60
+ return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
61
+ }
62
+ /** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
63
+ export function wipeBytes(bytes) {
64
+ bytes.fill(0);
65
+ }
66
+ /**
67
+ * Derive the identity from a mnemonic exactly as Commons does
68
+ * (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
69
+ */
70
+ export function deriveIdentityFromMnemonic(mnemonic) {
71
+ const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
72
+ if (!validateMnemonic(normalized, wordlist)) {
73
+ throw new Error('Invalid recovery phrase');
74
+ }
75
+ const seed = mnemonicToSeedSync(normalized);
76
+ const privateKeyBytes = seed.slice(0, 32);
77
+ wipeBytes(seed);
78
+ const privateKey = bytesToHex(privateKeyBytes);
79
+ wipeBytes(privateKeyBytes);
80
+ return { mnemonic: normalized, privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
81
+ }
82
+ /** A brand-new 12-word identity. Nothing is persisted. */
83
+ export function generateWebIdentity() {
84
+ return deriveIdentityFromMnemonic(generateMnemonic(wordlist, 128));
85
+ }
86
+ /** A fresh random data key for a new envelope. */
87
+ export function generateDataKey() {
88
+ const key = new Uint8Array(AEAD_KEY_LENGTH);
89
+ globalThis.crypto.getRandomValues(key);
90
+ return key;
91
+ }
92
+ /** The key-encryption key a passkey's PRF output yields for its own wrap. */
93
+ export function deriveKeyEncryptionKey(prfOutput, credentialId) {
94
+ if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
95
+ throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
96
+ }
97
+ return hkdfSha256(prfOutput, KEK_SALT, utf8ToBytes(credentialId), AEAD_KEY_LENGTH);
98
+ }
99
+ /** Wrap `dataKey` for one passkey. */
100
+ export function wrapDataKey(dataKey, prfOutput, credentialId, publicKey, now = new Date()) {
101
+ const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
102
+ try {
103
+ const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, credentialId));
104
+ return {
105
+ credentialId,
106
+ nonce: bytesToHex(nonce),
107
+ wrappedKey: bytesToHex(ciphertext),
108
+ createdAt: now.toISOString(),
109
+ };
110
+ }
111
+ finally {
112
+ wipeBytes(kek);
113
+ }
114
+ }
115
+ /**
116
+ * Seal an identity into a new envelope that opens with the given passkey.
117
+ *
118
+ * Returns the data key too, so the caller can add further wraps in the same
119
+ * session; wipe it when done.
120
+ */
121
+ export function sealWebIdentity(identity, firstWrap, now = new Date()) {
122
+ const derived = deriveIdentityFromMnemonic(identity.mnemonic);
123
+ if (derived.publicKey !== identity.publicKey.toLowerCase()) {
124
+ throw new Error('The recovery phrase does not belong to this identity');
125
+ }
126
+ const dataKey = generateDataKey();
127
+ const entropy = mnemonicToEntropy(derived.mnemonic, wordlist);
128
+ try {
129
+ const { nonce, ciphertext } = encryptAead(dataKey, entropy, entropyAad(derived.publicKey));
130
+ const envelope = {
131
+ version: WEB_IDENTITY_ENVELOPE_VERSION,
132
+ algorithm: 'xchacha20poly1305',
133
+ publicKey: derived.publicKey,
134
+ entropyNonce: bytesToHex(nonce),
135
+ sealedEntropy: bytesToHex(ciphertext),
136
+ wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
137
+ };
138
+ return { envelope, dataKey };
139
+ }
140
+ finally {
141
+ wipeBytes(entropy);
142
+ }
143
+ }
144
+ /** Recover the data key with one passkey's PRF output. */
145
+ export function unwrapDataKey(envelope, prfOutput, credentialId) {
146
+ const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
147
+ if (!wrap) {
148
+ throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
149
+ }
150
+ const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
151
+ try {
152
+ return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
153
+ }
154
+ catch {
155
+ throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
156
+ }
157
+ finally {
158
+ wipeBytes(kek);
159
+ }
160
+ }
161
+ /**
162
+ * Open an envelope with its data key.
163
+ *
164
+ * The derived public key is checked against the envelope's own: a sealed entropy
165
+ * that decrypts into a different identity is treated as corruption, never used.
166
+ */
167
+ export function openWebIdentity(envelope, dataKey) {
168
+ let entropy;
169
+ try {
170
+ entropy = decryptAead(dataKey, hexToBytes(envelope.entropyNonce), hexToBytes(envelope.sealedEntropy), entropyAad(envelope.publicKey));
171
+ }
172
+ catch {
173
+ throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
174
+ }
175
+ try {
176
+ const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
177
+ if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
178
+ throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
179
+ }
180
+ return identity;
181
+ }
182
+ finally {
183
+ wipeBytes(entropy);
184
+ }
185
+ }
186
+ /** Unwrap and open in one step. */
187
+ export function unlockWebIdentity(envelope, prfOutput, credentialId) {
188
+ const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
189
+ try {
190
+ return openWebIdentity(envelope, dataKey);
191
+ }
192
+ finally {
193
+ wipeBytes(dataKey);
194
+ }
195
+ }
196
+ /** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
197
+ export function addWrap(envelope, dataKey, prfOutput, credentialId, now = new Date()) {
198
+ // Proves `dataKey` belongs to THIS envelope before anything new can open it.
199
+ openWebIdentity(envelope, dataKey);
200
+ const others = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
201
+ return {
202
+ ...envelope,
203
+ wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
204
+ };
205
+ }
206
+ /** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
207
+ export function removeWrap(envelope, credentialId) {
208
+ const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
209
+ if (remaining.length === 0) {
210
+ throw new Error('An identity envelope must keep at least one passkey');
211
+ }
212
+ return { ...envelope, wraps: remaining };
213
+ }
214
+ /**
215
+ * The message an identity key signs to authorize an account action
216
+ * (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
217
+ * timestamp })`, byte-identical to what the API reconstructs.
218
+ */
219
+ export function buildIdentityActionMessage(action, userId, timestamp) {
220
+ return JSON.stringify({ action, userId, timestamp });
221
+ }
222
+ /** Sign an identity-action message with an opened identity's key. */
223
+ export async function signIdentityAction(identity, action, userId, timestamp = Date.now()) {
224
+ const signature = await signMessage(buildIdentityActionMessage(action, userId, timestamp), identity.privateKey);
225
+ return { signature, timestamp };
226
+ }
227
+ /**
228
+ * The 6-digit short authentication string both sides of an identity transfer show.
229
+ *
230
+ * Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
231
+ * that substitutes either key produces a different code on each screen. The user
232
+ * comparing the two codes is what makes the transfer safe against the relay itself.
233
+ */
234
+ export function deriveTransferSas(input) {
235
+ const digest = sha256(utf8ToBytes(JSON.stringify({
236
+ v: 'oxy-identity-transfer-sas-v1',
237
+ pairingId: input.pairingId.toLowerCase(),
238
+ initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
239
+ responder: input.responderEphemeralPublicKey.toLowerCase(),
240
+ })));
241
+ const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
242
+ return String(value % 1000000).padStart(6, '0');
243
+ }
@@ -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
  }