// Minisign signature checks (the format of Tauri's updater signatures, `tauri signer sign`), with node:crypto only. // A Tauri public key or `.sig` file is the base64 of the minisign text file: // public key: "untrusted comment: ...\n" + base64("Ed" | key id (8) | Ed25519 public key (32)) // signature: "untrusted comment: ...\n" + base64(alg (2) | key id (8) | signature (64)) + "\n" // "trusted comment: ...\n" + base64(global signature (64)) // alg "ED" signs the BLAKE2b-512 hash of the file (what Tauri writes), "Ed" the file itself. The global signature // covers the signature and the trusted comment, so the comment (timestamp, file name) cannot be swapped either. import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'; export interface PublicKey { keyId: string; key: Buffer } export interface Signature { alg: 'Ed' | 'ED'; keyId: string; sig: Buffer; trustedComment: string; globalSig: Buffer } const b64 = (s: string): Buffer => { const t = s.trim(); if (!/^[A-Za-z0-9+/]+={0,2}$/.test(t)) throw new Error('not base64'); return Buffer.from(t, 'base64'); }; /** The text of a minisign file, from the file itself or from Tauri's base64 wrapping of it. */ function text(input: string): string { const t = input.trim(); if (t.startsWith('untrusted comment:')) return t; return b64(t).toString('utf8'); } export function parsePublicKey(input: string): PublicKey { const lines = text(input).split(/\r?\n/); if (!lines[0]?.startsWith('untrusted comment:') || !lines[1]) throw new Error('minisign public key: unexpected format'); const raw = b64(lines[1]); if (raw.length !== 42 || raw.subarray(0, 2).toString('latin1') !== 'Ed') throw new Error('minisign public key: not an Ed25519 key'); return { keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString('hex').toUpperCase(), key: raw.subarray(10) }; } export function parseSignature(input: string): Signature { const lines = text(input).split(/\r?\n/); if (!lines[0]?.startsWith('untrusted comment:') || !lines[1] || !lines[2]?.startsWith('trusted comment: ') || !lines[3]) { throw new Error('minisign signature: unexpected format'); } const raw = b64(lines[1]); const alg = raw.subarray(0, 2).toString('latin1'); if (raw.length !== 74 || (alg !== 'Ed' && alg !== 'ED')) throw new Error('minisign signature: unknown algorithm'); const globalSig = b64(lines[3]); if (globalSig.length !== 64) throw new Error('minisign signature: bad global signature'); return { alg, keyId: Buffer.from(raw.subarray(2, 10)).reverse().toString('hex').toUpperCase(), sig: raw.subarray(10), trustedComment: lines[2].slice('trusted comment: '.length), globalSig }; } function ed25519(key: Buffer) { return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: key.toString('base64url') }, format: 'jwk' }); } export type VerifyResult = { ok: true; trustedComment: string } | { ok: false; reason: string }; /** Checks `data` against a minisign signature and public key (either may be Tauri's base64 form). */ export function verify(data: Uint8Array, signature: string, publicKey: string): VerifyResult { let pk: PublicKey; let s: Signature; try { pk = parsePublicKey(publicKey); s = parseSignature(signature); } catch (e) { return { ok: false, reason: (e as Error).message }; } if (pk.keyId !== s.keyId) return { ok: false, reason: `signed with key ${s.keyId}, expected ${pk.keyId}` }; const key = ed25519(pk.key); const msg = s.alg === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data); if (!edVerify(null, msg, key, s.sig)) return { ok: false, reason: 'the signature does not match the file' }; if (!edVerify(null, Buffer.concat([s.sig, Buffer.from(s.trustedComment, 'utf8')]), key, s.globalSig)) return { ok: false, reason: 'the trusted comment was changed' }; return { ok: true, trustedComment: s.trustedComment }; }