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.
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-11-04 23:58:11 +04:00
1 parent 7d53e16b76
commit 6864b8c0aa
13 files changed
+27902 -8

No files matched your search

+12
View File
@@ -93,6 +93,18 @@ jobs:
- name: Light client checkpoint not stale
run: python3 scripts/light_client_specs.py --relay-rpc https://zagros-rpc.pezkuwichain.io --check-age 60
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
# A wallet that finds a root it cannot verify stops refreshing its config, so every published
# signed/root.N.json is checked here exactly as installed wallets check it.
- name: Root chain verifies as installed wallets check it
run: |
npm ci --prefix scripts/root --ignore-scripts --no-audit --no-fund
node scripts/root/verify_root.mjs docs/root-signing/proposals/*.json
chain-integrity:
name: Chain Config Integrity
runs-on: ubuntu-latest
+1
View File
@@ -0,0 +1 @@
node_modules/
+16
View File
@@ -78,6 +78,22 @@ Code Quality fails once the checkpoint is more than 60 days old.
Code Quality refuses a tree whose files do not match the signed list, and its weekly run fails a month
before a signature expires.
### Replacing the root keys
A new root is drafted where the wallet never looks, then signed by the holders in their wallets:
```
python3 scripts/signed_config.py root --version N --expires-days 1825 --root-threshold 2 --root ADDR ADDR ADDR
```
This writes `docs/root-signing/proposals/root.N.json`, keeping the current targets key. Each holder of the
current root and of the new one opens the root signing page (`docs/root-signing/`, served by GitHub Pages) in
the Pezkuwi Wallet DApp browser, compares the fingerprint with the others by phone, signs, and sends the
signature code to whoever collects them. Once two current and two new holders have signed, the page offers
`root.N.json`; it goes to `signed/root.N.json` in a pull request. Code Quality checks every published root as
installed wallets do (wallet 1.3.0 accepts sr25519 only under the `substrate` signing context) and fails
before a root they would refuse reaches master: such a root stops their config refresh.
## Contributing
1. Fork this repository
+261
View File
@@ -0,0 +1,261 @@
<!doctype html>
<html lang="ku">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guhertina mifteya kokê</title>
<!--
Root signing page for the wallet's signed config. Open it in the Pezkuwi Wallet DApp browser.
It shows the drafted root (proposals/root.N.json), has the holder's wallet sign <Bytes>payload</Bytes>,
checks each signature the way the wallet will, and assembles signed/root.N.json once a threshold of the
previous root's keys and of the new root's keys have signed. Wallet 1.3.0 accepts sr25519 under the
"substrate" signing context only, so that is what counts toward the thresholds.
-->
<script src="vendor/bundle-pezkuwi-util.js"></script>
<script src="vendor/bundle-pezkuwi-util-crypto.js"></script>
<script src="vendor/bundle-polkadot-util.js"></script>
<script src="vendor/bundle-polkadot-util-crypto.js"></script>
<style>
:root { --bg: #08090E; --panel: rgba(153,158,199,0.10); --line: rgba(153,158,199,0.16); --text: rgba(255,255,255,0.88);
--muted: rgba(255,255,255,0.60); --accent: #009639; --ok: #2FC864; --warn: #EBC50A; --bad: #E53450; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 560px; margin: 0 auto; padding: 20px 16px 40px; display: flex; flex-direction: column; gap: 14px; }
h1 { margin: 0; font-size: 22px; }
p { margin: 0; color: var(--muted); }
.panel { background: var(--panel); border-radius: 12px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 12px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
.row span:first-child { color: var(--muted); }
.mono { font-family: ui-monospace, monospace; word-break: break-all; }
.fp { font-size: 17px; font-weight: 700; letter-spacing: 1px; }
button { min-height: 48px; border-radius: 12px; border: 0; background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
button.secondary { background: rgba(153,158,199,0.16); color: var(--text); }
button:disabled { opacity: 0.45; cursor: default; }
textarea { width: 100%; min-height: 80px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--text); padding: 10px; font: 13px ui-monospace, monospace; }
.ok { color: var(--ok); } .warn { color: var(--warn); } .bad { color: var(--bad); }
.note { padding: 12px; border-radius: 12px; font-size: 14px; }
.note.warn { background: rgba(237,206,54,0.14); } .note.bad { background: rgba(229,52,80,0.14); } .note.ok { background: rgba(47,200,100,0.12); }
label { display: flex; flex-direction: column; gap: 6px; color: var(--muted); font-size: 13px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Guhertina mifteya kokê</h1>
<p id="summary">Tê barkirin…</p>
<div id="error" class="note bad" hidden></div>
<div class="panel" id="facts" hidden>
<div class="row"><span>Guherto</span><span id="version"></span></div>
<div class="row"><span>Sînor</span><span id="threshold"></span></div>
<div class="row"><span>Dema derbasbûnê</span><span id="expires"></span></div>
</div>
<div id="keysBlock" hidden>
<p>Mifteyên kokê yên nû (sr25519, hesabên cîzdanê)</p>
<div class="panel" id="newKeys"></div>
</div>
<div class="panel row" id="fpBlock" hidden><span>Şopa pêşniyarê</span><span class="mono fp" id="fingerprint"></span></div>
<p id="fpHint" hidden>Berî îmzekirinê vê şopê bi telefonê bi xwediyên din re bide ber hev.</p>
<button id="signButton" hidden>Bi cîzdanê îmze bike</button>
<div id="signResult" class="note" hidden></div>
<button id="shareButton" class="secondary" hidden>Koda îmzeya min kopî bike</button>
<div id="collect" hidden>
<p id="tally"></p>
<div class="panel" id="holders"></div>
</div>
<label id="pasteBlock" hidden>Koda îmzeyê ya xwediyekî din zêde bike
<textarea id="paste" placeholder="Kodê li vir bixe"></textarea>
</label>
<button id="addButton" class="secondary" hidden>Kodê zêde bike</button>
<button id="downloadButton" hidden disabled>root.json daxîne</button>
</main>
<script>
(async () => {
const pz = window.pezkuwiUtilCrypto, pzu = window.pezkuwiUtil, pk = window.polkadotUtilCrypto;
const RAW = 'https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/signed/';
const $ = (id) => document.getElementById(id);
const enc = new TextEncoder();
const show = (id, on = true) => { $(id).hidden = !on; };
const fail = (message) => { $('error').textContent = message; show('error'); $('summary').textContent = ''; };
const b64 = (u8) => btoa(String.fromCharCode(...u8));
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, '0')).join('');
const sha256 = async (u8) => new Uint8Array(await crypto.subtle.digest('SHA-256', u8));
const wrap = (payload) => pzu.u8aConcat(enc.encode('<Bytes>'), payload, enc.encode('</Bytes>'));
async function readEnvelope(text) {
const envelope = JSON.parse(text);
const payload = unb64(envelope.signed);
return { payload, root: JSON.parse(new TextDecoder().decode(payload)), signatures: envelope.signatures || [] };
}
try {
await pz.cryptoWaitReady();
await pk.cryptoWaitReady();
// The newest published root: root.json, then root.N.json while they exist.
let previous = await readEnvelope(await (await fetch(RAW + 'root.json', { cache: 'no-store' })).text());
for (;;) {
const next = await fetch(RAW + `root.${previous.root.version + 1}.json`, { cache: 'no-store' });
if (!next.ok) break;
previous = await readEnvelope(await next.text());
}
const version = previous.root.version + 1;
const response = await fetch(`proposals/root.${version}.json`, { cache: 'no-store' });
if (!response.ok) { $('summary').textContent = `Ji bo guhertoya ${version} pêşniyar tune.`; return; }
const proposalText = await response.text();
const proposal = await readEnvelope(proposalText);
if (proposal.root.type !== 'root' || proposal.root.version !== version) throw new Error('Pêşniyar ne koka paşîn e.');
const message = wrap(proposal.payload);
const fingerprint = hex(await sha256(proposal.payload)).slice(0, 12).toUpperCase().match(/.{4}/g).join(' · ');
// keyId -> {address, roles}
const holders = new Map();
const addHolder = (root, role) => {
for (const keyId of root.root.keyids) {
const key = root.keys[keyId];
if (key.scheme !== 'sr25519') continue;
const entry = holders.get(keyId) || { public: unb64(key.public), roles: new Set() };
entry.roles.add(role);
holders.set(keyId, entry);
}
};
addHolder(previous.root, 'previous');
addHolder(proposal.root, 'next');
for (const entry of holders.values()) entry.address = pz.encodeAddress(entry.public, 42);
const signatures = new Map(); // keyId -> {sig, context}
function classify(keyId, sig) {
const entry = holders.get(keyId);
if (!entry) return null;
if (pk.sr25519Verify(message, sig, entry.public)) return 'substrate';
if (pz.sr25519Verify(message, sig, entry.public)) return 'bizinikiwi';
return null;
}
for (const { keyid, sig } of proposal.signatures) {
const bytes = unb64(sig);
const context = classify(keyid, bytes);
if (context) signatures.set(keyid, { sig: bytes, context });
}
$('summary').textContent = `Pêşniyar: koka config-a îmzekirî ya cîzdanê. Ji ${proposal.root.root.keyids.length} xwediyên nû ${proposal.root.root.threshold} îmze, ji yên heyî ${previous.root.root.threshold} îmze pêwîst in.`;
$('version').textContent = `${previous.root.version} → ${version}`;
$('threshold').textContent = `${proposal.root.root.threshold} ji ${proposal.root.root.keyids.length}`;
$('expires').textContent = new Date(proposal.root.expires * 1000).toLocaleDateString('de-DE');
for (const keyId of proposal.root.root.keyids) {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = '<span></span><span class="mono"></span>';
row.children[0].textContent = holders.get(keyId).address.slice(0, 6) + '…';
row.children[1].textContent = holders.get(keyId).address;
$('newKeys').appendChild(row);
}
$('fingerprint').textContent = fingerprint;
['facts', 'keysBlock', 'fpBlock', 'fpHint', 'collect', 'pasteBlock', 'addButton', 'downloadButton'].forEach((id) => show(id));
function render() {
$('holders').replaceChildren();
const counts = { previous: 0, next: 0 };
for (const [keyId, entry] of holders) {
const signed = signatures.get(keyId);
if (signed && signed.context === 'substrate') entry.roles.forEach((role) => counts[role]++);
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = '<span class="mono"></span><span></span>';
const roles = [...entry.roles].map((r) => (r === 'previous' ? 'heyî' : 'nû')).join(' + ');
row.children[0].textContent = `${entry.address.slice(0, 6)}…${entry.address.slice(-4)} (${roles})`;
row.children[1].textContent = !signed ? 'Li bendê' : signed.context === 'substrate' ? 'Îmzekirî' : 'Çewt: bizinikiwi';
row.children[1].className = !signed ? '' : signed.context === 'substrate' ? 'ok' : 'bad';
$('holders').appendChild(row);
}
const need = { previous: previous.root.root.threshold, next: proposal.root.root.threshold };
$('tally').textContent = `Îmze: heyî ${counts.previous} ji ${need.previous} · nû ${counts.next} ji ${need.next}`;
$('downloadButton').disabled = !(counts.previous >= need.previous && counts.next >= need.next);
}
render();
$('downloadButton').onclick = () => {
const valid = [...signatures].filter(([, s]) => s.context === 'substrate').map(([keyid, s]) => ({ keyid, sig: b64(s.sig) }));
const envelope = JSON.parse(proposalText);
envelope.signatures = valid;
const blob = new Blob([JSON.stringify(envelope, null, 1) + '\n'], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `root.${version}.json`;
a.click();
};
$('addButton').onclick = () => {
const text = $('paste').value.trim();
try {
const { keyid, sig } = JSON.parse(atob(text));
const bytes = unb64(sig);
const context = classify(keyid, bytes);
if (!context) throw new Error('bad');
signatures.set(keyid, { sig: bytes, context });
$('paste').value = '';
render();
} catch (e) {
alert('Ev kod ji bo vê pêşniyarê ne derbasdar e.');
}
};
// The wallet's injected provider; the signing page asks for accounts only to find the holder's key.
const injected = window.injectedWeb3 && (injectedWeb3['pezkuwi-wallet'] || injectedWeb3['pezkuwi-js'] || injectedWeb3['polkadot-js']);
if (!injected) {
$('signResult').textContent = 'Ji bo îmzekirinê vê rûpelê di geroka DApp ya Pezkuwi Cîzdan de veke.';
$('signResult').className = 'note warn';
show('signResult');
return;
}
show('signButton');
$('signButton').onclick = async () => {
try {
const extension = await injected.enable('Pezkuwi root signing');
const accounts = await extension.accounts.get();
const mine = accounts.map((a) => ({ address: a.address, keyId: null, public: pz.decodeAddress(a.address) }));
for (const account of mine) account.keyId = hex(await sha256(account.public));
const holder = mine.find((a) => holders.has(a.keyId));
if (!holder) throw new Error('Di vê cîzdanê de hesabekî xwediyê kokê tune.');
const { signature } = await extension.signer.signRaw({ address: holder.address, data: pzu.u8aToHex(message), type: 'bytes' });
let sig = pzu.hexToU8a(signature);
if (sig.length === 65 && sig[0] === 1) sig = sig.slice(1); // MultiSignature: 0x01 = sr25519
const context = classify(holder.keyId, sig);
signatures.set(holder.keyId, { sig, context });
render();
const box = $('signResult');
if (context === 'substrate') {
box.textContent = 'Îmze derbasdar e. Kodê kopî bike û ji xwediyê ku pelê berhev dike re bişîne.';
box.className = 'note ok';
const code = btoa(JSON.stringify({ keyid: holder.keyId, sig: b64(sig) }));
$('shareButton').onclick = () => navigator.clipboard.writeText(code).then(() => alert('Hate kopîkirin'), () => prompt('Kod', code));
show('shareButton');
} else {
box.textContent = context === 'bizinikiwi'
? 'Cîzdan bi çarçoveya "bizinikiwi" îmze kir; cîzdanên 1.3.0 vê qebûl nakin. Ji pêşdebiran re bêje.'
: 'Îmze nehat piştrastkirin.';
box.className = 'note bad';
}
show('signResult');
} catch (e) {
$('signResult').textContent = e.message || String(e);
$('signResult').className = 'note bad';
show('signResult');
}
};
} catch (e) {
fail(e.message || String(e));
}
})();
</script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
{
"signed": "eyJleHBpcmVzIjoxOTUxNTAxMDIwLCJrZXlzIjp7IjYwNjZlNmE4NzI5YzM0MDk4OWU3YzE1MGVmYmQ2M2IxZWZiOWZmMWNjYWExZTFmMzhmYTAxNWMyZjFmOWFiNWEiOnsicHVibGljIjoiWGFPSGYzT1NTWUxxNmxabUVuSTNDRlk5cmR3UGU5eWNsc0R4U1A2NEdXWT0iLCJzY2hlbWUiOiJlZDI1NTE5In0sIjdhMzU1Y2Q1MWNjMDQxNTc5MDM1NzJhNjRkY2ZiYmIyY2Q2NTUzNTA4ODZkNzllMGM5ZDI1Yzc5MWI3MDQ2ZWEiOnsicHVibGljIjoiakhGZ3p2YThEUkJKSWRYa1dXdGxHZ3hBazVLOW1KeXhCYWIvakhPQlMyaz0iLCJzY2hlbWUiOiJzcjI1NTE5In0sIjdlZmQ2MDg4Y2FlOWYzZDMzMGFmZjAwOGM1N2RkNzA5NWE2MjczMjkxMjRhZTYwZTg2Yzg3NmZhMWE1ZTkwY2MiOnsicHVibGljIjoiM0lmZThTRnNVWkRhdFIrb3BsekdSZjdYSU8rQU93RGVmTVJOMFVLcjBRZz0iLCJzY2hlbWUiOiJzcjI1NTE5In0sImZmZjc1NGJiNjcxNmYwNDU1Mjk2NzJkNmRkNWQxYzhlNmUxNzBkODNmZThjZDA3NjUyYWE4N2E0MjdjMGFhZjgiOnsicHVibGljIjoicEsyMi9nUVVtRmZsekNJTXU3czdHNEpiNkhvNFNTR2ZLSHNLUk5hVkkwYz0iLCJzY2hlbWUiOiJzcjI1NTE5In19LCJyb290Ijp7ImtleWlkcyI6WyI3YTM1NWNkNTFjYzA0MTU3OTAzNTcyYTY0ZGNmYmJiMmNkNjU1MzUwODg2ZDc5ZTBjOWQyNWM3OTFiNzA0NmVhIiwiN2VmZDYwODhjYWU5ZjNkMzMwYWZmMDA4YzU3ZGQ3MDk1YTYyNzMyOTEyNGFlNjBlODZjODc2ZmExYTVlOTBjYyIsImZmZjc1NGJiNjcxNmYwNDU1Mjk2NzJkNmRkNWQxYzhlNmUxNzBkODNmZThjZDA3NjUyYWE4N2E0MjdjMGFhZjgiXSwidGhyZXNob2xkIjoyfSwidGFyZ2V0cyI6eyJrZXlpZHMiOlsiNjA2NmU2YTg3MjljMzQwOTg5ZTdjMTUwZWZiZDYzYjFlZmI5ZmYxY2NhYTFlMWYzOGZhMDE1YzJmMWY5YWI1YSJdLCJ0aHJlc2hvbGQiOjF9LCJ0eXBlIjoicm9vdCIsInZlcnNpb24iOjJ9",
"signatures": []
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+562
View File
@@ -0,0 +1,562 @@
{
"name": "pezkuwi-wallet-utils-root",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pezkuwi-wallet-utils-root",
"dependencies": {
"@pezkuwi/util": "14.0.26",
"@pezkuwi/util-crypto": "14.0.26",
"@polkadot/util": "13.5.9",
"@polkadot/util-crypto": "13.5.9"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@pezkuwi/networks": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/networks/-/networks-14.0.26.tgz",
"integrity": "sha512-omDbJzFjDSm1FS32zebyNP1QGBk9/ZayM0YM0mUlOOwiLIP7iDVq3KQe2JdI7hm1dz01Lm68/hjerSoqiWEclA==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/util": "14.0.26",
"@substrate/ss58-registry": "^1.51.0",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pezkuwi/scure-sr25519": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@pezkuwi/scure-sr25519/-/scure-sr25519-0.2.1.tgz",
"integrity": "sha512-KGxCR+fI4p8QyIpwmRNIj8fbq7O+yz0iZZ/O2HJBVE7O9D6ksBKwIILqODJfE6Qvrf3F8DyT6/3cPoR3e/s8iA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.2",
"@noble/hashes": "~1.8.0"
}
},
"node_modules/@pezkuwi/util": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/util/-/util-14.0.26.tgz",
"integrity": "sha512-xV7uct4jtIqf+9LixcCoTgYz8JEZ2eK6aosMD92Zu5DRPSW87QcCYBQuq8Phec0LdjGvC/l7DgiloQODhTtHWA==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/x-bigint": "14.0.26",
"@pezkuwi/x-global": "14.0.26",
"@pezkuwi/x-textdecoder": "14.0.26",
"@pezkuwi/x-textencoder": "14.0.26",
"@types/bn.js": "^5.1.6",
"bn.js": "^5.2.1",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pezkuwi/util-crypto": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/util-crypto/-/util-crypto-14.0.26.tgz",
"integrity": "sha512-YR+3N2FwKx3q9NF11q75O0lJ+SKpM9CNKPE7rLEoPvFt7wpHpB/GXOAekYhNtrotkcsI8v/WoyOi4RI9NiwYVQ==",
"license": "Apache-2.0",
"dependencies": {
"@noble/curves": "^1.9.7",
"@noble/hashes": "^1.8.0",
"@pezkuwi/networks": "14.0.26",
"@pezkuwi/scure-sr25519": "^0.2.1",
"@pezkuwi/util": "14.0.26",
"@pezkuwi/wasm-crypto": "^7.5.20",
"@pezkuwi/wasm-util": "^7.5.20",
"@pezkuwi/x-bigint": "14.0.26",
"@pezkuwi/x-randomvalues": "14.0.26",
"@scure/base": "^1.1.7",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "14.0.26"
}
},
"node_modules/@pezkuwi/wasm-bridge": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-bridge/-/wasm-bridge-7.5.21.tgz",
"integrity": "sha512-KVWLXpmVHpYo1+woN+o8OK03Kk8+VwUtF/Dn5pnsVXbsGbXM22HWbx36ChzdkjpW35a5Ukfk92H0aCy8klfeDg==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/wasm-util": "7.5.21",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "*",
"@pezkuwi/x-randomvalues": "*"
}
},
"node_modules/@pezkuwi/wasm-crypto": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-crypto/-/wasm-crypto-7.5.21.tgz",
"integrity": "sha512-gL771hQsC0NXPFTX4+yKTPEMfX1svRAM1lzep+LkniQcYqpqI4AAxzUaOGZYj5lQzXKSkgYpcaLuTZ441zPXAg==",
"license": "Apache-2.0",
"dependencies": {
"@noble/hashes": "^1.3.3",
"@pezkuwi/wasm-bridge": "7.5.21",
"@pezkuwi/wasm-crypto-asmjs": "7.5.21",
"@pezkuwi/wasm-crypto-init": "7.5.21",
"@pezkuwi/wasm-crypto-wasm": "7.5.21",
"@pezkuwi/wasm-util": "7.5.21",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "*",
"@pezkuwi/x-randomvalues": "*"
}
},
"node_modules/@pezkuwi/wasm-crypto-asmjs": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-crypto-asmjs/-/wasm-crypto-asmjs-7.5.21.tgz",
"integrity": "sha512-uqhAR3slp6qbn2Cf7g72v4Ko0hxWm/NuZL2ZDH65QBSGP9jkSnAoBjm5L7AO739quEVA8rFsR8CxN5gvzERspA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "*"
}
},
"node_modules/@pezkuwi/wasm-crypto-init": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-crypto-init/-/wasm-crypto-init-7.5.21.tgz",
"integrity": "sha512-pdgYqhsHIoxTdhwbxS8iacw4sFh3NLN9fJ3odzCAo4+QCr3xTU1FMjBiYJRkx6Q4BpthCjOWJPCwTEZnJ3lSCg==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/wasm-bridge": "7.5.21",
"@pezkuwi/wasm-crypto-asmjs": "7.5.21",
"@pezkuwi/wasm-crypto-wasm": "7.5.21",
"@pezkuwi/wasm-util": "7.5.21",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "*",
"@pezkuwi/x-randomvalues": "*"
}
},
"node_modules/@pezkuwi/wasm-crypto-wasm": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-crypto-wasm/-/wasm-crypto-wasm-7.5.21.tgz",
"integrity": "sha512-TR8hHkjY6oga4aI9Irlv/y00+KwiCBMt+EyXi3mTUVNn7qKE4DwXbDc/az4rLK8p5Kno4/iyCF6YXuHbjEK4Ag==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/wasm-util": "7.5.21",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "*"
}
},
"node_modules/@pezkuwi/wasm-util": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/@pezkuwi/wasm-util/-/wasm-util-7.5.21.tgz",
"integrity": "sha512-KMiUHCEa6iFNLNWyw9GUf5/K2UP6RjCia2iSGjcazwBNofAQVpP6zfeLb9y5szpextKGkDsODxpcIFenJ3KcFQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "*"
}
},
"node_modules/@pezkuwi/x-bigint": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/x-bigint/-/x-bigint-14.0.26.tgz",
"integrity": "sha512-TiCUVJiW07KurXot79AuKsqdIKGCtysMVDKLGlUc7mxlDJ+BCNJVmi9D0vvbB/gboxfJqONFQKt8eOXHKFg1iw==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/x-global": "14.0.26",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pezkuwi/x-global": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/x-global/-/x-global-14.0.26.tgz",
"integrity": "sha512-3D/dgFUugJfJ59JETey2EPpWL7X7NOrSEgNj1Rt3Vu9zR74QNocc5HEcyapFKj2hNNp5gPPDOOWbrLkkDnbdXw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pezkuwi/x-randomvalues": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/x-randomvalues/-/x-randomvalues-14.0.26.tgz",
"integrity": "sha512-9tQ5+e7b5sC6y7NWFojfkfKZNSmWBpB1dnuyMv5vJMPlBPlsscTYARfk8ln860XWLdemQBYBvYbibmEBtLFfSQ==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/x-global": "14.0.26",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@pezkuwi/util": "14.0.26",
"@pezkuwi/wasm-util": "*"
}
},
"node_modules/@pezkuwi/x-textdecoder": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/x-textdecoder/-/x-textdecoder-14.0.26.tgz",
"integrity": "sha512-O3l/pcT+7q89COMe+h+Gr+HLLym3k0RfWXWwEg6RYGBHxp77GfixwqAR/o6k6rBXNfxlAY60RcYUqu+DGkKSZw==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/x-global": "14.0.26",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pezkuwi/x-textencoder": {
"version": "14.0.26",
"resolved": "https://registry.npmjs.org/@pezkuwi/x-textencoder/-/x-textencoder-14.0.26.tgz",
"integrity": "sha512-aUk+h0g5iO+V3CZV1vdlRy4G9MbE0SbomB5Lw/8IkuQ+wNPK9JQ1FlArMhxVBfXM9JD8DPih/ViZsaNhWfjVVw==",
"license": "Apache-2.0",
"dependencies": {
"@pezkuwi/x-global": "14.0.26",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polkadot/networks": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/networks/-/networks-13.5.9.tgz",
"integrity": "sha512-nmKUKJjiLgcih0MkdlJNMnhEYdwEml2rv/h59ll2+rAvpsVWMTLCb6Cq6q7UC44+8kiWK2UUJMkFU+3PFFxndA==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/util": "13.5.9",
"@substrate/ss58-registry": "^1.51.0",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polkadot/util": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/util/-/util-13.5.9.tgz",
"integrity": "sha512-pIK3XYXo7DKeFRkEBNYhf3GbCHg6dKQisSvdzZwuyzA6m7YxQq4DFw4IE464ve4Z7WsJFt3a6C9uII36hl9EWw==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/x-bigint": "13.5.9",
"@polkadot/x-global": "13.5.9",
"@polkadot/x-textdecoder": "13.5.9",
"@polkadot/x-textencoder": "13.5.9",
"@types/bn.js": "^5.1.6",
"bn.js": "^5.2.1",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polkadot/util-crypto": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/util-crypto/-/util-crypto-13.5.9.tgz",
"integrity": "sha512-foUesMhxkTk8CZ0/XEcfvHk6I0O+aICqqVJllhOpyp/ZVnrTBKBf59T6RpsXx2pCtBlMsLRvg/6Mw7RND1HqDg==",
"license": "Apache-2.0",
"dependencies": {
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.3.3",
"@polkadot/networks": "13.5.9",
"@polkadot/util": "13.5.9",
"@polkadot/wasm-crypto": "^7.5.3",
"@polkadot/wasm-util": "^7.5.3",
"@polkadot/x-bigint": "13.5.9",
"@polkadot/x-randomvalues": "13.5.9",
"@scure/base": "^1.1.7",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "13.5.9"
}
},
"node_modules/@polkadot/wasm-bridge": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/@polkadot/wasm-bridge/-/wasm-bridge-7.5.4.tgz",
"integrity": "sha512-6xaJVvoZbnbgpQYXNw9OHVNWjXmtcoPcWh7hlwx3NpfiLkkjljj99YS+XGZQlq7ks2fVCg7FbfknkNb8PldDaA==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "*",
"@polkadot/x-randomvalues": "*"
}
},
"node_modules/@polkadot/wasm-crypto": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto/-/wasm-crypto-7.5.4.tgz",
"integrity": "sha512-1seyClxa7Jd7kQjfnCzTTTfYhTa/KUTDUaD3DMHBk5Q4ZUN1D1unJgX+v1aUeXSPxmzocdZETPJJRZjhVOqg9g==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/wasm-bridge": "7.5.4",
"@polkadot/wasm-crypto-asmjs": "7.5.4",
"@polkadot/wasm-crypto-init": "7.5.4",
"@polkadot/wasm-crypto-wasm": "7.5.4",
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "*",
"@polkadot/x-randomvalues": "*"
}
},
"node_modules/@polkadot/wasm-crypto-asmjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-asmjs/-/wasm-crypto-asmjs-7.5.4.tgz",
"integrity": "sha512-ZYwxQHAJ8pPt6kYk9XFmyuFuSS+yirJLonvP+DYbxOrARRUHfN4nzp4zcZNXUuaFhpbDobDSFn6gYzye6BUotA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "*"
}
},
"node_modules/@polkadot/wasm-crypto-init": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-init/-/wasm-crypto-init-7.5.4.tgz",
"integrity": "sha512-U6s4Eo2rHs2n1iR01vTz/sOQ7eOnRPjaCsGWhPV+ZC/20hkVzwPAhiizu/IqMEol4tO2yiSheD4D6bn0KxUJhg==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/wasm-bridge": "7.5.4",
"@polkadot/wasm-crypto-asmjs": "7.5.4",
"@polkadot/wasm-crypto-wasm": "7.5.4",
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "*",
"@polkadot/x-randomvalues": "*"
}
},
"node_modules/@polkadot/wasm-crypto-wasm": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/@polkadot/wasm-crypto-wasm/-/wasm-crypto-wasm-7.5.4.tgz",
"integrity": "sha512-PsHgLsVTu43eprwSvUGnxybtOEuHPES6AbApcs7y5ZbM2PiDMzYbAjNul098xJK/CPtrxZ0ePDFnaQBmIJyTFw==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "*"
}
},
"node_modules/@polkadot/wasm-util": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/@polkadot/wasm-util/-/wasm-util-7.5.4.tgz",
"integrity": "sha512-hqPpfhCpRAqCIn/CYbBluhh0TXmwkJnDRjxrU9Bnqtw9nMNa97D8JuOjdd2pi0rxm+eeLQ/f1rQMp71RMM9t4w==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "*"
}
},
"node_modules/@polkadot/x-bigint": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/x-bigint/-/x-bigint-13.5.9.tgz",
"integrity": "sha512-JVW6vw3e8fkcRyN9eoc6JIl63MRxNQCP/tuLdHWZts1tcAYao0hpWUzteqJY93AgvmQ91KPsC1Kf3iuuZCi74g==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/x-global": "13.5.9",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polkadot/x-global": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/x-global/-/x-global-13.5.9.tgz",
"integrity": "sha512-zSRWvELHd3Q+bFkkI1h2cWIqLo1ETm+MxkNXLec3lB56iyq/MjWBxfXnAFFYFayvlEVneo7CLHcp+YTFd9aVSA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polkadot/x-randomvalues": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/x-randomvalues/-/x-randomvalues-13.5.9.tgz",
"integrity": "sha512-Uuuz3oubf1JCCK97fsnVUnHvk4BGp/W91mQWJlgl5TIOUSSTIRr+lb5GurCfl4kgnQq53Zi5fJV+qR9YumbnZw==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/x-global": "13.5.9",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@polkadot/util": "13.5.9",
"@polkadot/wasm-util": "*"
}
},
"node_modules/@polkadot/x-textdecoder": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/x-textdecoder/-/x-textdecoder-13.5.9.tgz",
"integrity": "sha512-W2HhVNUbC/tuFdzNMbnXAWsIHSg9SC9QWDNmFD3nXdSzlXNgL8NmuiwN2fkYvCQBtp/XSoy0gDLx0C+Fo19cfw==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/x-global": "13.5.9",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polkadot/x-textencoder": {
"version": "13.5.9",
"resolved": "https://registry.npmjs.org/@polkadot/x-textencoder/-/x-textencoder-13.5.9.tgz",
"integrity": "sha512-SG0MHnLUgn1ZxFdm0KzMdTHJ47SfqFhdIPMcGA0Mg/jt2rwrfrP3jtEIJMsHfQpHvfsNPfv55XOMmoPWuQnP/Q==",
"license": "Apache-2.0",
"dependencies": {
"@polkadot/x-global": "13.5.9",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@substrate/ss58-registry": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@substrate/ss58-registry/-/ss58-registry-1.51.0.tgz",
"integrity": "sha512-TWDurLiPxndFgKjVavCniytBIw+t4ViOi7TYp9h/D0NMmkEc9klFTo+827eyEJ0lELpqO207Ey7uGxUa+BS1jQ==",
"license": "Apache-2.0"
},
"node_modules/@types/bn.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz",
"integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/bn.js": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz",
"integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
"integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "pezkuwi-wallet-utils-root",
"private": true,
"type": "module",
"description": "Verifies the signed config root chain as the wallet does",
"dependencies": {
"@pezkuwi/util": "14.0.26",
"@pezkuwi/util-crypto": "14.0.26",
"@polkadot/util": "13.5.9",
"@polkadot/util-crypto": "13.5.9"
}
}
+130
View File
@@ -0,0 +1,130 @@
// 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);
+22 -8
View File
@@ -9,7 +9,7 @@ Every metadata file is an envelope {"signed": base64(payload), "signatures": [{"
hex sha256 of the 32-byte public key. An ed25519 key signs the payload bytes. An sr25519 key is a person's
wallet account and signs through the wallet's message signing, which wraps the bytes as <Bytes>payload</Bytes>.
signed_config.py root --version N --expires-days D --root-threshold T --root ADDR... --targets-pub FILE
signed_config.py root --version N --expires-days D --root-threshold T --root ADDR... [--targets-pub FILE]
signed_config.py sign --key SEED_FILE [--expires-days D]
signed_config.py check [--min-days D]
"""
@@ -33,10 +33,14 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
REPO = Path(__file__).resolve().parent.parent
ROOT_FILE = REPO / "signed" / "root.json"
TARGETS_FILE = REPO / "signed" / "targets.json"
# A root after the first is drafted here, where the wallet never looks, and signed with the root signing page
# beside it. Only a root with all its signatures moves to signed/: wallets that find a root they cannot verify
# stop refreshing their config.
PROPOSALS = REPO / "docs" / "root-signing" / "proposals"
# Everything the wallet may fetch from this repository is JSON outside these trees; the wallet
# refuses any other JSON file from here, so a tree is only left out when nothing reads it.
UNSERVED = ("nova-base/", "pezkuwi-overlay/", "docs/", "signed/")
UNSERVED = ("nova-base/", "pezkuwi-overlay/", "docs/", "signed/", "scripts/")
BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
DAY = 86400
@@ -117,7 +121,13 @@ def cmd_root(args):
"public": base64.b64encode(public).decode(),
}
root_ids.append(key_id(public))
targets_public = base64.b64decode(Path(args.targets_pub).read_text().strip())
if args.targets_pub:
targets_public = base64.b64decode(Path(args.targets_pub).read_text().strip())
else:
# Rotating the root keys keeps the targets key unless a new one is given.
previous = current_root()
(targets_id,) = previous["targets"]["keyids"]
targets_public = base64.b64decode(previous["keys"][targets_id]["public"])
if len(targets_public) != 32:
sys.exit("targets public key must be 32 bytes")
keys[key_id(targets_public)] = {
@@ -135,11 +145,15 @@ def cmd_root(args):
"root": {"keyids": sorted(root_ids), "threshold": args.root_threshold},
"targets": {"keyids": [key_id(targets_public)], "threshold": 1},
}
path = root_file(args.version)
path.parent.mkdir(exist_ok=True)
# The first root is trusted because the APK carries it; a later one needs the signatures of a threshold
# of the previous root keys and of its own, collected from the holders' wallets and added to this file
# before it is published.
# of the previous root keys and of its own, collected with the root signing page, before it is published.
if args.version == 1:
path = ROOT_FILE
else:
if args.version != current_root()["version"] + 1:
sys.exit(f"the next root is version {current_root()['version'] + 1}")
path = PROPOSALS / f"root.{args.version}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(envelope(canonical(payload), []))
print(f"wrote {path.relative_to(REPO)} version {args.version}")
@@ -241,7 +255,7 @@ def main():
root.add_argument("--expires-days", type=int, required=True)
root.add_argument("--root-threshold", type=int, required=True)
root.add_argument("--root", nargs="+", required=True)
root.add_argument("--targets-pub", required=True)
root.add_argument("--targets-pub")
root.set_defaults(run=cmd_root)
sign = sub.add_parser("sign")