SPB Git

Repository Public

signer.test.ts 17.7 KB 265 lines typescript

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
Files main
Menu

Theme