Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions projects/keepkey-sdk/tests/_clearsign.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Shared EVM clear-sign blob builder for the keepkey-sdk tests.
*
* Reproduces python-keepkey @1545299's signed-metadata format byte-for-byte
* (verified by clearsign-offline-parity.js against REFERENCE_BLOB_SNAPSHOTS).
* Given a catalog flow, builds the fixed deterministic tx + the metadata blob
* bound to that tx's real legacy sighash, signed by the CI test key (slot 3).
*
* Used by:
* - clearsign-offline-parity.js (offline sha256+len parity gate, no device)
* - evm-clearsign/_loadsigner-and-sign.js (on-device: load signer → sign flow)
*
* Golden input: fixtures/clearsign-golden.json (dumped from python-keepkey).
*/
const fs = require('fs')
const path = require('path')
const { keccak_256 } = require('@noble/hashes/sha3')
const { sha256 } = require('@noble/hashes/sha256')
const { secp256k1 } = require('@noble/curves/secp256k1')

const GOLDEN = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures/clearsign-golden.json'), 'utf8'))

// Fixed tx params — every catalog flow signs the same deterministic tx, so the
// firmware recomputes the identical sighash the blob is bound to.
const NONCE = 0n, GAS_PRICE = 20000000000n, GAS_LIMIT = 250000n
const CLASSIFICATION_VERIFIED = 1

const CI_TEST_PUBKEY = GOLDEN.testPubKey // == firmware METADATA_PUBKEYS[3]
const CI_SIGNER_ALIAS = 'CI Test'
const TEST_KEY_ID = GOLDEN.keyId // slot 3
const TEST_PRIV = hex(GOLDEN.testPrivKey)

function hex(h) { return Uint8Array.from(Buffer.from(h.replace(/^0x/, ''), 'hex')) }
function be(n, len) { const b = new Uint8Array(len); const v = new DataView(b.buffer); if (len === 4) v.setUint32(0, n); else if (len === 2) v.setUint16(0, n); return b }
function concat(arrs) { const n = arrs.reduce((a, x) => a + x.length, 0); const o = new Uint8Array(n); let off = 0; for (const a of arrs) { o.set(a, off); off += a.length } return o }

// ── minimal RLP (mirrors python signed_metadata._rlp_*) ──
function intMinBE(v) { if (v === 0n) return new Uint8Array(0); const o = []; while (v > 0n) { o.unshift(Number(v & 0xffn)); v >>= 8n } return Uint8Array.from(o) }
function rlpStr(b) {
if (b.length === 1 && b[0] < 0x80) return b
if (b.length <= 55) return concat([Uint8Array.from([0x80 + b.length]), b])
const le = intMinBE(BigInt(b.length)); return concat([Uint8Array.from([0xb7 + le.length]), le, b])
}
function rlpList(items) {
const body = concat(items)
if (body.length <= 55) return concat([Uint8Array.from([0xc0 + body.length]), body])
const le = intMinBE(BigInt(body.length)); return concat([Uint8Array.from([0xf7 + le.length]), le, body])
}

/** keccak256(rlp([nonce,gasPrice,gasLimit,to,value,data,chainId,0,0])) — EIP-155 legacy sighash */
function sighashLegacy(to, value, data, chainId) {
const items = [
rlpStr(intMinBE(NONCE)), rlpStr(intMinBE(GAS_PRICE)), rlpStr(intMinBE(GAS_LIMIT)),
rlpStr(to), rlpStr(intMinBE(BigInt(value))), rlpStr(data),
]
if (chainId) items.push(rlpStr(intMinBE(BigInt(chainId))), rlpStr(new Uint8Array(0)), rlpStr(new Uint8Array(0)))
return keccak_256(rlpList(items))
}

function serializeMetadata(f) {
const name = new TextEncoder().encode(f.methodName)
const parts = [Uint8Array.from([0x01]), be(f.chainId, 4), f.contractAddress, f.selector, f.txHash,
be(name.length, 2), name, Uint8Array.from([f.args.length])]
for (const a of f.args) {
const an = new TextEncoder().encode(a.name)
parts.push(Uint8Array.from([an.length]), an, Uint8Array.from([a.format]), be(a.value.length, 2), a.value)
}
parts.push(Uint8Array.from([f.classification]), be(f.timestamp, 4), Uint8Array.from([f.keyId]))
return concat(parts)
}

function signBlob(payload, priv) {
const digest = sha256(payload)
// lowS:false — python-ecdsa does NOT normalize; noble defaults to lowS:true.
const sig = secp256k1.sign(digest, priv, { lowS: false })
return concat([payload, sig.toCompactRawBytes(), Uint8Array.from([27 + sig.recovery])])
}

/**
* Build the deterministic tx + CI-signed metadata blob for a catalog flow.
* Returns { tx, blobHex, keyId, flow } — POST tx (with txMetadata) to
* /eth/sign-transaction; the device recomputes the same sighash the blob binds.
*/
function buildFlowBlob(key) {
const flow = GOLDEN.flows[key]
if (!flow) throw new Error(`unknown flow: ${key}`)
const to = hex(flow.to), data = hex(flow.calldata)
const jsHash = sighashLegacy(to, flow.value, data, flow.chainId)
const jsHashHex = Buffer.from(jsHash).toString('hex')
if (jsHashHex !== flow.txHash) throw new Error(`${key}: JS sighash ${jsHashHex} != golden ${flow.txHash}`)
const payload = serializeMetadata({
chainId: flow.chainId, contractAddress: to, selector: hex(flow.selector),
txHash: jsHash, methodName: flow.method,
args: flow.args.map(a => ({ name: a.name, format: a.format, value: hex(a.value) })),
classification: CLASSIFICATION_VERIFIED, timestamp: GOLDEN.timestamp, keyId: GOLDEN.keyId,
})
const blob = signBlob(payload, TEST_PRIV)
return {
flow,
keyId: GOLDEN.keyId,
blobHex: Buffer.from(blob).toString('hex'),
tx: {
to: '0x' + flow.to,
value: '0x' + BigInt(flow.value).toString(16),
data: '0x' + flow.calldata,
nonce: '0x0',
gasLimit: '0x' + GAS_LIMIT.toString(16),
gasPrice: '0x' + GAS_PRICE.toString(16),
chainId: flow.chainId,
},
}
}

module.exports = {
GOLDEN, buildFlowBlob, sighashLegacy, serializeMetadata, signBlob,
CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID, TEST_PRIV,
}
41 changes: 41 additions & 0 deletions projects/keepkey-sdk/tests/clearsign-offline-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Offline clear-sign parity gate — NO device, NO vault.
*
* Proves the shared JS metadata serializer + sighash + RFC-6979 signer
* (tests/_clearsign.js) reproduces python-keepkey's frozen reference blobs
* (REFERENCE_BLOB_SNAPSHOTS) for all 51 catalog flows, byte-for-byte. Golden
* input: fixtures/clearsign-golden.json (dumped from python-keepkey @1545299).
*
* Green here means the JS blob format, the legacy EIP-155 sighash, and the
* deterministic signature all match python — the cheapest gate before any
* device time. See docs/handoff-keepkey-sdk-clearsign-coverage.md.
*
* Run: node tests/clearsign-offline-parity.js
*/
const { sha256 } = require('@noble/hashes/sha256')
const { secp256k1 } = require('@noble/curves/secp256k1')
const { GOLDEN, buildFlowBlob, TEST_PRIV } = require('./_clearsign')

function main() {
// Sanity: the CI test key's pubkey == firmware slot 3.
const pub = Buffer.from(secp256k1.getPublicKey(TEST_PRIV, true)).toString('hex')
if (pub !== GOLDEN.firmwareSlot3Pubkey) { console.error(`FATAL: test key pubkey ${pub} != slot3 ${GOLDEN.firmwareSlot3Pubkey}`); process.exit(1) }

let pass = 0, fail = 0
const fails = []
for (const key of Object.keys(GOLDEN.flows)) {
// buildFlowBlob throws if the JS sighash != golden tx_hash (the #1 device trap).
let built
try { built = buildFlowBlob(key) } catch (e) { fail++; fails.push({ key, err: String(e.message) }); continue }
const blob = Buffer.from(built.blobHex, 'hex')
const [wantSha, wantLen] = GOLDEN.snapshots[key]
const gotSha = Buffer.from(sha256(blob)).toString('hex')
if (blob.length === wantLen && gotSha === wantSha) pass++
else { fail++; fails.push({ key, lenGot: blob.length, lenWant: wantLen, shaOk: gotSha === wantSha }) }
}
console.log(`\nclearsign offline parity: ${pass}/${pass + fail} flows match python @1545299 (blob sha256+len + JS sighash)`)
if (fail) { console.error('\nFAILURES:'); for (const f of fails) console.error(' ' + JSON.stringify(f)) }
process.exit(fail ? 1 : 0)
}
main()
135 changes: 135 additions & 0 deletions projects/keepkey-sdk/tests/evm-clearsign/clearsign-signer-flows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* evm-clearsign/clearsign-signer-flows.js — dual-mode on-device clear-sign test.
*
* Exercises the full runtime-signer clear-sign train against a real device, in
* either of two trust sources — same load→sign→assert skeleton, different signer:
*
* CLEARSIGN_MODE=self (default) — the CI test key signs golden catalog blobs
* offline; loads into slot 3. No Pioneer.
* CLEARSIGN_MODE=pioneer — the live Pioneer insight signer builds the
* blob and returns its pubkey; loads into the
* slot Pioneer signs (CLEARSIGN_KEY_SLOT, def 1).
*
* Both modes:
* 1. loadClearsignSigner(pubkey, slot) → DEVICE CONFIRM ("Trust signer" screen)
* 2. per flow: ethSignTransaction with the bound metadata blob → DEVICE clear-sign
* pages (VERIFIED) instead of raw hex. Assert a signature comes back.
*
* Requires firmware 7.15.0-rc3+ (LoadClearsignSigner msg 117) and a Vault built with
* the hdwallet loadClearsignSigner method + /eth/clearsign/load-signer route. Signers
* are RAM-only — reload after a device reboot. Sign-only, no broadcast.
*
* Run: KEEPKEY_API_KEY=… node tests/evm-clearsign/clearsign-signer-flows.js
* KEEPKEY_API_KEY=… CLEARSIGN_MODE=pioneer node tests/evm-clearsign/clearsign-signer-flows.js
*
* pioneer mode needs Pioneer's /descriptors/sign enabled (CLEARSIGN_LIVE_SIGN=true +
* INSIGHT_MNEMONIC) at PIONEER_URL (default http://localhost:9001).
*/
const { run, ETH_PATH, erc20Approve } = require('../_helpers')
const { buildFlowBlob, sighashLegacy, CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID } = require('../_clearsign')

const MODE = process.env.CLEARSIGN_MODE || 'self'
const PIONEER_URL = (process.env.PIONEER_URL || 'http://localhost:9001').replace(/\/+$/, '')

// Deterministic legacy tx params — identical to _clearsign.js so Pioneer, the local
// sighash cross-check, and the device all recompute the same EIP-155 sighash.
const NONCE = '0x0', GAS_PRICE = '0x4a817c800' /* 20 gwei */, GAS_LIMIT = '0x3d090' /* 250000 */

// self mode: golden catalog flows the CI key signs offline (slot 3, byte-parity-proven).
const SELF_FLOWS = ['aave-v3-supply', 'erc20-transfer', 'erc20-approve-unlimited']

// pioneer mode: real contracts Pioneer classifies VERIFIED. Extend as coverage grows.
const PIONEER_FLOWS = [
{
label: 'approve (VERIFIED contract)',
chainId: 1,
to: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
data: erc20Approve('0x1111111111111111111111111111111111111111', 1000000n),
value: '0x0',
},
]

const hb = (h) => Uint8Array.from(Buffer.from(h.replace(/^0x/, ''), 'hex'))

/** self mode: CI signer + offline golden blobs (buildFlowBlob does the binding). */
function selfProvider() {
const flows = SELF_FLOWS.map((k) => {
const b = buildFlowBlob(k)
return { label: k, tx: b.tx, blobHex: b.blobHex, keyId: b.keyId }
})
return {
signer: { keyId: TEST_KEY_ID, pubkey: CI_TEST_PUBKEY, alias: CI_SIGNER_ALIAS },
flows,
}
}

/** pioneer mode: fetch each blob + the signer pubkey from live Pioneer. */
async function pioneerProvider(assert) {
const flows = []
for (const f of PIONEER_FLOWS) {
const body = {
chainId: f.chainId, contractAddress: f.to, data: f.data,
nonce: NONCE, gasLimit: GAS_LIMIT, value: f.value, gasPrice: GAS_PRICE,
}
const resp = await fetch(`${PIONEER_URL}/api/v1/descriptors/sign`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
const d = await resp.json()
if (!d.success) throw new Error(`Pioneer /sign failed for ${f.label}: ${d.error} `
+ `(enable with CLEARSIGN_LIVE_SIGN=true + INSIGHT_MNEMONIC on ${PIONEER_URL})`)

// Prove the tx we'll send the device binds to the blob Pioneer signed.
const localHash = '0x' + Buffer.from(sighashLegacy(hb(f.to), BigInt(f.value), hb(f.data), f.chainId)).toString('hex')
if (localHash !== d.txHash) throw new Error(`${f.label}: sighash mismatch — local ${localHash} != Pioneer ${d.txHash}`)

console.log(` [${f.label}] classification=${d.classification} keyId=${d.keyId} `
+ `pubkey=${(d.signerPubkey || 'MISSING').slice(0, 22)}… blob=${Buffer.from(d.signedPayload, 'base64').length}B`)
flows.push({
label: f.label, keyId: d.keyId, signerPubkey: d.signerPubkey, classification: d.classification,
blobHex: Buffer.from(d.signedPayload, 'base64').toString('hex'), // caller path arrayifies a STRING as hex
tx: { to: f.to, value: f.value, data: f.data, nonce: NONCE, gasLimit: GAS_LIMIT, gasPrice: GAS_PRICE, chainId: f.chainId },
})
}

const f0 = flows[0]
// Preconditions that name their own fix if Pioneer isn't fully deployed:
assert('Pioneer returned signerPubkey (needs feat/clearsign-live-signer build)', !!f0.signerPubkey)
assert(`Pioneer signs a LOADABLE slot keyId=${f0.keyId} (1-3; set CLEARSIGN_KEY_SLOT=1 if 0)`,
f0.keyId >= 1 && f0.keyId <= 3)
if (!flows.every((f) => f.keyId === f0.keyId && f.signerPubkey === f0.signerPubkey))
throw new Error('Pioneer flows disagree on signer — all must share one keyId + pubkey')

return { signer: { keyId: f0.keyId, pubkey: f0.signerPubkey, alias: 'Pioneer Insight' }, flows }
}

run(`clear-sign runtime signer (mode=${MODE})`, async (getSdk, assert) => {
const sdk = await getSdk()

// loadClearsignSigner + ethSignTransaction each block on a human device confirm,
// but the SDK wrappers use post()'s 30s read timeout (not its 600s signingTimeoutMs),
// so a slow confirm aborts client-side. Raise the read timeout for this run.
// (sdk.client is TS-private but a real runtime property.)
if (sdk.client) sdk.client.timeoutMs = 600_000

const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH })
console.log(` Device ETH address: ${address}`)

const { signer, flows } = MODE === 'pioneer' ? await pioneerProvider(assert) : selfProvider()

console.log(`\n Loading signer into slot ${signer.keyId}, alias "${signer.alias}"`)
console.log(` pubkey ${signer.pubkey}`)
console.log(' >>> CONFIRM the "Trust signer" screen on device <<<\n')
const load = await sdk.eth.loadClearsignSigner(signer)
assert('Signer loaded (device confirmed)', !!load && load.ok === true)

for (const f of flows) {
console.log(`\n [${f.label}] to=${f.tx.to} keyId=${f.keyId}`)
console.log(' >>> APPROVE the clear-sign pages on device (should NOT be raw hex) <<<')
const result = await sdk.eth.ethSignTransaction({
...f.tx,
addressNList: ETH_PATH,
txMetadata: { signedPayload: f.blobHex, keyId: f.keyId },
})
assert(`[${f.label}] got signature`, !!(result && (result.serializedTx || result.r)))
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* evm-clearsign/loadsigner-sign-flows.js — the vault-side clear-sign train, on device.
*
* 1. Loads the CI test signer (pubkey == firmware slot 3) at runtime via the new
* POST /eth/clearsign/load-signer route → DEVICE CONFIRM (trust screen).
* 2. For each flagship flow: builds the metadata blob bound to the tx's real
* sighash (tests/_clearsign.js, byte-parity-proven offline) and signs via
* /eth/sign-transaction with txMetadata → DEVICE CONFIRM (clear-sign pages).
*
* Requires firmware 7.15.0-rc3+ (LoadClearsignSigner msg 117) and a Vault built
* with the new hdwallet loadClearsignSigner + route. Sign-only, no broadcast,
* no wipe. The signer is RAM-only — reload if the device reboots.
*
* Run: KEEPKEY_API_KEY=… node tests/evm-clearsign/loadsigner-sign-flows.js
*/
const { run, ETH_PATH } = require('../_helpers')
const { buildFlowBlob, CI_TEST_PUBKEY, CI_SIGNER_ALIAS, TEST_KEY_ID } = require('../_clearsign')

// Flagship tranche: STRING + TOKEN_AMOUNT + ADDRESS (aave), token transfer,
// and an UNLIMITED approval render. Expand to the full 51 once green.
const FLOWS = ['aave-v3-supply', 'erc20-transfer', 'erc20-approve-unlimited']

run('clear-sign: load CI signer + sign flagship flows', async (getSdk, assert) => {
const sdk = await getSdk()

const { address } = await sdk.address.ethGetAddress({ address_n: ETH_PATH })
console.log(` Device ETH address: ${address}`)

console.log(`\n Loading CI signer into slot ${TEST_KEY_ID}, alias "${CI_SIGNER_ALIAS}"`)
console.log(` pubkey ${CI_TEST_PUBKEY}`)
console.log(' >>> CONFIRM the "Trust signer" screen on device <<<\n')
const load = await sdk.eth.loadClearsignSigner({ keyId: TEST_KEY_ID, pubkey: CI_TEST_PUBKEY, alias: CI_SIGNER_ALIAS })
assert('Signer loaded (device confirmed)', !!load && load.ok === true)

for (const key of FLOWS) {
const { tx, blobHex, keyId, flow } = buildFlowBlob(key)
console.log(`\n [${key}] ${flow.method} to=${tx.to}`)
console.log(` args: ${flow.args.map(a => a.name).join(', ')}`)
console.log(' >>> APPROVE the clear-sign pages on device <<<')
const result = await sdk.eth.ethSignTransaction({
...tx,
addressNList: ETH_PATH,
txMetadata: { signedPayload: blobHex, keyId },
})
assert(`[${key}] got signature`, !!(result && (result.serializedTx || result.r)))
}
})
Loading