Repository Public
1import { mkdtempSync, readFileSync, statSync } from 'node:fs';2import { tmpdir } from 'node:os';3import { join } from 'node:path';4import { describe, expect, it } from 'vitest';5import { ed25519 } from '@noble/curves/ed25519.js';6import { base58 } from '@scure/base';7import { Transaction } from '@scure/btc-signer';8import { hex } from '@scure/base';9import { parseTransaction, recoverTransactionAddress, decodeFunctionData, erc20Abi } from 'viem';10import { TronWeb, utils as tronUtils } from 'tronweb';11import { buildSolMessage, intentHash, NETWORKS, verifyAuditChain, type Intent } from '@spw/core';12import { TestPasskey } from '../../../tests/helpers/passkey.ts';13import { deriveKeys } from '../src/keys.ts';14import { initKeystore, loadMnemonic } from '../src/keystore.ts';15import { authHeader, createSignerServer } from '../src/server.ts';16import { enrollChallenge, Signer } from '../src/signer.ts';17import { Store } from '../src/store.ts';18import { createHash } from 'node:crypto';1920// Public BIP-39 test vector mnemonic — holds no funds, never used outside tests.21const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';22const RP = 'wallet.test', ORIGIN = 'https://wallet.test';23const keys = deriveKeys(TEST_MNEMONIC);2425function setup(nowRef = { t: Math.floor(Date.now() / 1000) }) {26 const dir = mkdtempSync(join(tmpdir(), 'spw-signer-'));27 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));28 store.state.setupTokenHash = createHash('sha256').update('setup-token').digest('hex');29 const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => nowRef.t);30 const pk = new TestPasskey(RP, ORIGIN);31 return { dir, store, signer, pk, nowRef };32}33let seq = 0;34function evm(now: number, over: Partial<Intent> = {}): Intent {35 seq += 1;36 return { v: 1, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-${String(seq).padStart(12, '0')}`, keyId: keys.keyId, network: 'base', chainRef: '8453',37 asset: NETWORKS.base.tokens[0]!.id, symbol: 'USDC', decimals: 6, amount: '10000000', from: keys.addresses.evm,38 to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', maxFee: '100000000000000', createdAt: now, expiresAt: now + 120, policyVersion: 4,39 evm: { nonce: 0, gasLimit: '90000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' }, ...over };40}4142describe('key derivation (public test vectors)', () => {43 it('matches BIP-44 / BIP-84 / SLIP-0010 reference addresses', () => {44 expect(keys.addresses.evm).toBe('0x9858EfFD232B4033E47d90003D41EC34EcaEda94');45 expect(keys.addresses.btc).toBe('bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu');46 expect(keys.addresses.sol).toBe('HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk');47 });48});4950describe('per-network keys', () => {51 it('Litecoin uses BIP-84 coin type 2 (public vector) and every network has an address', () => {52 expect(keys.addresses.litecoin).toBe('ltc1qjmxnz78nmc8nq77wuxh25n2es7rzm5c2rkk4wh');53 for (const n of Object.values(NETWORKS)) expect(keys.addresses[n.id], n.id).toBeTruthy();54 for (const id of ['base', 'optimism', 'bsc', 'avalanche', 'hyperevm']) expect(keys.addresses[id]).toBe(keys.addresses.evm);55 });56 it('signs a Litecoin intent with Litecoin parameters and refuses a Bitcoin address as destination', async () => {57 const { signer, pk, nowRef } = setup();58 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });59 const t = nowRef.t;60 const ltc: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-cccccccccccc', keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 4, network: 'litecoin', chainRef: NETWORKS.litecoin.chainRef,61 asset: 'native', symbol: 'LTC', decimals: 8, amount: '2000000', from: keys.addresses.litecoin!, to: 'ltc1qg42tkwuuxefutzxezdkdel39gfstuap288mfea', maxFee: '100000',62 btc: { inputs: [{ txid: 'cd'.repeat(32), vout: 0, value: '5000000' }], change: '2990000', fee: '10000' } };63 const r = await signer.sign(ltc, pk.assert(intentHash(ltc)));64 const tx = Transaction.fromRaw(hex.decode(r.raw));65 expect(tx.id).toBe(r.txid);66 expect(tx.getOutput(0).amount).toBe(2_000_000n);67 const bad = { ...ltc, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-dddddddddddd', to: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' };68 await expect(signer.sign(bad, pk.assert(intentHash(bad)))).rejects.toThrow();69 const wrongChain = { ...ltc, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-eeeeeeeeeeee', chainRef: NETWORKS.bitcoin.chainRef };70 await expect(signer.sign(wrongChain, pk.assert(intentHash(wrongChain)))).rejects.toThrow(/chain/);71 });72 it('TRON: coin type 195 address (official SDK vector); signs a USDT intent identical to tronweb and refuses other shapes', async () => {73 expect(keys.addresses.tron).toBe('TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH');74 const sdkAccount = TronWeb.fromMnemonic(TEST_MNEMONIC, "m/44'/195'/0'/0/0");75 expect(keys.addresses.tron).toBe(sdkAccount.address);76 const { signer, pk, nowRef, store } = setup();77 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });78 const t = nowRef.t, usdt = NETWORKS.tron.tokens[0]!;79 const x = { ref_block_bytes: '2660', ref_block_hash: 'afad6ef83bbf9b23', expiration: String((t + 240) * 1000), timestamp: String(t * 1000), fee_limit: '20000000' };80 const tron: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-ffffffffff01', keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 4, network: 'tron', chainRef: NETWORKS.tron.chainRef,81 asset: usdt.id, symbol: 'USDT', decimals: 6, amount: '12500000', from: keys.addresses.tron!, to: 'TKHuVq1oKVruCGLvqVexFs6dawKv6fQgFs', maxFee: '21000000', x };82 const r = await signer.sign(tron, pk.assert(intentHash(tron)));83 // the same transfer built and signed by the official SDK, offline84 const tw = new TronWeb({ fullHost: 'http://127.0.0.1:9' });85 const sdk = (await tw.transactionBuilder.triggerSmartContract(usdt.id, 'transfer(address,uint256)',86 { feeLimit: 20_000_000, callValue: 0, txLocal: true, blockHeader: { ref_block_bytes: x.ref_block_bytes, ref_block_hash: x.ref_block_hash, expiration: Number(x.expiration), timestamp: Number(x.timestamp) } } as never,87 [{ type: 'address', value: tron.to }, { type: 'uint256', value: tron.amount }], tron.from)).transaction;88 const signed = await tw.trx.sign(sdk, sdkAccount.privateKey.slice(2));89 expect(r.txid).toBe(sdk.txID);90 const pb = tronUtils.transaction.txJsonToPb(sdk);91 pb.addSignature(Uint8Array.from(Buffer.from(signed.signature[0]!, 'hex')));92 expect(r.raw).toBe(Buffer.from(pb.serializeBinary()).toString('hex'));93 expect(store.state.spent[`${new Date(t * 1000).toISOString().slice(0, 10)}|tron:USDT`]).toBe('12500000');9495 const deny = async (id: string, over: Partial<Intent>, re: RegExp) => {96 const bad = { ...tron, proposalId: `6f1c1f0e-8f5f-4b1e-9c56-ffffffffff${id}`, ...over } as Intent;97 await expect(signer.sign(bad, pk.assert(intentHash(bad)))).rejects.toThrow(re);98 };99 await deny('02', { chainRef: NETWORKS.bitcoin.chainRef }, /chain/);100 await deny('03', { maxFee: '20000000' }, /fee exceeds/); // fee_limit + bandwidth above the approved ceiling101 await deny('04', { x: { ...x, fee_limit: '39000000' }, maxFee: '41000000' }, /fee ceiling above policy/);102 await deny('05', { asset: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', symbol: 'USDC' }, /limit|allowlisted/); // not allowlisted: no policy cap either103 await deny('06', { x: { ...x, data: 'ff' } }, /exactly/);104 await deny('07', { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }, /TRON address/);105 await deny('08', { to: usdt.id }, /token contract/);106 await deny('09', { amount: '50000001' }, /per-transaction limit/);107 await deny('10', { from: 'TV6MuMXfmLbBqPZvBHdwFsDnQeVfnmiuSi' }, /not controlled/);108 await deny('11', { x: { ...x, expiration: String((t + 120 + 601) * 1000) } }, /approval window/);109 const { x: _x, ...noX } = tron; // without its chain section the intent cannot even be hashed, so nothing can be approved110 expect(() => intentHash(noX as Intent)).toThrow(/chain section/);111 await expect(signer.sign(noX, pk.assert(intentHash(tron)))).rejects.toThrow();112 // a native transfer through the same path113 const trx: Intent = { ...tron, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-ffffffffff20', asset: 'native', symbol: 'TRX', amount: '2000000', maxFee: '1100000',114 x: { ref_block_bytes: x.ref_block_bytes, ref_block_hash: x.ref_block_hash, expiration: x.expiration, timestamp: x.timestamp } };115 const n = await signer.sign(trx, pk.assert(intentHash(trx)));116 const sdkTrx = await tw.transactionBuilder.sendTrx(trx.to, 2_000_000, trx.from, { blockHeader: { ref_block_bytes: x.ref_block_bytes, ref_block_hash: x.ref_block_hash, expiration: Number(x.expiration), timestamp: Number(x.timestamp) } });117 expect(n.txid).toBe(sdkTrx.txID);118 expect(n.raw).toContain(sdkTrx.raw_data_hex);119 await expect(signer.sign(trx, pk.assert(intentHash(trx)))).rejects.toThrow(/already signed/); // replay120 });121});122123describe('keystore', () => {124 it('encrypts at rest, 0600, never overwrites, round-trips', () => {125 const dir = mkdtempSync(join(tmpdir(), 'spw-ks-'));126 const ks = join(dir, 'keystore.json'), wk = join(dir, 'w', 'wrap.key');127 initKeystore(ks, wk);128 expect(statSync(ks).mode & 0o777).toBe(0o600);129 expect(statSync(wk).mode & 0o777).toBe(0o600);130 const m = loadMnemonic(ks, wk);131 expect(m.split(' ').length).toBe(24);132 const file = readFileSync(ks, 'utf8');133 for (const w of m.split(' ').filter((x) => x.length > 5)) expect(file.includes(w)).toBe(false);134 expect(() => initKeystore(ks, wk)).toThrow(/refusing/);135 expect(deriveKeys(m).addresses.evm).toMatch(/^0x[0-9a-fA-F]{40}$/);136 });137});138139describe('enrollment', () => {140 it('first passkey needs the one-time token; later ones need an existing passkey', async () => {141 const { signer, pk } = setup();142 await expect(signer.enroll({ credential: pk.credential(), setupToken: 'wrong' })).rejects.toThrow(/setup token/);143 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });144 const pk2 = new TestPasskey(RP, ORIGIN);145 await expect(signer.enroll({ credential: pk2.credential(), setupToken: 'setup-token' })).rejects.toThrow(/passkey approval/);146 const rogue = new TestPasskey(RP, ORIGIN);147 await expect(signer.enroll({ credential: pk2.credential(), assertion: rogue.assert(enrollChallenge(pk2.id, pk2.publicKey)) })).rejects.toThrow();148 await signer.enroll({ credential: pk2.credential(), assertion: pk.assert(enrollChallenge(pk2.id, pk2.publicKey)) });149 expect(signer.status().credentials.length).toBe(2);150 });151});152153describe('signing', () => {154 it('signs an approved USDC transfer; signature recovers to the wallet address', async () => {155 const { signer, pk, nowRef, store } = setup();156 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });157 const i = evm(nowRef.t);158 const r = await signer.sign(i, pk.assert(intentHash(i)));159 const tx = parseTransaction(r.raw as `0x${string}`);160 expect(await recoverTransactionAddress({ serializedTransaction: r.raw as never })).toBe(keys.addresses.evm);161 expect(tx.to?.toLowerCase()).toBe(NETWORKS.base.tokens[0]!.id.toLowerCase());162 expect(decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args).toEqual([i.to, 10_000_000n]);163 expect(verifyAuditChain(store.state.journal)).toBeNull();164 });165166 it('rejects approval bound to a different intent (any mutated field)', async () => {167 const { signer, pk, nowRef } = setup();168 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });169 const i = evm(nowRef.t);170 const a = pk.assert(intentHash(i));171 for (const m of [{ amount: '10000001' }, { to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' }, { maxFee: '100000000000001' },172 { evm: { ...i.evm!, nonce: 1 } }, { network: 'arbitrum' as const, chainRef: '42161', asset: NETWORKS.arbitrum.tokens[0]!.id }])173 await expect(signer.sign({ ...i, ...m }, a)).rejects.toThrow(/approval/);174 });175176 it('rejects replay, wrong origin, no user verification, unknown passkey, missing approval', async () => {177 const { signer, pk, nowRef } = setup();178 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });179 const i = evm(nowRef.t);180 await signer.sign(i, pk.assert(intentHash(i)));181 await expect(signer.sign(i, pk.assert(intentHash(i)))).rejects.toThrow(/already signed/);182 const j = evm(nowRef.t);183 await expect(signer.sign(j, pk.assert(intentHash(j), { origin: 'https://evil.example' }))).rejects.toThrow();184 await expect(signer.sign(j, pk.assert(intentHash(j), { uv: false }))).rejects.toThrow();185 await expect(signer.sign(j, new TestPasskey(RP, ORIGIN).assert(intentHash(j)))).rejects.toThrow(/enrolled/);186 await expect(signer.sign(j, undefined as never)).rejects.toThrow();187 await signer.sign(j, pk.assert(intentHash(j))); // still usable after failed attempts188 });189190 it('enforces limits, expiry, halt, key, chain, source and token allowlist', async () => {191 const { signer, pk, nowRef } = setup();192 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });193 const t = nowRef.t;194 const tryIt = (i: Intent) => signer.sign(i, pk.assert(intentHash(i)));195 await expect(tryIt(evm(t, { amount: '50000001' }))).rejects.toThrow(/per-transaction/);196 await expect(tryIt(evm(t, { expiresAt: t + 4000 }))).rejects.toThrow(/window/);197 await expect(tryIt(evm(t, { keyId: 'spw-ffffffffffffffff' }))).rejects.toThrow(/another key/);198 await expect(tryIt(evm(t, { chainRef: '1' }))).rejects.toThrow(/chain/);199 await expect(tryIt(evm(t, { from: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' }))).rejects.toThrow(/source/);200 await expect(tryIt(evm(t, { asset: '0xdAC17F958D2ee523a2206206994597C13D831ec7' }))).rejects.toThrow(/allowlisted/);201 await expect(tryIt(evm(t, { to: keys.addresses.evm }))).rejects.toThrow(/destination equals source/);202 await tryIt(evm(t, { amount: '50000000' }));203 await tryIt(evm(t, { amount: '50000000' }));204 await expect(tryIt(evm(t, { amount: '1' }))).rejects.toThrow(/daily limit/);205 nowRef.t += 86400; // next UTC day206 const k = evm(nowRef.t, { amount: '1' });207 await tryIt(k);208 const old = evm(t);209 await expect(tryIt(old)).rejects.toThrow(/expired/);210 signer.halt();211 await expect(tryIt(evm(nowRef.t))).rejects.toThrow(/halt/);212 });213214 it('signs Solana and Bitcoin intents verifiably', async () => {215 const { signer, pk, nowRef } = setup();216 await signer.enroll({ credential: pk.credential(), setupToken: 'setup-token' });217 const t = nowRef.t, common = { v: 1 as const, keyId: keys.keyId, createdAt: t, expiresAt: t + 120, policyVersion: 4 };218 const sol: Intent = { ...common, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-aaaaaaaaaaaa', network: 'solana', chainRef: NETWORKS.solana.chainRef,219 asset: NETWORKS.solana.tokens[0]!.id, symbol: 'USDC', decimals: 6, amount: '5000000', from: keys.addresses.sol,220 to: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', maxFee: '100000',221 sol: { recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', lastValidBlockHeight: 10, createAta: false, computeUnitLimit: 60000, computeUnitPrice: '1000' } };222 const rs = await signer.sign(sol, pk.assert(intentHash(sol)));223 const wire = Buffer.from(rs.raw, 'base64');224 expect(wire[0]).toBe(1);225 const sig = wire.subarray(1, 65), msg = wire.subarray(65);226 expect(Buffer.compare(msg, Buffer.from(buildSolMessage(sol)))).toBe(0);227 expect(ed25519.verify(sig, msg, base58.decode(keys.addresses.sol))).toBe(true);228 expect(rs.txid).toBe(base58.encode(sig));229230 const btc: Intent = { ...common, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-bbbbbbbbbbbb', network: 'bitcoin', chainRef: NETWORKS.bitcoin.chainRef,231 asset: 'native', symbol: 'BTC', decimals: 8, amount: '20000', from: keys.addresses.btc, to: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', maxFee: '2000',232 btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 0, value: '50000' }], change: '29500', fee: '500' } };233 const rb = await signer.sign(btc, pk.assert(intentHash(btc)));234 const tx = Transaction.fromRaw(hex.decode(rb.raw));235 expect(tx.id).toBe(rb.txid);236 expect(tx.outputsLength).toBe(2);237 expect(tx.getOutput(0).amount).toBe(20000n);238 expect(tx.getInput(0).finalScriptWitness?.length).toBe(2);239 });240});241242describe('gateway transport', () => {243 it('requires a valid, fresh, non-replayed HMAC and exposes no generic signing route', async () => {244 const { signer } = setup();245 const secret = 's'.repeat(48);246 const srv = createSignerServer(signer, secret).listen(0, '127.0.0.1');247 await new Promise((r) => srv.once('listening', r));248 const url = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;249 const call = (path: string, method: string, auth?: string, body = '') => fetch(url + path, { method, headers: auth ? { 'x-spw-auth': auth } : {}, body: body || undefined });250 expect((await call('/status', 'GET')).status).toBe(401);251 expect((await call('/status', 'GET', authHeader('x'.repeat(48), 'GET', '/status', ''))).status).toBe(401);252 expect((await call('/status', 'GET', authHeader(secret, 'GET', '/status', '', Math.floor(Date.now() / 1000) - 600))).status).toBe(401);253 const h = authHeader(secret, 'GET', '/status', '');254 const ok = await call('/status', 'GET', h);255 expect(ok.status).toBe(200);256 const body = await ok.text();257 expect(body).not.toMatch(/abandon|priv|seed/i);258 expect((await call('/status', 'GET', h)).status).toBe(401); // replayed nonce259 expect((await call('/sign-message', 'POST', authHeader(secret, 'POST', '/sign-message', '{}'), '{}')).status).toBe(404);260 const bad = await call('/sign', 'POST', authHeader(secret, 'POST', '/sign', '{"intent":{}}'), '{"intent":{}}');261 expect(bad.status).toBe(422);262 srv.close();263 });264});265