Files
pezkuwi-wallet-utils/scripts/root/verify_root.mjs
T
SatoshiQaziMuhammed 6864b8c0aa Root signing page and a root chain check for replacing the root keys (#81)
- signed_config.py root drafts a later root in docs/root-signing/proposals/,
  where wallets never look, and keeps the current targets key by default.
- docs/root-signing/: a page for the Pezkuwi Wallet DApp browser that shows
  the proposal and its fingerprint, has the holder's wallet sign
  <Bytes>payload</Bytes>, checks every signature as the wallet will, collects
  signature codes and offers root.N.json once two current and two new holders
  have signed. Crypto bundles are served beside it.
- scripts/root/verify_root.mjs checks every published signed/root.N.json the
  way installed wallets do; Code Quality runs it. Wallet 1.3.0 accepts sr25519
  only under the substrate signing context, and a root it cannot verify stops
  its config refresh, so such a root must never reach master.
- Draft root 2: three new holder accounts, threshold 2, five years.
2026-11-04 23:58:11 +04:00

131 lines
5.6 KiB
JavaScript

// Verifies the published root chain the way the wallet does, so a root that installed wallets would refuse
// is never published: a root they cannot verify stops their config refresh.
//
// signed/root.json is the root the APK carries. Each signed/root.N.json after it must be the next version and
// carry valid signatures from a threshold of the previous root's keys and of its own. An sr25519 key is a
// wallet account and signs <Bytes>payload</Bytes>. Wallet 1.3.0 checks sr25519 under the "substrate" signing
// context only, so that is what counts; a signature valid only under "bizinikiwi" is reported, not counted.
//
// node scripts/root/verify_root.mjs check the published chain
// node scripts/root/verify_root.mjs FILE... also report the signatures on proposals
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { cryptoWaitReady as polkadotReady, sr25519Verify as substrateVerify } from '@polkadot/util-crypto';
import { cryptoWaitReady as pezkuwiReady, sr25519Verify as bizinikiwiVerify, ed25519Verify } from '@pezkuwi/util-crypto';
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const SIGNED = join(REPO, 'signed');
const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
const wrapBytes = (payload) => Buffer.concat([Buffer.from('<Bytes>'), payload, Buffer.from('</Bytes>')]);
function readEnvelope(path) {
const envelope = JSON.parse(readFileSync(path, 'utf8'));
const payload = Buffer.from(envelope.signed, 'base64');
return { payload, root: JSON.parse(payload.toString('utf8')), signatures: envelope.signatures };
}
function parseRoot(root) {
if (root.type !== 'root') throw new Error(`type is ${root.type}, not root`);
const keys = {};
for (const [keyId, key] of Object.entries(root.keys)) {
const bytes = Buffer.from(key.public, 'base64');
if (bytes.length !== 32 || sha256Hex(bytes) !== keyId) throw new Error(`key ${keyId} does not match its id`);
if (key.scheme !== 'sr25519' && key.scheme !== 'ed25519') throw new Error(`key ${keyId} has unknown scheme ${key.scheme}`);
keys[keyId] = { scheme: key.scheme, bytes };
}
for (const role of ['root', 'targets']) {
const { keyids, threshold } = root[role];
if (threshold < 1 || threshold > keyids.length || !keyids.every((id) => id in keys)) throw new Error(`unusable ${role} role`);
}
return keys;
}
// keyId -> "substrate" | "bizinikiwi" | "ed25519" | null for each signature on payload
function classify(payload, signatures, keys) {
const result = new Map();
for (const { keyid, sig } of signatures) {
const key = keys[keyid];
if (!key) continue;
const signature = Buffer.from(sig, 'base64');
let kind = null;
if (key.scheme === 'ed25519') {
if (ed25519Verify(payload, signature, key.bytes)) kind = 'ed25519';
} else if (substrateVerify(wrapBytes(payload), signature, key.bytes)) {
kind = 'substrate';
} else if (bizinikiwiVerify(wrapBytes(payload), signature, key.bytes)) {
kind = 'bizinikiwi';
}
if (!result.get(keyid)) result.set(keyid, kind);
}
return result;
}
function count(classified, keyIds) {
const accepted = keyIds.filter((id) => ['substrate', 'ed25519'].includes(classified.get(id)));
const contextOnly = keyIds.filter((id) => classified.get(id) === 'bizinikiwi');
return { accepted: accepted.length, contextOnly: contextOnly.length };
}
function checkNext(previous, path) {
const next = readEnvelope(path);
const nextKeys = parseRoot(next.root);
if (next.root.version !== previous.root.version + 1) {
throw new Error(`version ${next.root.version} does not follow ${previous.root.version}`);
}
const lines = [];
let ok = true;
for (const [label, keys, role] of [
['previous root', previous.keys, previous.root.root],
['its own root', nextKeys, next.root.root],
]) {
const { accepted, contextOnly } = count(classify(next.payload, next.signatures, keys), role.keyids);
const met = accepted >= role.threshold;
ok &&= met;
lines.push(` ${label}: ${accepted} of ${role.threshold} required${contextOnly ? `, ${contextOnly} valid only under bizinikiwi (not accepted by wallet 1.3.0)` : ''}${met ? '' : ' <- NOT MET'}`);
}
return { ok, lines, next: { root: next.root, keys: nextKeys } };
}
await polkadotReady();
await pezkuwiReady();
let failed = false;
const first = readEnvelope(join(SIGNED, 'root.json'));
let current = { root: first.root, keys: parseRoot(first.root) };
console.log(`root ${current.root.version}: carried by the APK`);
for (let version = current.root.version + 1; existsSync(join(SIGNED, `root.${version}.json`)); version++) {
const path = join(SIGNED, `root.${version}.json`);
try {
const { ok, lines, next } = checkNext(current, path);
console.log(`root ${version}: ${ok ? 'accepted' : 'REFUSED'}`);
lines.forEach((line) => console.log(line));
if (!ok) {
console.log(`::error::signed/root.${version}.json would be refused by installed wallets and stop their config refresh`);
failed = true;
break;
}
current = next;
} catch (e) {
console.log(`::error::signed/root.${version}.json: ${e.message}`);
failed = true;
break;
}
}
for (const proposal of process.argv.slice(2)) {
try {
const { ok, lines } = checkNext(current, proposal);
console.log(`proposal ${proposal}: ${ok ? 'ready to publish' : 'not ready'}`);
lines.forEach((line) => console.log(line));
} catch (e) {
console.log(`proposal ${proposal}: ${e.message}`);
}
}
process.exit(failed ? 1 : 0);