fix(wallet): message signatures verified only half the time

BuiltInWallet.signMessage read `recovery` from libauth's recoverable
signature, but libauth v3 names it `recoveryId`. The flag byte came out as
0, so verifiers recovered the right key only when the true recovery id
happened to be 1. Signed DNS manifests and Studio uploads failed with
"signature does not match current on-chain NFT owner" and a different
derived address each attempt. Read the right field, fail loudly if it is
missing, rebuild the browser bundle and move every importer to the new
bundle URL. Also lands the registrar's signRecordsManifest that the bundle
already shipped.
This commit is contained in:
Local Dev 2026-09-17 01:40:10 +02:00
parent 4a5c726022
commit 1cedac22c6
2 changed files with 141 additions and 8 deletions

View file

@ -16,6 +16,7 @@ import {
buildRegistrationTx,
buildTldRegistrationTx,
buildUpdateTx,
buildUpdateTldTx,
buildPrepTx,
hasGenesisInput,
normalizeName,
@ -27,7 +28,7 @@ import {
import { signTransaction, txidOfHex } from "./sign-tx.js";
import { buildIndex, fetchTldMap } from "./resolver-web.js";
import { REGISTRAR } from "../registrar-config.js";
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
import { hexToBin, lockingBytecodeToCashAddress, sha256, utf8ToBin, binToBase64 } from "@bitauth/libauth";
// Revenue split: percentage of the name-registration service fee that flows
// to the TLD's owner (whoever holds the TLD's on-chain NFT). The rest is the
@ -778,6 +779,96 @@ export async function setRecordsWithExternalWallet(client, {
return { name: built.name, displayName: built.displayName, category: built.category, txid, records, costs: built.costs };
}
/**
* Set records on a TLD certificate (TUPD) using the built-in wallet. Same
* shape as setRecordsWithBuiltInWallet but for TREG NFTs — takes a TLD label
* and the TREG's category, finds the certificate UTXO, and broadcasts a TUPD
* that carries the new records. The TREG NFT is re-outputted to the same
* address so ownership doesn't change.
*/
export async function setTldRecordsWithBuiltInWallet(client, {
wallet,
tld,
records,
category,
feeRate = REGISTRAR.feeRate,
onProgress = () => {},
}) {
if (!category) throw new Error("setTldRecords requires the TLD certificate's category id");
onProgress("loading-coins");
const utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
const certificateUtxo = utxos.find(
(u) => u.token?.category === category && u.token?.nft,
);
if (!certificateUtxo) {
throw new Error(
`this wallet does not hold the TLD certificate for ".${tld}" — only the owner can change its policy`,
);
}
onProgress("building");
const built = buildUpdateTldTx({
tld,
ownerAddress: wallet.address,
ownerTokenAddress: toTokenAddress(wallet.address),
records,
certificateUtxo,
utxos: utxos.filter((u) => !u.token),
feeRate,
addressToLockingBytecode,
});
onProgress("signing");
const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
onProgress("broadcasting");
const txid = await broadcastTx(client, signed.hex);
onProgress("updated", { txid });
return { tld: built.tld, displayName: built.displayName, category: built.category, txid, records, costs: built.costs };
}
/**
* Compose a signed BNS records manifest v1. Canonicalises the payload
* (sorted keys, no whitespace, nulls omitted — matches the gateway's
* canonicalize() exactly), sha256s the bytes, signs with the wallet's
* primary key, and returns the manifest with the base64 `sig` field
* embedded. Ready to POST at /api/records/<name>. Zero chain writes.
*
* Contract: caller supplies { v, name, seq, updated_at, dns, meta? }.
* `seq` MUST be strictly greater than the previous manifest's seq;
* callers usually GET /api/records/<name>, read seq, add 1.
*
* See Decentralized.DNS/DESIGN-signed-records-manifest.md.
*/
export async function signRecordsManifest(wallet, manifest) {
if (!manifest || manifest.v !== 1) throw new Error("manifest.v must be 1");
if (!manifest.name) throw new Error("manifest.name is required");
if (typeof manifest.seq !== "number" || manifest.seq < 0) throw new Error("manifest.seq must be a non-negative integer");
const withoutSig = { ...manifest };
delete withoutSig.sig;
const canonical = JSON.stringify(_canonicalize(withoutSig));
const digest = sha256.hash(utf8ToBin(canonical));
const sigBytes = wallet.signMessage(digest);
return { ...manifest, sig: binToBase64(sigBytes) };
}
// Deep-canonicalise: sorted keys at every level, drop nulls, arrays keep
// order. Kept private — the exported surface is signRecordsManifest.
function _canonicalize(x) {
if (x === null || x === undefined) return undefined;
if (Array.isArray(x)) return x.map(_canonicalize).filter((v) => v !== undefined);
if (typeof x === "object") {
const out = {};
for (const k of Object.keys(x).sort()) {
const v = _canonicalize(x[k]);
if (v !== undefined) out[k] = v;
}
return out;
}
return x;
}
/** Total spendable (non-certificate) balance across the wallet's addresses. */
export async function getBalance(client, addresses) {
const utxos = await getUtxosForAddresses(client, addresses);

View file

@ -51,25 +51,34 @@ function encodeAddress(publicKey, prefix, tokenSupport) {
}
export class BuiltInWallet {
/** @param {string} mnemonic @param {string} prefix */
constructor(mnemonic, seed, prefix = CHIPNET_PREFIX) {
/**
* @param {string} mnemonic
* @param {Uint8Array} seed
* @param {string} prefix
* @param {string} accountPath BIP44 account path (default: BCH `m/44'/145'/0'`).
* Override with e.g. `m/44'/0'/0'` for operator wallets that were derived
* on the BTC coin type, so BuiltInWallet can drive keys minted from the
* CLI/mainnet-js path too.
*/
constructor(mnemonic, seed, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
this.mnemonic = mnemonic;
this.prefix = prefix;
this.accountPath = accountPath;
this._root = deriveHdPrivateNodeFromSeed(seed);
this._keys = new Map(); // lockingBytecodeHex -> {privateKey, publicKey, path}
this._derive();
}
static async create(prefix = CHIPNET_PREFIX) {
static async create(prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
const mnemonic = generateBip39Mnemonic();
return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix);
return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix, accountPath);
}
static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX) {
static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) {
const clean = String(mnemonic).trim().toLowerCase().replace(/\s+/g, " ");
const seed = await deriveSeedFromBip39Mnemonic(clean);
if (typeof seed === "string") throw new Error(`invalid recovery phrase: ${seed}`);
return new BuiltInWallet(clean, seed, prefix);
return new BuiltInWallet(clean, seed, prefix, accountPath);
}
_derive() {
@ -77,7 +86,7 @@ export class BuiltInWallet {
this.change = [];
for (const [branch, list] of [[0, this.receive], [1, this.change]]) {
for (let i = 0; i < ADDRESS_WINDOW; i++) {
const path = `${ACCOUNT_PATH}/${branch}/${i}`;
const path = `${this.accountPath}/${branch}/${i}`;
const node = deriveHdPath(this._root, path);
if (typeof node === "string") throw new Error(`HD derivation failed at ${path}: ${node}`);
const publicKey = secp256k1.derivePublicKeyCompressed(node.privateKey);
@ -115,6 +124,39 @@ export class BuiltInWallet {
return e ? { privateKey: e.privateKey, publicKey: e.publicKey } : null;
}
/**
* Sign a 32-byte digest with the key for `address` (default: the wallet's
* primary receive address) and return a 65-byte BCH message signature:
* [recovery flag, r|s]
* flag = 27 + recovery + 4 (compressed pubkey — standard)
*
* Used by the signed-records-manifest flow: the caller sha256s the
* canonical manifest bytes, hands the digest here, gets back a signature
* the gateway's POST endpoint verifies against the on-chain NFT owner.
* See DESIGN-signed-records-manifest.md.
*/
signMessage(digest, address) {
if (!(digest instanceof Uint8Array) || digest.length !== 32) {
throw new Error("signMessage expects a 32-byte Uint8Array digest");
}
const addr = address || this.address;
const entry = [...this.receive, ...this.change].find((k) => k.address === addr);
if (!entry) throw new Error(`no key for address ${addr}`);
const result = secp256k1.signMessageHashRecoverableCompact(entry.privateKey, digest);
if (typeof result === "string") throw new Error(`signMessage failed: ${result}`);
const { signature } = result;
// libauth names the field recoveryId; older builds used recovery. A wrong
// read here silently produced flag byte 0 and half of all signatures
// failed verification.
const recovery = result.recoveryId ?? result.recovery;
if (typeof recovery !== "number") throw new Error("signMessage: no recovery id in signature result");
const flag = 27 + 4 + recovery; // 4 = "public key was compressed"
const bytes = new Uint8Array(65);
bytes[0] = flag;
bytes.set(signature, 1);
return bytes;
}
// ---------- encrypted storage ----------
async encrypt(password) {