@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.
Files changed (107) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/aead.js +79 -0
  3. package/dist/cjs/crypto/ecdh.js +53 -0
  4. package/dist/cjs/crypto/kdf.js +39 -0
  5. package/dist/cjs/crypto/keyManager.js +7 -0
  6. package/dist/cjs/crypto/recoveryPhrase.js +89 -1
  7. package/dist/cjs/i18n/locales/ar-SA.json +2 -0
  8. package/dist/cjs/i18n/locales/ca-ES.json +2 -0
  9. package/dist/cjs/i18n/locales/de-DE.json +2 -0
  10. package/dist/cjs/i18n/locales/en-US.json +7 -0
  11. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  12. package/dist/cjs/i18n/locales/fr-FR.json +2 -0
  13. package/dist/cjs/i18n/locales/it-IT.json +2 -0
  14. package/dist/cjs/i18n/locales/ja-JP.json +2 -0
  15. package/dist/cjs/i18n/locales/ko-KR.json +2 -0
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +2 -0
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +2 -0
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +2 -0
  19. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +2 -0
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +2 -0
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +2 -0
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +2 -0
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +2 -0
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +2 -0
  27. package/dist/cjs/i18n/locales/pt-PT.json +2 -0
  28. package/dist/cjs/i18n/locales/zh-CN.json +2 -0
  29. package/dist/cjs/index.js +14 -4
  30. package/dist/cjs/mixins/OxyServices.identity.js +166 -0
  31. package/dist/cjs/mixins/OxyServices.identityBackup.js +161 -0
  32. package/dist/cjs/mixins/OxyServices.user.js +43 -0
  33. package/dist/cjs/mixins/index.js +4 -0
  34. package/dist/esm/.tsbuildinfo +1 -1
  35. package/dist/esm/crypto/aead.js +74 -0
  36. package/dist/esm/crypto/ecdh.js +51 -0
  37. package/dist/esm/crypto/kdf.js +36 -0
  38. package/dist/esm/crypto/keyManager.js +7 -0
  39. package/dist/esm/crypto/recoveryPhrase.js +88 -0
  40. package/dist/esm/i18n/locales/ar-SA.json +2 -0
  41. package/dist/esm/i18n/locales/ca-ES.json +2 -0
  42. package/dist/esm/i18n/locales/de-DE.json +2 -0
  43. package/dist/esm/i18n/locales/en-US.json +7 -0
  44. package/dist/esm/i18n/locales/es-ES.json +7 -0
  45. package/dist/esm/i18n/locales/fr-FR.json +2 -0
  46. package/dist/esm/i18n/locales/it-IT.json +2 -0
  47. package/dist/esm/i18n/locales/ja-JP.json +2 -0
  48. package/dist/esm/i18n/locales/ko-KR.json +2 -0
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +2 -0
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +2 -0
  51. package/dist/esm/i18n/locales/locales/de-DE.json +2 -0
  52. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  53. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +2 -0
  55. package/dist/esm/i18n/locales/locales/it-IT.json +2 -0
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +2 -0
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +2 -0
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +2 -0
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +2 -0
  60. package/dist/esm/i18n/locales/pt-PT.json +2 -0
  61. package/dist/esm/i18n/locales/zh-CN.json +2 -0
  62. package/dist/esm/index.js +4 -0
  63. package/dist/esm/mixins/OxyServices.identity.js +166 -0
  64. package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
  65. package/dist/esm/mixins/OxyServices.user.js +43 -0
  66. package/dist/esm/mixins/index.js +4 -0
  67. package/dist/types/.tsbuildinfo +1 -1
  68. package/dist/types/crypto/aead.d.ts +56 -0
  69. package/dist/types/crypto/ecdh.d.ts +29 -0
  70. package/dist/types/crypto/kdf.d.ts +25 -0
  71. package/dist/types/crypto/keyManager.d.ts +5 -0
  72. package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
  73. package/dist/types/index.d.ts +7 -3
  74. package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
  75. package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
  76. package/dist/types/mixins/OxyServices.user.d.ts +38 -8
  77. package/dist/types/mixins/index.d.ts +2 -1
  78. package/package.json +4 -2
  79. package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
  80. package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
  81. package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
  82. package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
  83. package/src/crypto/aead.ts +97 -0
  84. package/src/crypto/ecdh.ts +60 -0
  85. package/src/crypto/kdf.ts +43 -0
  86. package/src/crypto/keyManager.ts +8 -0
  87. package/src/crypto/recoveryPhrase.ts +133 -0
  88. package/src/i18n/locales/ar-SA.json +2 -0
  89. package/src/i18n/locales/ca-ES.json +2 -0
  90. package/src/i18n/locales/de-DE.json +2 -0
  91. package/src/i18n/locales/en-US.json +7 -0
  92. package/src/i18n/locales/es-ES.json +7 -0
  93. package/src/i18n/locales/fr-FR.json +2 -0
  94. package/src/i18n/locales/it-IT.json +2 -0
  95. package/src/i18n/locales/ja-JP.json +2 -0
  96. package/src/i18n/locales/ko-KR.json +2 -0
  97. package/src/i18n/locales/pt-PT.json +2 -0
  98. package/src/i18n/locales/zh-CN.json +2 -0
  99. package/src/index.ts +16 -1
  100. package/src/mixins/OxyServices.identity.ts +250 -0
  101. package/src/mixins/OxyServices.identityBackup.ts +237 -0
  102. package/src/mixins/OxyServices.user.ts +82 -4
  103. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
  104. package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
  105. package/src/mixins/__tests__/identityBackup.test.ts +258 -0
  106. package/src/mixins/index.ts +5 -0
  107. package/src/types/elliptic.d.ts +10 -2
@@ -0,0 +1,86 @@
1
+ /**
2
+ * RecoveryPhraseService.deriveBackupMaterial — encrypted off-device backup key
3
+ * schedule (b3 Feature 1).
4
+ *
5
+ * The two outputs are derived from the FULL 64-byte BIP-39 seed via HKDF-SHA256
6
+ * with domain-separated `info` labels. These tests PIN the derivation against a
7
+ * fixed phrase so an accidental algorithm/library/label swap is caught as a
8
+ * regression, and assert the domain separation is real (backup key ≠ lookup id ≠
9
+ * the raw private key, all from the same seed).
10
+ *
11
+ * The fixed phrase is the canonical BIP-39 all-zero-entropy vector
12
+ * ("abandon…about"), whose seed is the published `5eb00bbd…ce9e38e4` — so the
13
+ * pinned outputs below are reproducible from any independent HKDF-SHA256
14
+ * implementation over that seed.
15
+ */
16
+ import { RecoveryPhraseService } from '../recoveryPhrase';
17
+ import { KeyManager } from '../keyManager';
18
+
19
+ const FIXED_PHRASE =
20
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
21
+
22
+ // Pinned outputs (independently reproducible — see file header).
23
+ const EXPECTED_BACKUP_KEY_HEX = 'c6bb29585610550e2667a9d05f35d081f67dd8ccee20a04488c39b25f47a9e74';
24
+ const EXPECTED_LOOKUP_ID = '8cad137ca961bfc62a2ef329869e8369777737c7c5353a8d94bb70d888c0ad0d';
25
+ // The raw private key = seed[0:32] (the FROZEN phrase→privateKey derivation).
26
+ const EXPECTED_PRIVATE_KEY = '5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1';
27
+
28
+ const toHex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex');
29
+
30
+ describe('RecoveryPhraseService.deriveBackupMaterial (KDF vectors)', () => {
31
+ it('derives the pinned backupKey + lookupId for the fixed phrase', async () => {
32
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
33
+
34
+ expect(backupKey).toBeInstanceOf(Uint8Array);
35
+ expect(backupKey).toHaveLength(32);
36
+ expect(toHex(backupKey)).toBe(EXPECTED_BACKUP_KEY_HEX);
37
+
38
+ expect(lookupId).toBe(EXPECTED_LOOKUP_ID);
39
+ expect(lookupId).toMatch(/^[0-9a-f]{64}$/);
40
+ });
41
+
42
+ it('is deterministic (same phrase → identical material)', async () => {
43
+ const a = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
44
+ const b = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
45
+ expect(toHex(a.backupKey)).toBe(toHex(b.backupKey));
46
+ expect(a.lookupId).toBe(b.lookupId);
47
+ });
48
+
49
+ it('normalizes case/whitespace like the other phrase helpers', async () => {
50
+ const messy = ` ${FIXED_PHRASE.toUpperCase()} `;
51
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(messy);
52
+ expect(toHex(backupKey)).toBe(EXPECTED_BACKUP_KEY_HEX);
53
+ expect(lookupId).toBe(EXPECTED_LOOKUP_ID);
54
+ });
55
+
56
+ it('enforces real domain separation (backupKey ≠ lookupId ≠ raw private key)', async () => {
57
+ const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
58
+ const privateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(FIXED_PHRASE);
59
+
60
+ // Sanity: the frozen phrase→privateKey path is unchanged.
61
+ expect(privateKey).toBe(EXPECTED_PRIVATE_KEY);
62
+
63
+ // The three derivations from the SAME seed are mutually distinct — a device
64
+ // compromise leaking only the 32-byte private key reveals nothing about the
65
+ // backup key or lookup id (both need the full 64-byte seed).
66
+ expect(toHex(backupKey)).not.toBe(lookupId);
67
+ expect(toHex(backupKey)).not.toBe(privateKey);
68
+ expect(lookupId).not.toBe(privateKey);
69
+ });
70
+
71
+ it('produces a valid signing key material that is NOT the backup key', async () => {
72
+ // Extra guard: the backupKey must never coincide with a usable identity key
73
+ // for this phrase.
74
+ const { backupKey } = await RecoveryPhraseService.deriveBackupMaterial(FIXED_PHRASE);
75
+ const privateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(FIXED_PHRASE);
76
+ const publicKey = KeyManager.derivePublicKey(privateKey);
77
+ expect(publicKey).toMatch(/^04[0-9a-f]+$/);
78
+ expect(toHex(backupKey)).not.toBe(privateKey);
79
+ });
80
+
81
+ it('rejects an invalid recovery phrase', async () => {
82
+ await expect(RecoveryPhraseService.deriveBackupMaterial('not a real phrase')).rejects.toThrow(
83
+ /Invalid recovery phrase/,
84
+ );
85
+ });
86
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * b3 Phase 0 crypto primitives — HKDF-SHA256, XChaCha20-Poly1305 AEAD, and
3
+ * secp256k1 ECDH. These are the pure, self-contained foundation for the Commons
4
+ * encrypted-backup and device-to-device transfer flows.
5
+ *
6
+ * - kdf: pinned against the published RFC 5869 test vectors (TC1/TC2/TC3) so an
7
+ * accidental algorithm/library swap is caught as a regression.
8
+ * - aead: round-trip plus exhaustive tamper coverage (flipped byte, wrong AAD,
9
+ * wrong key, wrong nonce) — every mismatch must throw.
10
+ * - ecdh: symmetry (`derive(a,pubB) === derive(b,pubA)`), fixed 32-byte width,
11
+ * and compressed/uncompressed public-key parity.
12
+ */
13
+
14
+ import { ec as EC } from 'elliptic';
15
+ import { hkdfSha256 } from '../kdf';
16
+ import { encryptAead, decryptAead, AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH } from '../aead';
17
+ import { deriveSharedSecret } from '../ecdh';
18
+
19
+ const ec = new EC('secp256k1');
20
+
21
+ const fromHex = (hex: string): Uint8Array =>
22
+ Uint8Array.from(Buffer.from(hex.replace(/\s/g, ''), 'hex'));
23
+ const toHex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex');
24
+ const range = (start: number, end: number): Uint8Array =>
25
+ Uint8Array.from(Array.from({ length: end - start + 1 }, (_, i) => start + i));
26
+
27
+ describe('hkdfSha256 (RFC 5869)', () => {
28
+ // RFC 5869 Appendix A — Test Case 1 (basic, with salt & info).
29
+ it('matches RFC 5869 Test Case 1', () => {
30
+ const okm = hkdfSha256(
31
+ fromHex('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'),
32
+ fromHex('000102030405060708090a0b0c'),
33
+ fromHex('f0f1f2f3f4f5f6f7f8f9'),
34
+ 42,
35
+ );
36
+ expect(toHex(okm)).toBe(
37
+ '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865',
38
+ );
39
+ });
40
+
41
+ // RFC 5869 Appendix A — Test Case 2 (longer inputs & output).
42
+ it('matches RFC 5869 Test Case 2', () => {
43
+ const okm = hkdfSha256(range(0x00, 0x4f), range(0x60, 0xaf), range(0xb0, 0xff), 82);
44
+ expect(toHex(okm)).toBe(
45
+ 'b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87',
46
+ );
47
+ });
48
+
49
+ // RFC 5869 Appendix A — Test Case 3 (zero-length salt & info).
50
+ it('matches RFC 5869 Test Case 3 (empty salt/info)', () => {
51
+ const okm = hkdfSha256(
52
+ fromHex('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'),
53
+ new Uint8Array(0),
54
+ new Uint8Array(0),
55
+ 42,
56
+ );
57
+ expect(toHex(okm)).toBe(
58
+ '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8',
59
+ );
60
+ });
61
+
62
+ it('returns exactly the requested number of bytes', () => {
63
+ const ikm = fromHex('deadbeef');
64
+ expect(hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 16).length).toBe(16);
65
+ expect(hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 32).length).toBe(32);
66
+ expect(hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 64).length).toBe(64);
67
+ });
68
+
69
+ it('derives independent keys for distinct info (context binding)', () => {
70
+ const ikm = fromHex('00112233445566778899aabbccddeeff');
71
+ const salt = fromHex('a1b2c3d4');
72
+ const backup = hkdfSha256(ikm, salt, new TextEncoder().encode('oxy.backup.v1'), 32);
73
+ const transfer = hkdfSha256(ikm, salt, new TextEncoder().encode('oxy.transfer.v1'), 32);
74
+ expect(toHex(backup)).not.toBe(toHex(transfer));
75
+ });
76
+
77
+ it('rejects invalid output lengths', () => {
78
+ const ikm = fromHex('deadbeef');
79
+ expect(() => hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 0)).toThrow();
80
+ expect(() => hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), -1)).toThrow();
81
+ expect(() => hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 1.5)).toThrow();
82
+ expect(() => hkdfSha256(ikm, new Uint8Array(0), new Uint8Array(0), 255 * 32 + 1)).toThrow();
83
+ });
84
+ });
85
+
86
+ describe('encryptAead / decryptAead (XChaCha20-Poly1305)', () => {
87
+ const key = hkdfSha256(fromHex('00'), new Uint8Array(0), new TextEncoder().encode('aead-test'), AEAD_KEY_LENGTH);
88
+ const plaintext = new TextEncoder().encode('the quick brown fox jumps over the lazy dog');
89
+ const aad = new TextEncoder().encode('oxy.backup.v1:did:web:oxy.so:u:123');
90
+
91
+ it('round-trips without AAD', () => {
92
+ const { nonce, ciphertext } = encryptAead(key, plaintext);
93
+ expect(nonce.length).toBe(AEAD_NONCE_LENGTH);
94
+ // Ciphertext = plaintext length + 16-byte Poly1305 tag.
95
+ expect(ciphertext.length).toBe(plaintext.length + 16);
96
+ expect(toHex(decryptAead(key, nonce, ciphertext))).toBe(toHex(plaintext));
97
+ });
98
+
99
+ it('round-trips with AAD', () => {
100
+ const { nonce, ciphertext } = encryptAead(key, plaintext, aad);
101
+ expect(toHex(decryptAead(key, nonce, ciphertext, aad))).toBe(toHex(plaintext));
102
+ });
103
+
104
+ it('produces a distinct random nonce on each call', () => {
105
+ const a = encryptAead(key, plaintext);
106
+ const b = encryptAead(key, plaintext);
107
+ expect(toHex(a.nonce)).not.toBe(toHex(b.nonce));
108
+ // Random nonce => distinct ciphertext even for identical plaintext+key.
109
+ expect(toHex(a.ciphertext)).not.toBe(toHex(b.ciphertext));
110
+ });
111
+
112
+ it('throws when a ciphertext byte is flipped (tamper detection)', () => {
113
+ const { nonce, ciphertext } = encryptAead(key, plaintext, aad);
114
+ const tampered = Uint8Array.from(ciphertext);
115
+ tampered[0] ^= 0x01;
116
+ expect(() => decryptAead(key, nonce, tampered, aad)).toThrow();
117
+ });
118
+
119
+ it('throws when the authentication tag is flipped (tamper detection)', () => {
120
+ const { nonce, ciphertext } = encryptAead(key, plaintext, aad);
121
+ const tampered = Uint8Array.from(ciphertext);
122
+ tampered[tampered.length - 1] ^= 0x80;
123
+ expect(() => decryptAead(key, nonce, tampered, aad)).toThrow();
124
+ });
125
+
126
+ it('throws when AAD does not match', () => {
127
+ const { nonce, ciphertext } = encryptAead(key, plaintext, aad);
128
+ const wrongAad = new TextEncoder().encode('oxy.backup.v1:did:web:oxy.so:u:999');
129
+ expect(() => decryptAead(key, nonce, ciphertext, wrongAad)).toThrow();
130
+ // AAD present at encrypt, absent at decrypt must also fail.
131
+ expect(() => decryptAead(key, nonce, ciphertext)).toThrow();
132
+ });
133
+
134
+ it('throws when the key does not match', () => {
135
+ const { nonce, ciphertext } = encryptAead(key, plaintext, aad);
136
+ const wrongKey = hkdfSha256(fromHex('01'), new Uint8Array(0), new TextEncoder().encode('aead-test'), AEAD_KEY_LENGTH);
137
+ expect(() => decryptAead(wrongKey, nonce, ciphertext, aad)).toThrow();
138
+ });
139
+
140
+ it('throws when the nonce does not match', () => {
141
+ const { nonce, ciphertext } = encryptAead(key, plaintext, aad);
142
+ const wrongNonce = Uint8Array.from(nonce);
143
+ wrongNonce[0] ^= 0x01;
144
+ expect(() => decryptAead(key, wrongNonce, ciphertext, aad)).toThrow();
145
+ });
146
+
147
+ it('rejects keys of the wrong length', () => {
148
+ expect(() => encryptAead(new Uint8Array(16), plaintext)).toThrow();
149
+ const { nonce, ciphertext } = encryptAead(key, plaintext);
150
+ expect(() => decryptAead(new Uint8Array(31), nonce, ciphertext)).toThrow();
151
+ });
152
+
153
+ it('rejects a nonce of the wrong length on decrypt', () => {
154
+ const { ciphertext } = encryptAead(key, plaintext);
155
+ expect(() => decryptAead(key, new Uint8Array(12), ciphertext)).toThrow();
156
+ });
157
+
158
+ it('handles empty plaintext', () => {
159
+ const empty = new Uint8Array(0);
160
+ const { nonce, ciphertext } = encryptAead(key, empty, aad);
161
+ expect(ciphertext.length).toBe(16); // tag only
162
+ expect(decryptAead(key, nonce, ciphertext, aad).length).toBe(0);
163
+ });
164
+ });
165
+
166
+ describe('deriveSharedSecret (secp256k1 ECDH)', () => {
167
+ const alice = ec.genKeyPair();
168
+ const bob = ec.genKeyPair();
169
+ const alicePriv = alice.getPrivate('hex');
170
+ const bobPriv = bob.getPrivate('hex');
171
+ const alicePubUncompressed = alice.getPublic('hex');
172
+ const bobPubUncompressed = bob.getPublic('hex');
173
+
174
+ it('is symmetric: derive(a, pubB) === derive(b, pubA)', () => {
175
+ const ab = deriveSharedSecret(alicePriv, bobPubUncompressed);
176
+ const ba = deriveSharedSecret(bobPriv, alicePubUncompressed);
177
+ expect(toHex(ab)).toBe(toHex(ba));
178
+ });
179
+
180
+ it('returns exactly 32 bytes', () => {
181
+ expect(deriveSharedSecret(alicePriv, bobPubUncompressed).length).toBe(32);
182
+ });
183
+
184
+ it('agrees for compressed and uncompressed public-key encodings', () => {
185
+ const bobPubCompressed = bob.getPublic(true, 'hex');
186
+ const fromUncompressed = deriveSharedSecret(alicePriv, bobPubUncompressed);
187
+ const fromCompressed = deriveSharedSecret(alicePriv, bobPubCompressed);
188
+ expect(toHex(fromCompressed)).toBe(toHex(fromUncompressed));
189
+ });
190
+
191
+ it('yields different secrets for different counterparties', () => {
192
+ const carol = ec.genKeyPair();
193
+ const withBob = deriveSharedSecret(alicePriv, bobPubUncompressed);
194
+ const withCarol = deriveSharedSecret(alicePriv, carol.getPublic('hex'));
195
+ expect(toHex(withBob)).not.toBe(toHex(withCarol));
196
+ });
197
+
198
+ it('is stable across repeated derivations', () => {
199
+ const first = deriveSharedSecret(alicePriv, bobPubUncompressed);
200
+ const second = deriveSharedSecret(alicePriv, bobPubUncompressed);
201
+ expect(toHex(first)).toBe(toHex(second));
202
+ });
203
+
204
+ it('composes with HKDF to yield a usable AEAD key round-trip', () => {
205
+ const shared = deriveSharedSecret(alicePriv, bobPubUncompressed);
206
+ const info = new TextEncoder().encode('oxy.transfer.v1');
207
+ const aliceKey = hkdfSha256(shared, new Uint8Array(0), info, AEAD_KEY_LENGTH);
208
+ const bobKey = hkdfSha256(
209
+ deriveSharedSecret(bobPriv, alicePubUncompressed),
210
+ new Uint8Array(0),
211
+ info,
212
+ AEAD_KEY_LENGTH,
213
+ );
214
+ expect(toHex(aliceKey)).toBe(toHex(bobKey));
215
+
216
+ const message = new TextEncoder().encode('device transfer payload');
217
+ const { nonce, ciphertext } = encryptAead(aliceKey, message);
218
+ expect(toHex(decryptAead(bobKey, nonce, ciphertext))).toBe(toHex(message));
219
+ });
220
+
221
+ it('rejects non-hex inputs', () => {
222
+ expect(() => deriveSharedSecret('zzzz', bobPubUncompressed)).toThrow();
223
+ expect(() => deriveSharedSecret(alicePriv, 'nothex')).toThrow();
224
+ });
225
+ });
@@ -237,6 +237,39 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
237
237
  expect(KeyManager.derivePublicKey(m.get('oxy_identity_private_key') as string)).toBe(a);
238
238
  });
239
239
 
240
+ it('a failed primary write during the post-rotation importKeyPair leaves the OLD key recoverable from backup', async () => {
241
+ // b3 key rotation: the server has already swapped to the NEW key; the device
242
+ // now commits it via importKeyPair({overwrite:true}). If that write fails
243
+ // mid-overwrite, the OLD key MUST remain intact and recoverable — never a
244
+ // half-written new identity that can't be decrypted or backed up.
245
+ const oldPublic = await KeyManager.createIdentity();
246
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
247
+ const oldPriv = ss.__getStore__().get('oxy_identity_private_key');
248
+ resetCaches();
249
+
250
+ // The NEW rotated key material (generateKeyPair does NOT persist).
251
+ const rotated = await KeyManager.generateKeyPair();
252
+
253
+ // The new primary private write fails mid-commit.
254
+ ss.__failPlan__.failOp = 'set';
255
+ ss.__failPlan__.failKey = 'oxy_identity_private_key';
256
+ await expect(KeyManager.importKeyPair(rotated.privateKey, { overwrite: true })).rejects.toBeDefined();
257
+
258
+ // Recover from the simulated fault.
259
+ ss.__failPlan__.failKey = undefined;
260
+ ss.__failPlan__.failOp = undefined;
261
+ resetCaches();
262
+
263
+ // Primary is STILL the old identity (rolled back — never the new one).
264
+ expect(await KeyManager.hasIdentity()).toBe(true);
265
+ expect(await KeyManager.getPublicKey()).toBe(oldPublic);
266
+ const m = ss.__getStore__();
267
+ expect(m.get('oxy_identity_private_key')).toBe(oldPriv);
268
+ // And the backup still holds the OLD identity, so the user can recover it.
269
+ expect(m.get('oxy_identity_backup_private_key')).toBe(oldPriv);
270
+ expect(m.get('oxy_identity_backup_public_key')).toBe(oldPublic);
271
+ });
272
+
240
273
  it('restores a provably-absent primary from a valid backup', async () => {
241
274
  const original = await KeyManager.createIdentity();
242
275
  const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * RecoveryPhraseService — pure, non-persisting derivation helpers (b3 rotation).
3
+ *
4
+ * `derivePendingIdentity()` and `derivePrivateKeyFromPhrase()` must produce
5
+ * valid, self-consistent key material WITHOUT touching secure storage — the
6
+ * whole point of a "pending" identity is that nothing is committed until an
7
+ * external step (a server-confirmed key rotation) succeeds.
8
+ */
9
+
10
+ import { RecoveryPhraseService } from '../recoveryPhrase';
11
+ import { KeyManager } from '../keyManager';
12
+
13
+ describe('RecoveryPhraseService.derivePendingIdentity (non-persisting)', () => {
14
+ afterEach(() => {
15
+ jest.restoreAllMocks();
16
+ });
17
+
18
+ it('produces a valid 12-word phrase whose privateKey derives the returned publicKey', async () => {
19
+ const pending = await RecoveryPhraseService.derivePendingIdentity();
20
+
21
+ expect(RecoveryPhraseService.validatePhrase(pending.phrase)).toBe(true);
22
+ expect(pending.words).toHaveLength(12);
23
+ expect(pending.words.join(' ')).toBe(pending.phrase);
24
+
25
+ // The private key is a canonical 64-hex-char secp256k1 scalar…
26
+ expect(pending.privateKey).toMatch(/^[0-9a-f]{64}$/);
27
+ // …and it deterministically derives the returned public key.
28
+ expect(KeyManager.derivePublicKey(pending.privateKey)).toBe(pending.publicKey);
29
+ });
30
+
31
+ it('does NOT persist anything to secure storage (never imports a key pair)', async () => {
32
+ const importSpy = jest.spyOn(KeyManager, 'importKeyPair');
33
+ const createSpy = jest.spyOn(KeyManager, 'createIdentity');
34
+
35
+ await RecoveryPhraseService.derivePendingIdentity();
36
+
37
+ expect(importSpy).not.toHaveBeenCalled();
38
+ expect(createSpy).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it('yields a distinct identity on each call', async () => {
42
+ const a = await RecoveryPhraseService.derivePendingIdentity();
43
+ const b = await RecoveryPhraseService.derivePendingIdentity();
44
+ expect(a.phrase).not.toBe(b.phrase);
45
+ expect(a.publicKey).not.toBe(b.publicKey);
46
+ });
47
+
48
+ it('derivePrivateKeyFromPhrase round-trips: same phrase → same private/public key', async () => {
49
+ const pending = await RecoveryPhraseService.derivePendingIdentity();
50
+
51
+ const rederivedPrivate = await RecoveryPhraseService.derivePrivateKeyFromPhrase(pending.phrase);
52
+ expect(rederivedPrivate).toBe(pending.privateKey);
53
+ expect(KeyManager.derivePublicKey(rederivedPrivate)).toBe(pending.publicKey);
54
+ });
55
+
56
+ it('derivePrivateKeyFromPhrase rejects an invalid mnemonic', async () => {
57
+ await expect(
58
+ RecoveryPhraseService.derivePrivateKeyFromPhrase('not a real recovery phrase at all'),
59
+ ).rejects.toThrow(/Invalid recovery phrase/);
60
+ });
61
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Authenticated Encryption with Associated Data (XChaCha20-Poly1305)
3
+ *
4
+ * Pure-JS/TS AEAD via `@noble/ciphers` — identical behaviour on web, Node, and
5
+ * React Native with zero WebCrypto / native-module dependency. This replaces
6
+ * the `crypto.subtle`-based path (unreliable on React Native) for the Commons
7
+ * encrypted backup and device-to-device transfer flows.
8
+ *
9
+ * XChaCha20-Poly1305 is chosen over AES-GCM specifically for its 24-byte
10
+ * (192-bit) random nonce: the nonce space is large enough that random nonces
11
+ * never collide in practice, so callers do not need to maintain a per-key
12
+ * counter. The 16-byte Poly1305 tag is appended to the ciphertext by the
13
+ * underlying library and validated on decrypt.
14
+ *
15
+ * The optional Associated Data (AAD) is authenticated but NOT encrypted: it
16
+ * binds the ciphertext to its context (e.g. a backup version, a device id, a
17
+ * DID). Decryption fails if the key, nonce, ciphertext, OR aad differ from
18
+ * those used at encryption time.
19
+ *
20
+ * ESM/CJS safe: static `import` only, no `require()`.
21
+ */
22
+
23
+ // Loading the polyfill guarantees `globalThis.crypto.getRandomValues` exists on
24
+ // every platform (native crypto on web/Node; expo-crypto-backed shim on RN).
25
+ import './polyfill';
26
+ import { xchacha20poly1305 } from '@noble/ciphers/chacha';
27
+
28
+ /** Key length for XChaCha20-Poly1305, in bytes (256-bit). */
29
+ export const AEAD_KEY_LENGTH = 32;
30
+ /** Nonce length for XChaCha20-Poly1305, in bytes (192-bit). */
31
+ export const AEAD_NONCE_LENGTH = 24;
32
+
33
+ /** Ciphertext (Poly1305 tag appended) plus the random nonce used to produce it. */
34
+ export interface AeadResult {
35
+ /** The 24-byte random nonce. Store/transmit alongside the ciphertext. */
36
+ nonce: Uint8Array;
37
+ /** Ciphertext with the 16-byte Poly1305 authentication tag appended. */
38
+ ciphertext: Uint8Array;
39
+ }
40
+
41
+ function assertKey(key: Uint8Array): void {
42
+ if (key.length !== AEAD_KEY_LENGTH) {
43
+ throw new Error(`AEAD key must be ${AEAD_KEY_LENGTH} bytes, got ${key.length}`);
44
+ }
45
+ }
46
+
47
+ /** Generate a fresh 24-byte random nonce via the platform CSPRNG. */
48
+ function randomNonce(): Uint8Array {
49
+ const nonce = new Uint8Array(AEAD_NONCE_LENGTH);
50
+ globalThis.crypto.getRandomValues(nonce);
51
+ return nonce;
52
+ }
53
+
54
+ /**
55
+ * Encrypt `plaintext` under `key` with a fresh random nonce, authenticating the
56
+ * optional `aad`.
57
+ *
58
+ * @param key 32-byte symmetric key (e.g. from `hkdfSha256`).
59
+ * @param plaintext Bytes to encrypt.
60
+ * @param aad Optional associated data authenticated but not encrypted.
61
+ * @returns `{ nonce, ciphertext }` — both are required to decrypt.
62
+ */
63
+ export function encryptAead(
64
+ key: Uint8Array,
65
+ plaintext: Uint8Array,
66
+ aad?: Uint8Array,
67
+ ): AeadResult {
68
+ assertKey(key);
69
+ const nonce = randomNonce();
70
+ const ciphertext = xchacha20poly1305(key, nonce, aad).encrypt(plaintext);
71
+ return { nonce, ciphertext };
72
+ }
73
+
74
+ /**
75
+ * Decrypt and authenticate `ciphertext` produced by {@link encryptAead}.
76
+ *
77
+ * Throws if the key, nonce, ciphertext, or aad differ from those used at
78
+ * encryption time (tamper detection), or if the tag is invalid.
79
+ *
80
+ * @param key 32-byte symmetric key.
81
+ * @param nonce The 24-byte nonce returned by `encryptAead`.
82
+ * @param ciphertext Ciphertext with the appended Poly1305 tag.
83
+ * @param aad The same associated data supplied at encryption time.
84
+ * @returns The recovered plaintext bytes.
85
+ */
86
+ export function decryptAead(
87
+ key: Uint8Array,
88
+ nonce: Uint8Array,
89
+ ciphertext: Uint8Array,
90
+ aad?: Uint8Array,
91
+ ): Uint8Array {
92
+ assertKey(key);
93
+ if (nonce.length !== AEAD_NONCE_LENGTH) {
94
+ throw new Error(`AEAD nonce must be ${AEAD_NONCE_LENGTH} bytes, got ${nonce.length}`);
95
+ }
96
+ return xchacha20poly1305(key, nonce, aad).decrypt(ciphertext);
97
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ECDH shared-secret derivation (secp256k1)
3
+ *
4
+ * Derives a raw 32-byte ECDH shared secret from a local private key and a
5
+ * remote public key, using the SAME `elliptic` `EC('secp256k1')` primitive the
6
+ * rest of core's identity layer uses (`keyManager.ts`). This is the key-exchange
7
+ * step for the Commons device-to-device transfer flow: each side computes the
8
+ * same shared secret, which is then run through `hkdfSha256` to derive the
9
+ * symmetric key handed to `encryptAead` / `decryptAead`.
10
+ *
11
+ * The returned value is the raw x-coordinate of the ECDH point, big-endian,
12
+ * zero-padded to 32 bytes. It is NOT itself a symmetric key — always pass it
13
+ * through a KDF (HKDF) with a context-binding `info` before use.
14
+ *
15
+ * ESM/CJS safe: static `import` only, no `require()`.
16
+ */
17
+
18
+ import { ec as EC } from 'elliptic';
19
+
20
+ const ec = new EC('secp256k1');
21
+
22
+ /** Lowercase and left-pad a private-key hex string to canonical 64-char form. */
23
+ function canonicalPrivateKey(key: string): string {
24
+ return key.toLowerCase().padStart(64, '0');
25
+ }
26
+
27
+ const HEX_RE = /^[0-9a-fA-F]+$/;
28
+
29
+ /**
30
+ * Compute the ECDH shared secret between a local private key and a remote
31
+ * public key on secp256k1.
32
+ *
33
+ * Symmetric by construction:
34
+ * `deriveSharedSecret(privA, pubB) === deriveSharedSecret(privB, pubA)`.
35
+ *
36
+ * @param privateKeyHex Local private key, hex (up to 64 chars; canonicalized).
37
+ * @param otherPublicKeyHex Remote public key, hex — compressed (`02`/`03` + 32
38
+ * bytes) or uncompressed (`04` + 64 bytes).
39
+ * @returns The 32-byte big-endian shared secret.
40
+ */
41
+ export function deriveSharedSecret(
42
+ privateKeyHex: string,
43
+ otherPublicKeyHex: string,
44
+ ): Uint8Array {
45
+ if (typeof privateKeyHex !== 'string' || !HEX_RE.test(privateKeyHex)) {
46
+ throw new Error('deriveSharedSecret: privateKeyHex must be a hex string');
47
+ }
48
+ if (typeof otherPublicKeyHex !== 'string' || !HEX_RE.test(otherPublicKeyHex)) {
49
+ throw new Error('deriveSharedSecret: otherPublicKeyHex must be a hex string');
50
+ }
51
+
52
+ const keyPair = ec.keyFromPrivate(canonicalPrivateKey(privateKeyHex));
53
+ const otherKey = ec.keyFromPublic(otherPublicKeyHex, 'hex');
54
+
55
+ // `derive` returns a BN (the shared point's x-coordinate). Serialize it
56
+ // big-endian, fixed 32 bytes, so both sides agree byte-for-byte regardless of
57
+ // any leading-zero stripping.
58
+ const sharedBytes = keyPair.derive(otherKey.getPublic()).toArray('be', 32);
59
+ return Uint8Array.from(sharedBytes);
60
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Key Derivation Function (HKDF-SHA256)
3
+ *
4
+ * Pure-JS/TS HKDF via `@noble/hashes` — identical behaviour on web, Node, and
5
+ * React Native with zero WebCrypto / native-module dependency. Used to derive
6
+ * fixed-length symmetric keys from higher-entropy input keying material (an
7
+ * ECDH shared secret, a recovery-phrase seed, etc.) for the Commons encrypted
8
+ * backup and device-to-device transfer flows.
9
+ *
10
+ * ESM/CJS safe: static `import` only, no `require()`.
11
+ */
12
+
13
+ import { hkdf } from '@noble/hashes/hkdf';
14
+ import { sha256 } from '@noble/hashes/sha256';
15
+
16
+ /**
17
+ * Derive `length` bytes of keying material from `ikm` using HKDF-SHA256
18
+ * (RFC 5869 — extract-then-expand).
19
+ *
20
+ * @param ikm Input keying material (the raw secret; NOT necessarily uniform).
21
+ * @param salt Non-secret random salt. An empty array is treated by HKDF as a
22
+ * zero-filled salt of the hash length — pass a real salt whenever
23
+ * one is available so derivations for different contexts diverge.
24
+ * @param info Context/application-binding string ("what is this key for").
25
+ * Distinct `info` values yield independent keys from the same ikm.
26
+ * @param length Number of output bytes. Must be in (0, 255 * 32].
27
+ * @returns Exactly `length` bytes of derived keying material.
28
+ */
29
+ export function hkdfSha256(
30
+ ikm: Uint8Array,
31
+ salt: Uint8Array,
32
+ info: Uint8Array,
33
+ length: number,
34
+ ): Uint8Array {
35
+ if (!Number.isInteger(length) || length <= 0) {
36
+ throw new Error('hkdfSha256: length must be a positive integer');
37
+ }
38
+ // HKDF-Expand is defined for at most 255 * HashLen bytes of output.
39
+ if (length > 255 * 32) {
40
+ throw new Error('hkdfSha256: length must not exceed 8160 bytes (255 * 32)');
41
+ }
42
+ return hkdf(sha256, ikm, salt, info, length);
43
+ }
@@ -1464,6 +1464,14 @@ export class KeyManager {
1464
1464
  return keyPair.getPublic('hex');
1465
1465
  }
1466
1466
 
1467
+ /**
1468
+ * Normalize a public key to uncompressed, lowercased hex. Used when building
1469
+ * signed rotation payloads so legacy compressed/cased encodings still verify.
1470
+ */
1471
+ static canonicalPublicKey(publicKey: string): string {
1472
+ return ec.keyFromPublic(publicKey, 'hex').getPublic(false, 'hex').toLowerCase();
1473
+ }
1474
+
1467
1475
  /**
1468
1476
  * Validate that a string is a valid public key
1469
1477
  *