|
| 1 | +import { HDKey } from '@scure/bip32'; |
| 2 | +// Use `node:crypto` explicitly — `'crypto'` is aliased to react-native- |
| 3 | +// quick-crypto by the RN jest preset. |
| 4 | +const { createECDH, createDecipheriv, createHash } = require('node:crypto'); |
| 5 | +import { Buffer } from 'buffer'; |
| 6 | + |
| 7 | +// Override the global jest.setup.js mock for `react-native-quick-crypto` |
| 8 | +// with real Node crypto for this test file (the global mock only stubs |
| 9 | +// randomBytes+createHash, not ECDH/cipher primitives). |
| 10 | +jest.mock('react-native-quick-crypto', () => { |
| 11 | + // The `react-native` jest preset aliases `crypto` to `react-native- |
| 12 | + // quick-crypto`, so `jest.requireActual('crypto')` recurses into the |
| 13 | + // very module we're trying to mock. Use `node:crypto` which the preset |
| 14 | + // does not remap to get Node's real crypto. |
| 15 | + const nodeCrypto = jest.requireActual('node:crypto'); |
| 16 | + return { |
| 17 | + __esModule: true, |
| 18 | + default: { |
| 19 | + randomBytes: nodeCrypto.randomBytes, |
| 20 | + createHash: nodeCrypto.createHash, |
| 21 | + createECDH: nodeCrypto.createECDH, |
| 22 | + createCipheriv: nodeCrypto.createCipheriv, |
| 23 | + createDecipheriv: nodeCrypto.createDecipheriv, |
| 24 | + }, |
| 25 | + }; |
| 26 | +}); |
| 27 | + |
| 28 | +import { getMasterXpriv } from '../../src/lib/wallet'; |
| 29 | +import { buildRecoveryResponse } from '../../src/lib/recoveryHandler'; |
| 30 | + |
| 31 | +/** |
| 32 | + * Tests for the ssp-key-side recovery handler. |
| 33 | + * |
| 34 | + * Verifies: |
| 35 | + * 1. Input validation rejects malformed pkEph / nonce. |
| 36 | + * 2. The nonce + timestamp are echoed back unchanged. |
| 37 | + * 3. The transit ciphertext is decryptable by the wallet ephemeral key |
| 38 | + * using the same ECDH-derived AES key (wire format matches the |
| 39 | + * wallet's `unwrapSkRFromTransit`). |
| 40 | + * 4. The unwrapped sk_r equals the BIP32 /11/0 derivation from the |
| 41 | + * same seed. |
| 42 | + */ |
| 43 | + |
| 44 | +const MNEMONIC = |
| 45 | + 'silver trouble mountain crouch angry park film strong escape theory illegal bunker cargo taxi tuna real drift alert state match great escape option explain'; |
| 46 | + |
| 47 | +function genEphemeralKeypair() { |
| 48 | + const dh = createECDH('secp256k1'); |
| 49 | + dh.generateKeys(); |
| 50 | + return { |
| 51 | + priv: dh.getPrivateKey(), |
| 52 | + pub: dh.getPublicKey(null, 'compressed'), |
| 53 | + }; |
| 54 | +} |
| 55 | + |
| 56 | +function ecdh(privKey: Buffer, otherPubKey: Buffer): Buffer { |
| 57 | + const dh = createECDH('secp256k1'); |
| 58 | + dh.setPrivateKey(privKey); |
| 59 | + return dh.computeSecret(otherPubKey); |
| 60 | +} |
| 61 | + |
| 62 | +function deriveTransitKey(sharedSecret: Buffer): Buffer { |
| 63 | + return createHash('sha256') |
| 64 | + .update( |
| 65 | + Buffer.concat([ |
| 66 | + Buffer.from('SSP-RECOVERY-TRANSIT-v1', 'utf8'), |
| 67 | + sharedSecret, |
| 68 | + ]), |
| 69 | + ) |
| 70 | + .digest(); |
| 71 | +} |
| 72 | + |
| 73 | +function getBtcIdentityXpriv(): string { |
| 74 | + return getMasterXpriv(MNEMONIC, 48, 0, 0, 'p2wsh', 'btc'); |
| 75 | +} |
| 76 | + |
| 77 | +describe('recoveryHandler.buildRecoveryResponse', () => { |
| 78 | + test('echoes nonce and timestamp from the request', () => { |
| 79 | + const xpriv = getBtcIdentityXpriv(); |
| 80 | + const eph = genEphemeralKeypair(); |
| 81 | + const nonce = 'aa'.repeat(16); |
| 82 | + const timestamp = 1_700_000_000; |
| 83 | + |
| 84 | + const response = buildRecoveryResponse({ |
| 85 | + xprivKeyIdentity: xpriv, |
| 86 | + request: { |
| 87 | + pkEph: eph.pub.toString('hex'), |
| 88 | + nonce, |
| 89 | + timestamp, |
| 90 | + }, |
| 91 | + identityChain: 'btc' as const, |
| 92 | + }); |
| 93 | + |
| 94 | + expect(response.nonce).toBe(nonce); |
| 95 | + expect(response.timestamp).toBe(timestamp); |
| 96 | + expect(typeof response.transit).toBe('string'); |
| 97 | + expect(response.transit.length).toBeGreaterThan(0); |
| 98 | + }); |
| 99 | + |
| 100 | + test('produces a transit ciphertext decryptable by the wallet ephemeral key', () => { |
| 101 | + const xpriv = getBtcIdentityXpriv(); |
| 102 | + const eph = genEphemeralKeypair(); |
| 103 | + |
| 104 | + const response = buildRecoveryResponse({ |
| 105 | + xprivKeyIdentity: xpriv, |
| 106 | + request: { |
| 107 | + pkEph: eph.pub.toString('hex'), |
| 108 | + nonce: 'cd'.repeat(16), |
| 109 | + timestamp: 1_700_000_000, |
| 110 | + }, |
| 111 | + identityChain: 'btc' as const, |
| 112 | + }); |
| 113 | + |
| 114 | + const bytes = Buffer.from(response.transit, 'hex'); |
| 115 | + expect(bytes[0]).toBe(0x01); // version |
| 116 | + |
| 117 | + const iv = bytes.subarray(1, 13); |
| 118 | + const ciphertext = bytes.subarray(13, 13 + 32); |
| 119 | + const tag = bytes.subarray(13 + 32); |
| 120 | + |
| 121 | + // Wallet-side view: derive ssp-key's identity pubkey (the envelope |
| 122 | + // stores this as `keyIdentityPubKey`) from the same xpriv. |
| 123 | + const { blockchains } = require('@storage/blockchains'); |
| 124 | + const master = HDKey.fromExtendedKey(xpriv, blockchains.btc.bip32); |
| 125 | + const identityChild = master.deriveChild(10).deriveChild(0); |
| 126 | + const sspKeyIdentityPub = Buffer.from(identityChild.publicKey!); |
| 127 | + |
| 128 | + // Wallet-side ECDH: walletEphPriv + sspKeyIdentityPub. |
| 129 | + const shared = ecdh(eph.priv, sspKeyIdentityPub); |
| 130 | + const aesKey = deriveTransitKey(shared); |
| 131 | + |
| 132 | + const decipher = createDecipheriv('aes-256-gcm', aesKey, iv); |
| 133 | + decipher.setAuthTag(tag); |
| 134 | + const skR = Buffer.concat([decipher.update(ciphertext), decipher.final()]); |
| 135 | + |
| 136 | + expect(skR.length).toBe(32); |
| 137 | + |
| 138 | + // And the unwrapped sk_r must match the /11/0 derivation from the seed. |
| 139 | + const recoveryChild = master.deriveChild(11).deriveChild(0); |
| 140 | + const expectedSkR = Buffer.from(recoveryChild.privateKey!); |
| 141 | + expect(skR.equals(expectedSkR)).toBe(true); |
| 142 | + }); |
| 143 | + |
| 144 | + test('rejects a malformed pkEph (wrong length)', () => { |
| 145 | + const xpriv = getBtcIdentityXpriv(); |
| 146 | + expect(() => |
| 147 | + buildRecoveryResponse({ |
| 148 | + xprivKeyIdentity: xpriv, |
| 149 | + request: { |
| 150 | + pkEph: '02aabb', |
| 151 | + nonce: 'cd'.repeat(16), |
| 152 | + timestamp: 1_700_000_000, |
| 153 | + }, |
| 154 | + identityChain: 'btc' as const, |
| 155 | + }), |
| 156 | + ).toThrow(/invalid pkEph/); |
| 157 | + }); |
| 158 | + |
| 159 | + test('rejects a malformed pkEph (non-hex)', () => { |
| 160 | + const xpriv = getBtcIdentityXpriv(); |
| 161 | + expect(() => |
| 162 | + buildRecoveryResponse({ |
| 163 | + xprivKeyIdentity: xpriv, |
| 164 | + request: { |
| 165 | + pkEph: 'zz'.repeat(33), |
| 166 | + nonce: 'cd'.repeat(16), |
| 167 | + timestamp: 1_700_000_000, |
| 168 | + }, |
| 169 | + identityChain: 'btc' as const, |
| 170 | + }), |
| 171 | + ).toThrow(/invalid pkEph/); |
| 172 | + }); |
| 173 | + |
| 174 | + test('rejects a malformed nonce (non-hex)', () => { |
| 175 | + const xpriv = getBtcIdentityXpriv(); |
| 176 | + const eph = genEphemeralKeypair(); |
| 177 | + expect(() => |
| 178 | + buildRecoveryResponse({ |
| 179 | + xprivKeyIdentity: xpriv, |
| 180 | + request: { |
| 181 | + pkEph: eph.pub.toString('hex'), |
| 182 | + nonce: 'not-hex!', |
| 183 | + timestamp: 1_700_000_000, |
| 184 | + }, |
| 185 | + identityChain: 'btc' as const, |
| 186 | + }), |
| 187 | + ).toThrow(/invalid nonce/); |
| 188 | + }); |
| 189 | + |
| 190 | + test('produces different transit ciphertexts on repeated calls (fresh IV)', () => { |
| 191 | + const xpriv = getBtcIdentityXpriv(); |
| 192 | + const eph = genEphemeralKeypair(); |
| 193 | + const request = { |
| 194 | + pkEph: eph.pub.toString('hex'), |
| 195 | + nonce: 'cd'.repeat(16), |
| 196 | + timestamp: 1_700_000_000, |
| 197 | + }; |
| 198 | + |
| 199 | + const a = buildRecoveryResponse({ |
| 200 | + xprivKeyIdentity: xpriv, |
| 201 | + request, |
| 202 | + identityChain: 'btc' as const, |
| 203 | + }); |
| 204 | + const b = buildRecoveryResponse({ |
| 205 | + xprivKeyIdentity: xpriv, |
| 206 | + request, |
| 207 | + identityChain: 'btc' as const, |
| 208 | + }); |
| 209 | + |
| 210 | + expect(a.transit).not.toBe(b.transit); |
| 211 | + expect(a.nonce).toBe(b.nonce); |
| 212 | + expect(a.timestamp).toBe(b.timestamp); |
| 213 | + }); |
| 214 | +}); |
0 commit comments