Repository navigation
Add cold-wallet signing for NEAR transactions - #174
Conversation
Sign a NEAR transaction prepared by near-cli-rs (sign-later) with a cold
Quantus wallet over the existing ur:quantus-sign-request QR transport.
- Envelope v2 {v: 2, chain: "near", network, payload: borsh(TransactionV0)}
alongside the untouched v1; AnySignRequest dispatches on version.
- NEAR wire types gain BorshDeserialize plus from_bytes/from_base64 and a
per-action description for display on the signing device.
- quantus near sign-cold --unsigned-tx <b64|@file> --wallet <cold>
[--out] [--send]: verifies the response key is tx.public_key, hashes to
the cold wallet's stored address, and verifies the empty-context ML-DSA-65
signature over SHA-256(borsh(tx)) before assembling the SignedTransaction.
- developer cold-sign-sim answers v2 requests with a local ML-DSA-65 wallet.
- Golden test pins the borsh layout and hash to near-cli-rs 0.30.1 output.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT-6 Sol
Request changes: the new cold-signing flow can show a misleading transaction preview and emit requests its own v2 decoder refuses.
-
[P1] Escape untrusted FunctionCall arguments in the signing preview. At src/near/protocol.rs:253-258, String::from_utf8_lossy preserves terminal controls such as ESC, carriage return, and newline. Both src/near/cold.rs:163-164 and the simulator print this string directly. A prepared transaction with control bytes in its arguments can erase or spoof the displayed receiver, deposit, or action lines while the original bytes are signed. Render control bytes visibly (or show hex for non-text arguments), and cover this with a regression test.
-
[P2] Include the gas allowance when previewing AddKey. At src/near/protocol.rs:266-272, a FunctionCall permission displays its receiver and methods but drops p.allowance. An unlimited key and a limited key therefore produce the same review text despite granting different spending authority. Show the allowance, including the unlimited case.
-
[P2] Reject oversized v2 requests before QR handoff. At src/near/cold.rs:169-170, any decoded transaction is encoded and displayed, but src/qr/sign_request.rs:46-49 rejects payloads above 8 KiB. A valid prepared call with sufficiently large arguments (the ML-DSA-65 key already consumes 1,952 bytes) reaches a QR that the v2 decoder, including cold-sign-sim, cannot accept. Validate the encoded transaction size before displaying it, then either coordinate a larger v2 limit across clients or document and report the supported limit clearly.
Validation: git diff --check passed; nightly rustfmt check of changed Rust files passed; 31 focused NEAR tests and 9 request-envelope tests passed. CI format, Ubuntu build/test, analysis, and security jobs passed; macOS and examples were still running at review time. A physical v2 cold-wallet integration test remains unavailable because device/app support is deferred in the PR description.
Function-call arguments are shown as hex unless every character renders in place, so control or bidi characters in a prepared transaction cannot rewrite the lines the user is approving. AddKey previews include the gas allowance, unlimited or not. NearSignRequest::new refuses a transaction over the 8 KiB payload limit every decoder enforces, before anything is displayed.
|
Addressed in 12b5202:
|
Method names, account ids, and beneficiaries follow the same rule as call arguments: text that contains terminal controls or bidi marks is shown as hex, so it cannot rewrite the lines a signer is approving.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT-6 Sol
Request changes: the NEAR cold-signing preview still makes distinct signed transactions look identical to the signer.
-
[P1] Identify deployed contract code in the preview (
src/near/protocol.rs:252).DeployContractshows only the byte length. Two different WASM blobs of the same size therefore produce the same action line in bothsign-coldandcold-sign-sim, although one can replace an account's contract with different behavior. Show a cryptographic digest ofa.codealongside its length so the signer can compare it with the intended artifact; test that same-length, different code has different previews. -
[P2] Preserve the exact FunctionCall gas limit (
src/near/protocol.rs:253-258). Dividinga.gasby 1,000,000,000,000 truncates the value: 1,999,999,999,999 gas appears as1 TGas, and positive gas below 1 TGas appears as0 TGas. The signer cannot verify the actual execution limit or cost from this line. Display the exact gas amount or a lossless fractional TGas value, with a nonintegral regression case.
The earlier argument escaping, AddKey allowance, and request-size findings are addressed. The latest commit also escapes method names and account fields before terminal display.
Validation at 25b33f1: git diff --check and cargo +nightly fmt --check passed; SKIP_CIRCUIT_BUILD=1 cargo test --locked --lib near:: (34 tests), qr::sign_request::tests (10), and cli::cold_signing::tests (7) passed. CI for this head was still running when checked. Physical cold-wallet v2 integration remains outside this PR.
A DeployContract line now includes the SHA-256 of the WASM, so two blobs of the same length do not look identical. FunctionCall gas is shown as an exact TGas value, including a fractional remainder, instead of truncating to a whole TGas.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT-6 Sol
Approve at c6e9ab6: no blocking findings remain. The two issues from the previous review are fixed: DeployContract previews identify the code with a SHA-256 digest, and FunctionCall gas is shown without truncation. The earlier argument-display, AddKey allowance, and request-size findings are also addressed.
Validation: git diff --check and cargo +nightly fmt --check passed. SKIP_CIRCUIT_BUILD=1 cargo test --locked --lib near:: passed 36 tests; qr::sign_request::tests passed 10; cli::cold_signing::tests passed 7. At review time, GitHub's format, Ubuntu build/test, analysis, and security checks passed; macOS and examples were still running. Physical v2 cold-wallet integration remains untested because device/app support is deferred to the follow-up described in the PR.
Matches the cold wallet, which now rejects any other label, so the CLI never emits a request the device refuses, and never accepts a scanned one carrying a lookalike label.
|
870e24c (rebased onto the two preview commits already on the branch): |
Summary
Sign a NEAR transaction prepared by near-cli-rs (
sign-later) with a cold Quantus wallet, over the sameur:quantus-sign-requestQR transport Quantus extrinsics already use. This covers any contract call (DEX swaps, lending, staking) rather than only the hotnear send/near daocommands.Protocol
{v: 2, chain: "near", network, payload: "0x<borsh TransactionV0>"}in the existing UR type. v1 is untouched;AnySignRequestdispatches onv, and wallets that only read v1 refuse v2 by version as before. Nosignerfield: the transaction already namessigner_idand the exactpublic_key.tx.public_keyis its own ML-DSA-65 key, signsSHA-256(borsh(tx))as a pure FIPS 204 signature (empty context — NEAR verifies without one), answerssignature[3309] ‖ public_key[1952].tx.public_key→ response key hashes to the cold wallet's stored SS58 → signature verifies. Length/parse failures are rescan-safe; the rest abort.Changes
src/qr/sign_request.rs:NearSignRequest(v2,deny_unknown_fields),AnySignRequest; shared payload hex/size helpers.src/near/protocol.rs:BorshDeserializeon the wire types,Transaction::from_bytes/from_base64(rejects trailing bytes),SignedTransaction::to_base64,describe_actions(),format_near.src/near/cold.rs(new):sign_transaction_cold,validate_near_response,load_unsigned_transaction(bare base64 or thesave-to-fileJSON).src/cli/near.rs:quantus near sign-cold --unsigned-tx <b64|@file> --wallet <cold> [--network] [--out] [--send] [--rpc-url].src/cli/cold_signing.rs: QR hand-off factored intopresent_sign_request/response_source/read_signature_responseso both paths share it;cold-sign-simanswers v2 requests with a local ML-DSA-65 wallet.Verification
./clippy.shclean (fmt + taplo + clippy--all-targets --all-features -D warnings); 808 tests pass.sign-lateroutput (be358ec9…), byte-identical on re-encode.v2.ref-finance.near storage_deposittx with anml-dsa-65:signer key →sign-cold(--cold-request-out/--cold-response-in) →cold-sign-sim→ signed base64.near transaction print-transaction signed <ours>decodes it as anml-dsa-65:signature, and itspub key hashequals ourshow-keyhandle.--wallet, hot wallet passed tosign-cold, ML-DSA-87 response size.Follow-ups (not in this PR)
near send/dao --wallet <cold>can look up the nonce and route throughsign_transaction_cold.