// 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 payload. 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(''), payload, Buffer.from('')]);
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);