diff --git a/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts b/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts new file mode 100644 index 00000000..6b4e49e8 --- /dev/null +++ b/projects/keepkey-vault/src/bun/emulator-confirm-details.test.ts @@ -0,0 +1,180 @@ +/** + * The emulator confirm dialog must not MISREPRESENT a Hive operation. + * + * /hive/sign-operations hands the device a pre-serialized blob, so the dialog + * has nothing to read amounts out of unless the caller passes the op array. It + * previously showed "Hive Sign Operations / Chain: HIVE" and nothing else — the + * user approved a claim, a market order and a power-down through an identical + * prompt. These tests pin the two properties that matter: the op is named, and + * no row is invented for an op that has no counterparty. + */ +import { describe, it, expect } from 'bun:test' +import { hiveConfirmDetails, hiveMessagePreview } from './emulator-confirm-details' + +describe('hiveConfirmDetails', () => { + it('names the operation instead of a bare chain header', () => { + const d = hiveConfirmDetails('hiveSignOperations', [ + ['claim_reward_balance', { + account: 'alice', + reward_hive: '1.500 HIVE', + reward_hbd: '0.000 HBD', + reward_vests: '1000.000000 VESTS', + }], + ]) + expect(d.opLabel).toBe('Claim Rewards') + expect(d.chain).toBe('Hive') + }) + + it('omits zero rewards so the real amount is not buried', () => { + const d = hiveConfirmDetails('hiveSignOperations', [ + ['claim_reward_balance', { + account: 'alice', + reward_hive: '1.500 HIVE', + reward_hbd: '0.000 HBD', + reward_vests: '1000.000000 VESTS', + }], + ]) + expect(d.value).toContain('1.500 HIVE') + expect(d.value).toContain('1000.000000 VESTS') + expect(d.value).not.toContain('0.000 HBD') + }) + + it('does not forge a recipient for an op that has none', () => { + // Claim/cancel/profile act on the signer's own account. A "To:" row here + // would be a claim the operation does not make. + for (const op of [ + ['claim_reward_balance', { account: 'alice', reward_hive: '1.500 HIVE' }], + ['limit_order_cancel', { owner: 'alice', orderid: 1 }], + ['account_update2', { account: 'alice', posting_json_metadata: '{}' }], + ] as Array<[string, any]>) { + const d = hiveConfirmDetails('hiveSignOperations', [op]) + expect(d.to, `${op[0]} invented a To row`).toBeUndefined() + } + }) + + it('labels the counterparty by its real role', () => { + const del = hiveConfirmDetails('hiveSignOperations', [ + ['delegate_vesting_shares', { delegator: 'alice', delegatee: 'bob', vesting_shares: '1000.000000 VESTS' }], + ]) + expect(del.to).toBe('@bob') + expect(del.toLabel).toBe('Delegatee') + + const vote = hiveConfirmDetails('hiveSignOperations', [ + ['vote', { voter: 'alice', author: 'bob', permlink: 'a-post', weight: 10000 }], + ]) + expect(vote.toLabel).toBe('Post') + expect(vote.to).toContain('@bob') + }) + + it('says "self" for a self power-up rather than showing an empty row', () => { + const d = hiveConfirmDetails('hiveSignOperations', [ + ['transfer_to_vesting', { from: 'alice', to: '', amount: '1.500 HIVE' }], + ]) + expect(d.to).toBe('self') + expect(d.value).toBe('1.500 HIVE') + }) + + it('shows both sides of a market order', () => { + const d = hiveConfirmDetails('hiveSignOperations', [ + ['limit_order_create', { + owner: 'alice', orderid: 1, + amount_to_sell: '1.500 HIVE', min_to_receive: '0.400 HBD', + fill_or_kill: false, expiration: 1700003600, + }], + ]) + expect(d.opLabel).toBe('Market Order') + expect(d.value).toBe('1.500 HIVE → 0.400 HBD') + }) + + it('never claims a single recipient for a batch', () => { + const d = hiveConfirmDetails('hiveSignOperations', [ + ['comment', { author: 'alice', permlink: 'p', title: 'T', body: 'b', json_metadata: '{}' }], + ['comment_options', { author: 'alice', permlink: 'p', percent_hbd: 10000 }], + ]) + expect(d.opLabel).toBe('2 Hive operations') + expect(d.to).toBeUndefined() + expect(d.memo).toContain('Post / Comment') + expect(d.memo).toContain('Payout Options') + }) + + it('asserts nothing when the op array is missing or unparseable', () => { + // Better a bare dialog than a fabricated one — the OLED is still correct. + for (const bad of [undefined, null, [], 'nonsense', [{ not: 'an op' }]]) { + const d = hiveConfirmDetails('hiveSignOperations', bad) + expect(d.chain).toBe('Hive') + expect(d.opLabel).toBeUndefined() + expect(d.to).toBeUndefined() + expect(d.value).toBeUndefined() + } + }) +}) + +describe('hiveMessagePreview', () => { + it('shows the login message a dApp is asking to sign', () => { + const msg = Buffer.from('{login: "bithighlander22"}', 'utf8') + expect(hiveMessagePreview(msg)).toBe('{login: "bithighlander22"}') + }) + + it('collapses whitespace and truncates rather than flooding the dialog', () => { + const long = Buffer.from('a'.repeat(200), 'utf8') + const out = hiveMessagePreview(long) + expect(out.length).toBeLessThanOrEqual(65) + expect(out.endsWith('…')).toBe(true) + expect(hiveMessagePreview(Buffer.from('a\n\n b', 'utf8'))).toBe('a b') + }) + + it('reports binary payloads as bytes instead of mojibake', () => { + // Keychain signs a serialized Buffer as raw bytes; those are not text. + const bin = Buffer.from([0xff, 0xfe, 0x00, 0x01]) + expect(hiveMessagePreview(bin)).toBe('4 bytes (binary)') + }) + + it('reports VALID-UTF-8 control bytes as binary, not invisible text', () => { + // 00 01 02 03 is well-formed UTF-8, so a decode round-trip alone accepts + // it and the dialog would show an empty-looking message for a payload the + // device is being asked to sign. + expect(hiveMessagePreview(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe('4 bytes (binary)') + // Embedded NUL inside otherwise-printable text. + expect(hiveMessagePreview(Buffer.from([0x61, 0x62, 0x00, 0x63, 0x64]))).toBe('5 bytes (binary)') + }) + + it('reports bidi/zero-width controls as binary — they spoof what is read', () => { + // U+202E RIGHT-TO-LEFT OVERRIDE reorders the rendered string, so the text + // shown can differ from the bytes signed. + const rlo = Buffer.from('user‮exe.gnp', 'utf8') + expect(hiveMessagePreview(rlo)).toBe(`${rlo.length} bytes (binary)`) + // U+200B ZERO WIDTH SPACE — invisible, so "alice" and "al​ice" look identical. + const zwsp = Buffer.from('al​ice', 'utf8') + expect(hiveMessagePreview(zwsp)).toBe(`${zwsp.length} bytes (binary)`) + }) + + it('still accepts ordinary whitespace and non-ASCII text', () => { + expect(hiveMessagePreview(Buffer.from('line1\nline2\tend', 'utf8'))).toBe('line1 line2 end') + expect(hiveMessagePreview(Buffer.from('gm 🛹 skatehive', 'utf8'))).toBe('gm 🛹 skatehive') + }) + + it('reports whitespace-only payloads as binary rather than an empty dialog', () => { + expect(hiveMessagePreview(Buffer.from(' \n\t ', 'utf8'))).toBe('6 bytes (binary)') + }) +}) + +describe('vote weight formatting', () => { + const pct = (weight: number) => + hiveConfirmDetails('hiveSignOperations', [ + ['vote', { voter: 'alice', author: 'bob', permlink: 'p', weight }], + ] as any).value + + it('does not round fractional basis-point weights to a wrong percentage', () => { + // toFixed(0) reported these as 0%, 1% and -1% — misstating the vote. + expect(pct(1)).toBe('0.01%') + expect(pct(50)).toBe('0.5%') + expect(pct(-50)).toBe('-0.5%') + }) + + it('keeps whole percentages clean', () => { + expect(pct(10000)).toBe('100%') + expect(pct(-10000)).toBe('-100%') + expect(pct(0)).toBe('0%') + expect(pct(500)).toBe('5%') + }) +}) diff --git a/projects/keepkey-vault/src/bun/emulator-confirm-details.ts b/projects/keepkey-vault/src/bun/emulator-confirm-details.ts index ff76655d..35083d60 100644 --- a/projects/keepkey-vault/src/bun/emulator-confirm-details.ts +++ b/projects/keepkey-vault/src/bun/emulator-confirm-details.ts @@ -155,3 +155,176 @@ export function evmConfirmDetails(operation: string, fallbackChain: string, para memo: `data ${selector}${decoded ? '' : ' (unrecognized)'}`, } } + +// ── Hive ──────────────────────────────────────────────────────────────── +// +// /hive/sign-operations sends the device a pre-serialized Graphene blob, so +// unlike every other chain the confirm dialog has no params to read amounts +// out of — without this it showed "Hive Sign Operations / Chain: HIVE" and +// nothing else, for a tx that may carry up to four distinct operations. The +// caller passes the ORIGINAL op array (the same one it serialized) purely for +// display; nothing here feeds the serializer. +// +// Multi-screen ops (claim_reward_balance is 2 screens, limit_order_create 2, +// comment_options 2 + one per beneficiary) raise one prompt PER firmware +// ButtonRequest, so the same summary is shown on each press. That is intended: +// the OLED behind it is what changes, and it stays authoritative. + +/** Human label per op — same wording the extension's approval card uses. */ +const HIVE_OP_LABELS: Record = { + vote: 'Vote', + comment: 'Post / Comment', + comment_options: 'Payout Options', + custom_json: 'Custom JSON', + transfer_to_vesting: 'Power Up', + withdraw_vesting: 'Power Down', + delegate_vesting_shares: 'Delegate HP', + transfer_to_savings: 'Savings Deposit', + transfer_from_savings: 'Savings Withdraw', + claim_reward_balance: 'Claim Rewards', + convert: 'Convert HBD', + account_update2: 'Update Profile', + limit_order_create: 'Market Order', + limit_order_cancel: 'Cancel Order', +} + +/** + * The counterparty account an op acts on, and what to call it. Returns null + * when an op has no meaningful counterparty (claim, cancel, profile update) — + * better to show no "To:" row than to forge one from the signer's own name. + */ +function hiveCounterparty(name: string, p: any): { to: string; toLabel: string } | null { + const at = (v: any) => (typeof v === 'string' && v ? `@${v}` : null) + switch (name) { + case 'vote': { + const a = at(p?.author) + return a ? { to: `${a}/${p?.permlink ?? ''}`, toLabel: 'Post' } : null + } + case 'comment_options': { + const a = at(p?.author) + return a ? { to: `${a}/${p?.permlink ?? ''}`, toLabel: 'Post' } : null + } + case 'transfer_to_vesting': { + // to may be "" meaning self — say so rather than showing an empty row. + const a = at(p?.to) + return { to: a ?? 'self', toLabel: 'To' } + } + case 'transfer_to_savings': + case 'transfer_from_savings': { + const a = at(p?.to) + return a ? { to: a, toLabel: 'To' } : null + } + case 'delegate_vesting_shares': { + const a = at(p?.delegatee) + return a ? { to: a, toLabel: 'Delegatee' } : null + } + default: + return null + } +} + +/** The amount an op moves, verbatim as serialized (already a Hive asset string). */ +function hiveValue(name: string, p: any): string | undefined { + const asset = (v: any) => (typeof v === 'string' && v ? v : undefined) + switch (name) { + case 'transfer_to_vesting': + case 'transfer_to_savings': + case 'transfer_from_savings': + case 'convert': + return asset(p?.amount) + case 'withdraw_vesting': + case 'delegate_vesting_shares': + return asset(p?.vesting_shares) + case 'limit_order_create': + return asset(p?.amount_to_sell) && asset(p?.min_to_receive) + ? `${p.amount_to_sell} → ${p.min_to_receive}` + : asset(p?.amount_to_sell) + case 'claim_reward_balance': { + // Only the non-zero rewards — a claim is typically 2 of 3, and printing + // "0.000 HIVE" alongside the real one buries it. + const parts = [p?.reward_hive, p?.reward_hbd, p?.reward_vests] + .filter((v: any) => typeof v === 'string' && v && !/^0(\.0+)? /.test(v)) + return parts.length ? parts.join(' + ') : undefined + } + case 'vote': { + // Weight is basis points (-10000..10000), so 1bp = 0.01%. toFixed(0) + // would round a real 0.5% vote to "1%" and a 0.01% vote to "0%" — + // misreporting the value being signed. Keep up to two decimals and drop + // trailing zeros so whole percentages still read as "100%". + const w = Number(p?.weight) + if (!Number.isFinite(w)) return undefined + return `${Number((w / 100).toFixed(2))}%` + } + default: + return undefined + } +} + +/** + * Build confirm details for a Hive operation batch. + * + * `ops` is the condenser-style [[name, params], …] array. A single op gets the + * full treatment (label, counterparty, amount); a batch names the count and + * lists the ops, since one dialog cannot honestly claim a single recipient for + * four operations. + */ +export function hiveConfirmDetails(operation: string, ops: any): EmulatorConfirmDetails { + const list: Array<[string, any]> = Array.isArray(ops) + ? ops.filter((o: any) => Array.isArray(o) && typeof o[0] === 'string') + : [] + + if (list.length === 0) { + // Unparseable/absent — say nothing rather than guess. The OLED still shows + // the real thing; this dialog just adds no claim of its own. + return { operation, chain: 'Hive' } + } + + if (list.length === 1) { + const [name, p] = list[0] + const party = hiveCounterparty(name, p) + return { + operation, + opLabel: HIVE_OP_LABELS[name] ?? name, + chain: 'Hive', + ...(party ? { to: party.to, toLabel: party.toLabel } : {}), + value: hiveValue(name, p), + memo: name, // the raw op name, so an unlabeled op is still identifiable + } + } + + return { + operation, + opLabel: `${list.length} Hive operations`, + chain: 'Hive', + memo: list.map(([name]) => HIVE_OP_LABELS[name] ?? name).join(' · '), + } +} + +/** + * One-line preview of a Hive signBuffer payload for the confirm dialog. + * + * Keychain signBuffer is overwhelmingly dApp login, where the message names the + * site and account being logged into — the single fact worth showing. Binary + * payloads are reported as a byte count rather than rendered as mojibake. + */ +export function hiveMessagePreview(messageBytes: ArrayLike): string { + const bytes = Buffer.from(Array.from(messageBytes)) + const txt = bytes.toString('utf8') + const binary = `${bytes.length} bytes (binary)` + // Round-trip check: a lossy decode means it was not UTF-8 text. + if (!Buffer.from(txt, 'utf8').equals(bytes)) return binary + // A round-trip alone is not enough: 00 01 02 03 and embedded NULs are VALID + // UTF-8, so they survive it and would render as invisible "text". Bidi and + // other format controls are worse than invisible — they reorder what the + // user reads, so a payload could display as something other than what is + // signed. Anything carrying control or format characters is reported by + // byte count instead of rendered. + // \p{C} = control, format, surrogate, private-use, unassigned + // Tab/newline/CR are ordinary whitespace in a login message and are folded + // to spaces below, so they are dropped before the test rather than exempted + // inside it (a trailing \p{C} in one pattern would match them too). + if (/\p{C}/u.test(txt.replace(/[\t\n\r]/g, ''))) return binary + const oneLine = txt.replace(/\s+/g, ' ').trim() + if (!oneLine) return binary + return oneLine.length > 64 ? `${oneLine.slice(0, 64)}…` : oneLine +} diff --git a/projects/keepkey-vault/src/bun/rest-api.ts b/projects/keepkey-vault/src/bun/rest-api.ts index 28ab9a17..647b1c61 100644 --- a/projects/keepkey-vault/src/bun/rest-api.ts +++ b/projects/keepkey-vault/src/bun/rest-api.ts @@ -2179,10 +2179,11 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // Default to the posting role — Keychain signBuffer is overwhelmingly // dApp login, which verifies against the account's posting authority. const addressNList = body.addressNList || body.address_n || hiveRolePath('posting', 0) + const { hiveMessagePreview } = await import('./emulator-confirm-details') const result = await emuWrap(() => (wallet as any).hiveSignMessage({ addressNList, message: new Uint8Array(messageBytes), - }), { operation: 'hiveSignMessage', chain: 'HIVE' }) + }), { operation: 'hiveSignMessage', opLabel: 'Sign Message', chain: 'Hive', memo: hiveMessagePreview(messageBytes) }) if (!result?.signature) throw new HttpError(500, 'Hive sign-message: device returned no signature') const sigBytes = result.signature instanceof Uint8Array ? Buffer.from(result.signature) : Buffer.from(String(result.signature), 'hex') const pubBytes = result.publicKey instanceof Uint8Array ? Buffer.from(result.publicKey) : Buffer.from(String(result.publicKey), 'hex') @@ -2237,11 +2238,12 @@ export function startRestApi(engine: EngineController, auth: AuthStore, port = 1 // Role from op tier unless the caller pinned a path const addressNList = body.addressNList || body.address_n || hiveRolePath(serialized.tier, 0) + const { hiveConfirmDetails } = await import('./emulator-confirm-details') const result: any = await emuWrap(() => (wallet as any).hiveSignOperations({ addressNList, chainId: txParams.chainId, serializedTx: new Uint8Array(serialized.serializedTx), - }), { operation: 'hiveSignOperations', chain: 'HIVE' }) + }), hiveConfirmDetails('hiveSignOperations', body.operations)) if (!result?.signature) throw new HttpError(500, 'Hive sign-operations: device returned no signature') const sigBytes = result.signature instanceof Uint8Array ? Buffer.from(result.signature) : Buffer.from(String(result.signature), 'hex') // Return the expiration derived from the SIGNED expirationUnix (the value