Skip to content

Commit b871d71

Browse files
committed
adjust tests
1 parent 188ccb8 commit b871d71

3 files changed

Lines changed: 330 additions & 1 deletion

File tree

tests/lib/fcmHelper.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ jest.mock('@notifee/react-native', () => ({
99
__esModule: true,
1010
default: {
1111
onBackgroundEvent: jest.fn(),
12+
onForegroundEvent: jest.fn(),
1213
requestPermission: jest.fn(),
1314
createChannel: jest.fn(),
1415
displayNotification: jest.fn(),
1516
},
17+
EventType: {
18+
PRESS: 1,
19+
},
1620
}));
1721

1822
// Mock keychain module
@@ -95,6 +99,8 @@ describe('FCM Helper Lib', () => {
9599
});
96100
(messaging.onMessage as jest.Mock).mockImplementation(() => {});
97101
mockedNotifee.onBackgroundEvent.mockImplementation(() => {});
102+
// onForegroundEvent expects the observer to return a cleanup fn.
103+
mockedNotifee.onForegroundEvent.mockImplementation(() => () => {});
98104

99105
notificationListener();
100106

@@ -110,7 +116,10 @@ describe('FCM Helper Lib', () => {
110116
mockMessagingInstance,
111117
expect.any(Function),
112118
);
113-
expect(mockedNotifee.onBackgroundEvent).toHaveBeenCalled();
119+
// onBackgroundEvent is registered at module load (top-level of
120+
// fcmHelper.ts) per Notifee's requirement, not inside
121+
// notificationListener — so we assert the foreground listener.
122+
expect(mockedNotifee.onForegroundEvent).toHaveBeenCalled();
114123
});
115124

116125
test('should return success data when onBackgroundMessageHandler', () => {

tests/lib/recoveryCrypto.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Use `node:crypto` explicitly — `'crypto'` is aliased to react-native-
2+
// quick-crypto by the RN jest preset.
3+
const { createECDH, randomBytes } = require('node:crypto');
4+
import { Buffer } from 'buffer';
5+
6+
// The global jest.setup.js mock for `react-native-quick-crypto` only
7+
// stubs `randomBytes` and `createHash` — our module also needs
8+
// `createECDH` and `createCipheriv`. Override here by delegating to
9+
// Node's real `crypto` module for this test file.
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 {
29+
wrapSkRForTransit,
30+
TRANSIT_VERSION,
31+
} from '../../src/lib/recoveryCrypto';
32+
33+
/**
34+
* ssp-key's recoveryCrypto only implements the transit wrap side of the
35+
* protocol (the wallet does the ECIES envelope build + ECIES + transit
36+
* unwrap). Tests here verify:
37+
* 1. The transit byte layout is correct (version byte, iv, ciphertext+tag).
38+
* 2. Output is hex-decodable and the right length.
39+
* 3. Wrong-sized sk_r is rejected.
40+
* 4. Running wrap twice produces different ciphertext (fresh IV).
41+
*
42+
* Round-trip correctness (wrap ↔ unwrap) is already covered by the
43+
* ssp-wallet integration test which uses the same wire format.
44+
*/
45+
46+
function genKeypair() {
47+
const dh = createECDH('secp256k1');
48+
dh.generateKeys();
49+
return {
50+
priv: dh.getPrivateKey(),
51+
pub: dh.getPublicKey(null, 'compressed'),
52+
};
53+
}
54+
55+
describe('recoveryCrypto (ssp-key side)', () => {
56+
describe('wrapSkRForTransit', () => {
57+
test('produces a hex string with the expected byte layout', () => {
58+
const sspKey = genKeypair();
59+
const walletEph = genKeypair();
60+
const skR = randomBytes(32);
61+
62+
const wrapped = wrapSkRForTransit(sspKey.priv, walletEph.pub, skR);
63+
const bytes = Buffer.from(wrapped, 'hex');
64+
65+
// [1 byte version][12 byte iv][32 byte ciphertext][16 byte tag] = 61 bytes
66+
expect(bytes.length).toBe(1 + 12 + 32 + 16);
67+
expect(bytes[0]).toBe(TRANSIT_VERSION);
68+
expect(TRANSIT_VERSION).toBe(0x01);
69+
});
70+
71+
test('produces different ciphertexts on repeated calls (fresh IV)', () => {
72+
const sspKey = genKeypair();
73+
const walletEph = genKeypair();
74+
const skR = randomBytes(32);
75+
76+
const a = wrapSkRForTransit(sspKey.priv, walletEph.pub, skR);
77+
const b = wrapSkRForTransit(sspKey.priv, walletEph.pub, skR);
78+
79+
expect(a).not.toBe(b);
80+
});
81+
82+
test('rejects sk_r that is not 32 bytes', () => {
83+
const sspKey = genKeypair();
84+
const walletEph = genKeypair();
85+
86+
expect(() =>
87+
wrapSkRForTransit(sspKey.priv, walletEph.pub, randomBytes(16)),
88+
).toThrow(/sk_r must be 32 bytes/);
89+
expect(() =>
90+
wrapSkRForTransit(sspKey.priv, walletEph.pub, randomBytes(64)),
91+
).toThrow(/sk_r must be 32 bytes/);
92+
});
93+
94+
test('output is valid hex (decodable)', () => {
95+
const sspKey = genKeypair();
96+
const walletEph = genKeypair();
97+
const wrapped = wrapSkRForTransit(
98+
sspKey.priv,
99+
walletEph.pub,
100+
randomBytes(32),
101+
);
102+
expect(/^[0-9a-f]+$/.test(wrapped)).toBe(true);
103+
expect(wrapped.length % 2).toBe(0);
104+
});
105+
});
106+
});

tests/lib/recoveryHandler.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)