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
3 changes: 3 additions & 0 deletions ui/changelog.d/known-limits/signing-record-residuals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **What a device remembers about its own signatures stays on that device.** A second device of the
same seat, a restored device, or a browser whose site data was cleared does not know what was
signed before, and asks as for a first signature. A backup does not carry this record.
6 changes: 6 additions & 0 deletions ui/changelog.d/security/device-signing-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- **Your device is more careful with a payment it already signed.** It asks before signing a payment
again in more cases than before, recognises the same payment when it comes back under another
proposal, and shows an earlier transaction only when it can confirm it signed it. Proposals are now
signed only from their signing panel; the standalone `/net` page no longer signs them. This narrows
the risk of a payment going out twice; it does not close it until approvals are bound to what a
payment pays. The details follow in a later entry.
11 changes: 11 additions & 0 deletions ui/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,17 @@ h1.klab,h2.klab,h3.klab{margin:0;font-weight:400}
.modal-card.sign-confirm{max-height:calc(100vh - 48px); max-height:calc(100dvh - 48px); overflow-y:auto; overscroll-behavior:contain; scroll-padding-bottom:72px}
/* bottom: minus the card's padding, so the row sits on the card's edge and covers no text at the end. */
.sign-confirm > .btns{position:sticky; bottom:-26px; margin:16px -26px -26px; padding:14px 26px; background:var(--surface-1); border-top:1px solid var(--line)}
/* A long CTA (pt-BR "Assinar de novo e enviar: ... ZEC") wraps instead of pushing Cancel off the
card's start edge, and the card reflows at 320 px (WCAG 1.4.10, 1.4.12). */
.sign-confirm > .btns{flex-wrap:wrap}
.sign-confirm > .btns .btn{white-space:normal; text-align:center; min-width:0; max-width:100%}
/* Stacked buttons on a phone make the sticky foot taller: a focused checkbox is scrolled clear of it
(WCAG 2.4.11). */
@media(max-width:560px){ .modal-card.sign-confirm{scroll-padding-bottom:170px} }
@media(max-width:400px){
.modal-card.sign-confirm{padding:18px}
.sign-confirm > .btns{bottom:-18px; margin:16px -18px -18px; padding:12px 18px}
}
.modal-h{margin:6px 0 4px; font-size:22px}
.modal-p{color:var(--text-muted); font-size:13.5px; line-height:1.55; margin:0 0 14px}
/* A deliberate acknowledgement before a risky confirm (signing a payment again): the box and its text
Expand Down
29 changes: 23 additions & 6 deletions ui/src/VaultSigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { getVault, getProposalDetail, IS_NET, type Proposal, type Vault } from './api'
import { listVaults } from './storage'
import { approvedLinesFor, readPlan, refusePlan, gateDecision, ourReceiversFrom, paysOurselves, type ApprovedLine, type ApprovedPlan, type GateDecision, type PcztOutput } from './approved-payment'
import { approvedLinesFor, paidKey, readPlan, refusePlan, gateDecision, ourReceiversFrom, paysOurselves, type ApprovedLine, type ApprovedPlan, type GateDecision, type PcztOutput } from './approved-payment'
import { ensureWasm } from './wasm-ready'
import { uaReceiver } from './wasm-pkg/konclave_wasm.js'
import { useBackgroundSigner, type BackgroundSignerState } from './useBackgroundSigner'
import { ARM_TTL_MS, makeSigningGate } from './signing-gate'
import { OwnerSigning } from './owner-signing'
import { browserRecordStore } from './signed-record'
import { browserRecordStore, txidIsOurs } from './signed-record'
import type { FailureCode } from './background-session'
import { deviceRefusal, unlockedHere } from './session'
import { mayContribute } from './approved-payment'
Expand Down Expand Up @@ -45,7 +45,7 @@ interface VaultSignerCtx {
armActive: (opts?: { again?: boolean }) => Promise<void>
/** A second transaction for this payment was refused: until when the first one stays valid. */
/** The first transaction's expiry block, and when this device stops counting it (unix seconds, 0 when unknown). */
signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean } | null
signedBefore: { proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number }; sameOutputs?: boolean; sameAt?: number } | null
/** When this device's signature stops counting (epoch ms), or null when it is not armed. */
armedUntil: number | null
/** What the active proposal asks to pay, from the read the gate compares: undefined until read,
Expand Down Expand Up @@ -121,6 +121,9 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) {
// The owner signs a PROPOSAL, because that is what they see and click; the consent it gives covers
// the first transaction this device clears for it (`consent` below).
const activeIdRef = useRef<string | null>(null)
// The open proposal's txid as the coordinator answers it: shown after a lapse only if this device signed it.
const activeTxidRef = useRef<string | null>(null)
activeTxidRef.current = active?.txid ?? null
activeIdRef.current = active?.id ?? null
// #281: what did the quorum actually approve, in the bytes the note is bound to?
//
Expand Down Expand Up @@ -188,7 +191,7 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) {
vaultIdRef.current = vault?.id ?? null
// A second transaction for an approval this device already signed, refused: what the panel tells
// its owner (until when the first stays valid) before they may sign again knowing it.
const [signedBefore, setSignedBefore] = useState<{ proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean } | null>(null)
const [signedBefore, setSignedBefore] = useState<{ proposal: string; firstExpiry: number; firstUntil: number; untilHeight: number | null; byEpoch: boolean; lapsedAt?: number | null; lapsedTxid?: string | null; lapsedOf?: { k: number; n: number }; sameOutputs?: boolean; sameAt?: number } | null>(null)
const gate = useMemo(
() => makeSigningGate({
mode: () => 'manual',
Expand All @@ -204,15 +207,29 @@ export function VaultSignerProvider({ children }: { children: ReactNode }) {
// stops reading as signed, so its owner can sign again after checking the book (the panel says so).
const claim = useMemo(
() => async (sighashHex: string, expiry: number) => {
const r = await owner.current.claim(vaultIdRef.current ?? '', activeIdRef.current, sighashHex, expiry)
// What the transaction pays, as the gate just matched it: recognises the same payment under a new
// proposal id until approvals are bound to content (#567).
const approved = approvalRef.current?.approved
const paid = approved && approved.length ? await paidKey(approved) : undefined
const r = await owner.current.claim(vaultIdRef.current ?? '', activeIdRef.current, sighashHex, expiry, paid)
if (r !== 'ok') { setArmedProposal(null); setArmedAt(null) }
const last = owner.current.lastSecond
// The machine read this transaction, so the WASM is ready. The time was computed by WASM, by the
// same rule that made the decision (with the earlier transaction's branch, so an upgrade that ends
// it is counted), so the screen says exactly when this warning stops.
// The machine words this one itself: a record it could not read is not a second transaction.
if (r === 'unreadable') throw new Error('[unreadable] this device could not read what it signed')
if (r === 'second' && last) setSignedBefore({ proposal: last.proposal, firstExpiry: last.firstExpiry, firstUntil: last.firstUntil, untilHeight: last.untilHeight, byEpoch: last.byEpoch })
if (r === 'second' && last) setSignedBefore({ proposal: last.proposal, firstExpiry: last.firstExpiry, firstUntil: last.firstUntil, untilHeight: last.untilHeight, byEpoch: last.byEpoch, lapsedAt: null, sameOutputs: !!last.sameOutputs, sameAt: last.sameAt })
// Every earlier attempt lapsed: asked the same way, worded for what this device cannot know (A1).
const lapsed = owner.current.lastLapsed
if (r === 'lapsed' && lapsed) {
const at = lapsed.earlier.length ? Math.max(...lapsed.earlier) : 0
// A txid handed to the device is shown only when it is one this device signed (R5): the
// coordinator cannot make the screen point at another transaction.
const cand = activeTxidRef.current
const ours = cand ? await txidIsOurs(cand, lapsed.hashes ?? []) : false
setSignedBefore({ proposal: lapsed.proposal, firstExpiry: at, firstUntil: 0, untilHeight: null, byEpoch: false, lapsedAt: at, lapsedTxid: ours ? cand : null, lapsedOf: { k: ours ? 1 : 0, n: lapsed.earlier.length }, sameOutputs: !!lapsed.sameOutputs, sameAt: lapsed.sameAt })
}
return r === 'ok'
},
[],
Expand Down
14 changes: 14 additions & 0 deletions ui/src/approved-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,17 @@ describe('mayContribute', () => {
expect(mayContribute({ cp: 3, receiversState: 'verified', abandoned: 'x' }, undefined)).toBe(false)
})
})

// The key the signing record keeps of what a transaction pays (#567 bridge): the same lines in any
// order are the same payment; any change of receiver, amount or memo is another.
describe('paidKey', () => {
const l = (r: string, z: number, m = '') => ({ toReceiver: r, amountZat: z, memo: m })
it('is order-free, case-free on the receiver, and changes with any field', async () => {
const { paidKey } = await import('./approved-payment')
const k = await paidKey([l('AA', 1), l('bb', 2, 'x')])
expect(await paidKey([l('bb', 2, 'x'), l('aa', 1)])).toBe(k)
expect(await paidKey([l('aa', 1), l('bb', 3, 'x')])).not.toBe(k)
expect(await paidKey([l('aa', 1), l('bb', 2, 'y')])).not.toBe(k)
expect(await paidKey([l('aa', 1), l('cc', 2, 'x')])).not.toBe(k)
})
})
10 changes: 10 additions & 0 deletions ui/src/approved-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,13 @@ export function refusePlan(id: string): null {
export function forgetPlansForTests(): void {
planMemory.clear()
}

/** A key of what a transaction pays: its approved lines (receiver, amount, memo), order-free, hashed.
* Read only after the gate matched the transaction to these lines, so it names what it pays. The
* device's signing record keeps it so the same payment is recognised under a new proposal id, until
* approvals are bound to content (#567). */
export async function paidKey(lines: ApprovedLine[]): Promise<string> {
const rows = lines.map((l) => [norm(l.toReceiver), l.amountZat, l.memo.normalize('NFC')]).map((r) => JSON.stringify(r)).sort()
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(rows))))
return Array.from(d, (b) => b.toString(16).padStart(2, '0')).join('')
}
17 changes: 15 additions & 2 deletions ui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,7 +1071,7 @@ export const en: Record<string, string> = {
'signing.tryAgain': 'Try again',
'signing.againTitle': 'Sign this payment again?',
'signing.againBody': 'This device already signed an earlier attempt at this payment. If that attempt goes out, signing again could pay it twice: {amt} ZEC a second time. Do this only if the book does not show the payment as sent.',
'signing.againUntil': 'Until about {when}, this device treats the earlier attempt as able to reach the network (the network itself gives up on it earlier). Waiting until then is the safe choice: after that, this device stops asking about the earlier attempt, and signing works as usual.',
'signing.againUntil': 'Until about {when}, this device treats the earlier attempt as able to reach the network (the network itself gives up on it earlier). Waiting until then is the safe choice. After that, this device still asks before it signs this payment again.',
// DRAFT (#638), for the maintainer to approve before merge. A network upgrade is behind each of the next
// texts; none can appear until the upgrade's row is in the build.
'signing.againUntilEpoch': 'An earlier transaction for this payment can only be mined until block {block} (about {when}). If it is mined first, signing again pays this payment twice. Waiting until then is the safe choice.',
Expand All @@ -1082,6 +1082,19 @@ export const en: Record<string, string> = {
'error.upgradeHoldCrossing': 'This payment moves funds between pools and could be held by the network across the upgrade. Send it after the upgrade; nothing was built.',
'error.coordinatorNotUpdated': 'The coordinator has not been updated for the network upgrade yet. Nothing was signed or sent. Your vote stands until the proposal expires (72 hours); you will be asked to sign again when the payment can be built.',
'signing.againChecked': 'I checked the book: this payment does not show as sent.',
'signing.againLapsedBody': 'This device already signed an earlier attempt at this payment. By this device\'s clock it cannot go out after block {block}, but the device cannot tell whether it went out before then. If it did, signing again pays {amt} ZEC a second time.',
'signing.againLapsedBodyMany': 'This device already signed {n} earlier attempts at this payment. By this device\'s clock none of them can go out after block {block}, but the device cannot tell whether one went out before then. If one did, signing again pays {amt} ZEC a second time.',
'signing.againLapsedPartial': 'This device could identify {k} of its {n} earlier attempts. The others cannot be checked here: ask the other members whether the payment went out.',
'signing.againSameBody': '{whenPhrase}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal, and that signature can still go out. If this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment.',
'signing.againSameChecked': 'This is a separate payment, not the earlier one again.',
'signing.againSameLapsedBody': '{whenPhrase}, this device signed a payment of exactly these recipients, amounts and memos, under another proposal. If that payment went out and this is the same payment again, signing pays {amt} ZEC a second time. Sign only if this is a separate payment, or if you checked that the earlier one did not go out.',
'signing.againSameLapsedChecked': 'This is a separate payment, or I checked that the earlier one did not go out and the explorer\'s latest block is past {block}.',
'signing.copyTxid': 'Copy transaction id',
'signing.sameOn': 'On {when}',
'signing.sameEarlier': 'Earlier',
'signing.againLapsedChecked': 'I checked: no earlier attempt at this payment went out, and the explorer\'s latest block is past {block}. If I am wrong, this pays it twice.',
'signing.againLapsedTxid': 'This device checked that it signed the transaction below. Look it up in a block explorer: if the explorer shows it, the payment went out, so do not sign.',
'signing.againLapsedNoTxid': 'This device cannot show the transaction id of any earlier attempt, so it cannot point you to it. Ask the other members whether the payment went out before signing again.',
'signing.againCta': 'Sign again: {amt} ZEC',
'signing.readAgainHeading': 'What the vault built this time',
'signing.againCtaSend': 'Sign again and send: {amt} ZEC',
Expand Down Expand Up @@ -1367,7 +1380,7 @@ export const en: Record<string, string> = {
'net.err.unreadableSpends': 'Refused to sign: this device could not read which parts of the transaction need its signature. Nothing was signed. The reason follows.',
'net.err.relayDown': 'Could not reach the relay (the network went down). Reload both tabs and try again.',
'net.err.notApproved': 'Refused to sign: this transaction does not pay exactly what the group approved. Nothing was signed. What this device read is below; memos are checked too, but not shown. Do not try again, and tell the other members.',
'net.err.signedAnother': 'Not signed: this device already signed this payment once, and the vault has since built it again. Nothing was signed this time. Check the book first. If the payment shows as sent, stop here. If it does not, you can sign again.',
'net.err.signedAnother': 'Not signed: this device already signed this payment once, and the vault has since built it again. Nothing was signed this time. First check whether the payment went out. If it did, stop here. If it did not, you can sign again.',
'net.err.approvalUnknown': 'Refused to sign: this device could not load what the group approved, or its own record of the vault, so it did not check this transaction against them. Nothing was signed. This alone does not show tampering, but the coordinator can cause it: reload Konclave on this device and open the proposal again. If it keeps happening, do not sign, and tell the other members.',
'net.err.feeClock': "Refused to sign: the network fee in this transaction is lower than this device accepts for its date and time. That can mean this device's date or time is set too early, or that the transaction was built at a rate this vault does not use yet. Nothing was signed. If this device's date or time is wrong, correct it, but never set it ahead of the real time to make it sign. If they are right, do not try again, and tell the other members.",
'net.err.feeTooHigh': 'Refused to sign: the network fee in this transaction is not the standard fee for its size. Higher, the difference would leave the vault; lower, the network may not relay it. Nothing was signed. This can mean the request was tampered with: do not try again, and tell the other members.',
Expand Down
Loading
Loading