@graneth/verify
Advanced tools
+37
-3
@@ -6,2 +6,19 @@ /** | ||
| * wires this up to real fs + process.exit. | ||
| * | ||
| * ── WHY THIS FILE HAS THREE OUTCOMES AND NOT TWO ──────────────────────────── | ||
| * This is the one thing we ship that runs on the *recipient's* machine. An | ||
| * agency forwards a receipt to its DACH client; the client runs this; whatever | ||
| * this prints is what the agency then has to explain. | ||
| * | ||
| * It used to print FAIL for "I forgot --key", for "the key file isn't where I | ||
| * said", for "the receipt didn't download properly", and for "this document | ||
| * was altered after signing" — one word for a mistake in the command and for | ||
| * an accusation of forgery. The first four are true of thousands of correct | ||
| * receipts. Printing the same word for them spends the credibility the receipt | ||
| * exists to earn, on the machine where we are least able to explain ourselves. | ||
| * | ||
| * So: PASS / FAIL / MALFORMED / UNCHECKED, as a word for the person AND as an | ||
| * exit code for the script, because in CI nobody reads the word. Which of the | ||
| * last two a document gets is decided by `loadReceipt` / `readKeyDocument` | ||
| * below — see the note there; it is a security boundary, not a taxonomy. | ||
| */ | ||
@@ -14,2 +31,19 @@ export interface CliIO { | ||
| } | ||
| /** | ||
| * The contract a script may depend on. | ||
| * | ||
| * 0 is reserved: it means a signature was checked and is valid, and it means | ||
| * nothing else. Everything that is not a verdict — including `--help` — is 2, | ||
| * so no invocation that failed to check anything can be mistaken for one that | ||
| * did. | ||
| * | ||
| * 2 and 3 differ by WHOSE fault blocked the check, which decides whether the | ||
| * reader may be reassured: 2 is the reader's side, 3 is the document's. | ||
| */ | ||
| export declare const EXIT: { | ||
| readonly pass: 0; | ||
| readonly fail: 1; | ||
| readonly unchecked: 2; | ||
| readonly malformed: 3; | ||
| }; | ||
| /** Runs the CLI logic against injected I/O. Returns the process exit code. Never throws. */ | ||
@@ -42,6 +76,6 @@ export declare function runVerifyCli(argv: string[], io: CliIO): number; | ||
| * | ||
| * The exit code stays 0. The signature IS valid, scripts key on that, and | ||
| * changing the contract to express a display concern would break automation | ||
| * to fix prose. | ||
| * This is a DIFFERENT AXIS from PASS/FAIL/UNCHECKED: it describes the coverage | ||
| * of a signature that verified. The exit code stays 0, because the signature | ||
| * is valid and scripts key on that. | ||
| */ | ||
| export declare function describeAttestation(attestation: unknown): string[]; |
+214
-37
@@ -6,4 +6,98 @@ /** | ||
| * wires this up to real fs + process.exit. | ||
| * | ||
| * ── WHY THIS FILE HAS THREE OUTCOMES AND NOT TWO ──────────────────────────── | ||
| * This is the one thing we ship that runs on the *recipient's* machine. An | ||
| * agency forwards a receipt to its DACH client; the client runs this; whatever | ||
| * this prints is what the agency then has to explain. | ||
| * | ||
| * It used to print FAIL for "I forgot --key", for "the key file isn't where I | ||
| * said", for "the receipt didn't download properly", and for "this document | ||
| * was altered after signing" — one word for a mistake in the command and for | ||
| * an accusation of forgery. The first four are true of thousands of correct | ||
| * receipts. Printing the same word for them spends the credibility the receipt | ||
| * exists to earn, on the machine where we are least able to explain ourselves. | ||
| * | ||
| * So: PASS / FAIL / MALFORMED / UNCHECKED, as a word for the person AND as an | ||
| * exit code for the script, because in CI nobody reads the word. Which of the | ||
| * last two a document gets is decided by `loadReceipt` / `readKeyDocument` | ||
| * below — see the note there; it is a security boundary, not a taxonomy. | ||
| */ | ||
| import { verifyReceipt } from "./index.js"; | ||
| /** | ||
| * The contract a script may depend on. | ||
| * | ||
| * 0 is reserved: it means a signature was checked and is valid, and it means | ||
| * nothing else. Everything that is not a verdict — including `--help` — is 2, | ||
| * so no invocation that failed to check anything can be mistaken for one that | ||
| * did. | ||
| * | ||
| * 2 and 3 differ by WHOSE fault blocked the check, which decides whether the | ||
| * reader may be reassured: 2 is the reader's side, 3 is the document's. | ||
| */ | ||
| export const EXIT = { pass: 0, fail: 1, unchecked: 2, malformed: 3 }; | ||
| const KEY_DOWNLOAD_HINT = "Download it once with:\n" + | ||
| " curl -o graneth-key.json https://graneth.com/.well-known/graneth-attestation-key.json\n" + | ||
| "graneth-verify is an offline tool and will not fetch it for you."; | ||
| const HELP = `graneth-verify — offline check of a Graneth governance receipt's Ed25519 signature. | ||
| Usage: | ||
| graneth-verify <receipt.json> --key <key.json> | ||
| Options: | ||
| -k, --key <path> public key to check against: either Graneth's published key | ||
| document or a raw SPKI PEM file | ||
| -h, --help show this text | ||
| Get the key once (this tool makes no network calls of its own): | ||
| curl -o graneth-key.json https://graneth.com/.well-known/graneth-attestation-key.json | ||
| Exit codes: | ||
| 0 PASS the signature was checked, and it is valid | ||
| 1 FAIL the signature was checked, and the file does not match it: | ||
| the document was altered after it was signed | ||
| 2 UNCHECKED nothing was checked, and the obstacle is on YOUR side — a | ||
| missing or wrong key, an unreadable file, a mistake in the | ||
| command. This one says nothing about the receipt. | ||
| 3 MALFORMED this is not a well-formed Graneth receipt: no signature, or a | ||
| signed field removed since it was issued. Not the same as | ||
| UNCHECKED, and not to be treated as evidence. | ||
| Only 0 means verified. \`--help\` exits 2 for the same reason: exit 0 is reserved | ||
| for a receipt that actually verified.`; | ||
| const USAGE_HINT = "Usage: graneth-verify <receipt.json> --key <key.json>\n" + | ||
| "Run `graneth-verify --help` for the exit codes."; | ||
| /** | ||
| * Reports that no check happened, in a form that cannot be read as a verdict. | ||
| * The leading sentence is the one that saves the argument on the client's | ||
| * machine, so it is printed before the cause, every time. | ||
| */ | ||
| function unchecked(io, cause, hint) { | ||
| // "remains unverified" is load-bearing: without it the reassurance on the | ||
| // second half reads as an all-clear, which it is not — nothing was checked. | ||
| io.error("UNCHECKED — no signature was checked, so this receipt remains unverified."); | ||
| io.error(` ${cause}`); | ||
| if (hint) | ||
| for (const line of hint.split("\n")) | ||
| io.error(` ${line}`); | ||
| io.error(" The obstacle is on this side, not in the receipt's contents."); | ||
| return EXIT.unchecked; | ||
| } | ||
| /** | ||
| * The document is not a well-formed receipt. Deliberately carries none of the | ||
| * reassurance UNCHECKED carries: the case this exists for is a signed field | ||
| * being REMOVED, and telling that reader "this says nothing about the receipt" | ||
| * would be false at exactly the moment it matters. | ||
| */ | ||
| function malformed(io, cause) { | ||
| io.error("MALFORMED — this is not a well-formed Graneth receipt, and it has not been shown to be genuine."); | ||
| io.error(` ${cause}`); | ||
| io.error(" Do not treat it as evidence. Ask the sender for the receipt as it was issued."); | ||
| return EXIT.malformed; | ||
| } | ||
| /** | ||
| * Flags are recognised as flags. Previously anything unrecognised became the | ||
| * receipt path, so `graneth-verify --help` looked for a file called `--help` | ||
| * and reported FAIL — the tool's own help flag printed the word it prints for | ||
| * a forged document. | ||
| */ | ||
| function parseArgs(argv) { | ||
@@ -13,14 +107,25 @@ const out = {}; | ||
| const a = argv[i]; | ||
| if (a === "--key" || a === "-k") { | ||
| out.keyPath = argv[++i]; | ||
| if (a === "--help" || a === "-h") { | ||
| out.help = true; | ||
| } | ||
| else if (a === "--key" || a === "-k") { | ||
| const value = argv[++i]; | ||
| if (value === undefined) | ||
| return { usageError: `${a} needs a path to a key file` }; | ||
| out.keyPath = value; | ||
| } | ||
| else if (a.startsWith("-") && a !== "-") { | ||
| return { usageError: `unknown option: ${a}` }; | ||
| } | ||
| else if (!out.receiptPath) { | ||
| out.receiptPath = a; | ||
| } | ||
| else { | ||
| // Checking the first and ignoring the rest would report on less than the | ||
| // caller believes it reported on. | ||
| return { usageError: `this tool checks one receipt at a time, and got two: ${out.receiptPath} and ${a}` }; | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| const KEY_DOWNLOAD_HINT = "Download it once with:\n" + | ||
| " curl -o graneth-key.json https://graneth.com/.well-known/graneth-attestation-key.json\n" + | ||
| "graneth-verify is an offline tool and will not fetch it for you."; | ||
| /** | ||
@@ -54,4 +159,4 @@ * Accepts either the JSON key document served at | ||
| return { | ||
| error: `no published key matches the receipt's keyId ${wantedKeyId}. The signing key may have been ` + | ||
| "rotated without republishing that key document — download a fresh copy, or contact the issuer.", | ||
| error: `your key document has no key matching the receipt's keyId ${wantedKeyId}. The signing key may have been ` + | ||
| "rotated since you downloaded it — download a fresh copy, or contact the issuer.", | ||
| }; | ||
@@ -66,2 +171,15 @@ } | ||
| } | ||
| /** | ||
| * Which published key to try — read ONLY from the signed object. | ||
| * | ||
| * This runs BEFORE verifyReceipt classifies the document, so it is the earlier | ||
| * of the two places an unsigned field could steer the verdict. Falling back to | ||
| * the envelope's `keyId` (which no signature covers) let a stripped receipt | ||
| * reach "your key document has no key matching…" — an UNCHECKED, reassuring | ||
| * message — without the removal of the signed field ever being reported. | ||
| * | ||
| * With no signed keyId, no key is requested, `resolvePublicKeyPem` hands back | ||
| * the current one, and the document reaches the malformed check that is meant | ||
| * to catch it. | ||
| */ | ||
| function receiptKeyId(receipt) { | ||
@@ -74,43 +192,102 @@ const attestation = receipt.attestation; | ||
| } | ||
| const topLevel = receipt.keyId; | ||
| return typeof topLevel === "string" ? topLevel : undefined; | ||
| return undefined; | ||
| } | ||
| /** | ||
| * ── THE LINE, DRAWN ONCE ──────────────────────────────────────────────────── | ||
| * These two loaders exist so the reader's-fault / document's-fault split is a | ||
| * choice made in ONE place per artifact, instead of a judgement re-made at every | ||
| * error site. It had to be re-made three times, and got it wrong three times. | ||
| * | ||
| * Before the tool holds bytes claimed to be a receipt — no path, no file, no | ||
| * key, a mistyped command — the obstacle is the READER'S, and UNCHECKED's | ||
| * reassurance is true. | ||
| * | ||
| * Once it holds those bytes, a defect IN them is the DOCUMENT'S, and it is | ||
| * MALFORMED. Not because we know who damaged them: because one corrupted byte | ||
| * is indistinguishable, from in here, from a file that was edited — and the | ||
| * person who edits the file is the one who would benefit from the doubt. | ||
| * | ||
| * The key stays the reader's artifact end to end, so every defect in it — | ||
| * unreadable, not JSON, no matching key — is UNCHECKED, and that stays true. | ||
| * The default side is the one whose sentence does not lie under either reading: | ||
| * "this is not a well-formed Graneth receipt" is honest even when the reader | ||
| * simply pointed at the wrong file. | ||
| */ | ||
| function loadReceipt(io, path) { | ||
| let text; | ||
| try { | ||
| text = io.readFile(path); | ||
| } | ||
| catch (err) { | ||
| // Reading failed — a directory, a permission, a vanished file. No bytes | ||
| // were ever claimed to be a receipt, so this is still the reader's side. | ||
| return unchecked(io, `receipt file could not be read: ${err.message}`); | ||
| } | ||
| try { | ||
| return { receipt: JSON.parse(text) }; | ||
| } | ||
| catch (err) { | ||
| return malformed(io, `this file is not valid JSON, so it is not a receipt: ${err.message}. ` + | ||
| "It may have been truncated or edited — ask the sender for the receipt as it was issued"); | ||
| } | ||
| } | ||
| /** The key is the reader's own artifact: every way it can be wrong is UNCHECKED. */ | ||
| function readKeyDocument(io, path) { | ||
| try { | ||
| return io.readFile(path); | ||
| } | ||
| catch (err) { | ||
| return unchecked(io, `key file could not be read: ${err.message}`, KEY_DOWNLOAD_HINT); | ||
| } | ||
| } | ||
| /** Runs the CLI logic against injected I/O. Returns the process exit code. Never throws. */ | ||
| export function runVerifyCli(argv, io) { | ||
| const { receiptPath, keyPath } = parseArgs(argv); | ||
| const { receiptPath, keyPath, help, usageError } = parseArgs(argv); | ||
| if (help) { | ||
| io.log(HELP); | ||
| return EXIT.unchecked; | ||
| } | ||
| if (usageError) { | ||
| return unchecked(io, usageError, USAGE_HINT); | ||
| } | ||
| if (!receiptPath) { | ||
| io.error("Usage: graneth-verify <receipt.json> --key <key.json>"); | ||
| return 2; | ||
| return unchecked(io, "no receipt file given.", USAGE_HINT); | ||
| } | ||
| if (!io.exists(receiptPath)) { | ||
| io.error(`FAIL — receipt file not found: ${receiptPath}`); | ||
| return 1; | ||
| return unchecked(io, `receipt file not found: ${receiptPath}`); | ||
| } | ||
| let receipt; | ||
| try { | ||
| receipt = JSON.parse(io.readFile(receiptPath)); | ||
| } | ||
| catch (err) { | ||
| io.error(`FAIL — receipt file is not valid JSON: ${err.message}`); | ||
| return 1; | ||
| } | ||
| const loaded = loadReceipt(io, receiptPath); | ||
| if (typeof loaded === "number") | ||
| return loaded; | ||
| const receipt = loaded.receipt; | ||
| const receiptObj = receipt && typeof receipt === "object" ? receipt : {}; | ||
| if (!keyPath) { | ||
| io.error(`FAIL — no public key supplied. Pass --key <path-to-key.json>.\n${KEY_DOWNLOAD_HINT}`); | ||
| return 1; | ||
| return unchecked(io, "no public key supplied. Pass --key <path-to-key.json>.", KEY_DOWNLOAD_HINT); | ||
| } | ||
| if (!io.exists(keyPath)) { | ||
| io.error(`FAIL — key file not found: ${keyPath}\n${KEY_DOWNLOAD_HINT}`); | ||
| return 1; | ||
| return unchecked(io, `key file not found: ${keyPath}`, KEY_DOWNLOAD_HINT); | ||
| } | ||
| const resolved = resolvePublicKeyPem(io.readFile(keyPath), receiptKeyId(receiptObj)); | ||
| const keyText = readKeyDocument(io, keyPath); | ||
| if (typeof keyText === "number") | ||
| return keyText; | ||
| const resolved = resolvePublicKeyPem(keyText, receiptKeyId(receiptObj)); | ||
| if ("error" in resolved) { | ||
| io.error(`FAIL — ${resolved.error}`); | ||
| return 1; | ||
| return unchecked(io, resolved.error); | ||
| } | ||
| const result = verifyReceipt(receipt, resolved.pem); | ||
| if (!result.valid) { | ||
| io.error(`FAIL — ${result.reason}`); | ||
| return 1; | ||
| return report(io, verifyReceipt(receipt, resolved.pem), receiptObj); | ||
| } | ||
| /** Turns the one verdict into the one word and the one exit code it owns. */ | ||
| function report(io, result, receiptObj) { | ||
| if (result.outcome === "unverifiable") { | ||
| return unchecked(io, result.reason ?? "the check could not be completed."); | ||
| } | ||
| if (result.outcome === "malformed") { | ||
| return malformed(io, result.reason ?? "this document is not a well-formed receipt."); | ||
| } | ||
| if (result.outcome === "mismatch") { | ||
| io.error("FAIL — this file does not match the signature it carries."); | ||
| io.error(` ${result.reason}`); | ||
| io.error(" Do not rely on it. Ask the sender for the receipt as it was issued."); | ||
| return EXIT.fail; | ||
| } | ||
| io.log("PASS — Ed25519 signature is valid."); | ||
@@ -120,3 +297,3 @@ for (const line of describeAttestation(receiptObj.attestation)) | ||
| io.log(` keyId: ${result.keyId}`); | ||
| return 0; | ||
| return EXIT.pass; | ||
| } | ||
@@ -155,5 +332,5 @@ /** Fields a reader expects a governance receipt to attest to, and their labels. */ | ||
| * | ||
| * The exit code stays 0. The signature IS valid, scripts key on that, and | ||
| * changing the contract to express a display concern would break automation | ||
| * to fix prose. | ||
| * This is a DIFFERENT AXIS from PASS/FAIL/UNCHECKED: it describes the coverage | ||
| * of a signature that verified. The exit code stays 0, because the signature | ||
| * is valid and scripts key on that. | ||
| */ | ||
@@ -160,0 +337,0 @@ export function describeAttestation(attestation) { |
+31
-1
@@ -49,7 +49,37 @@ /** | ||
| } | ||
| /** | ||
| * Which of three things happened — NOT two. | ||
| * | ||
| * `valid: false` used to carry both "the cryptography ran and disagreed" and | ||
| * "the cryptography never ran", and a caller had no way to tell them apart | ||
| * short of string-matching `reason`. Those are opposite statements: the first | ||
| * accuses the document, the second describes the verifier's own inputs. A | ||
| * verifier that cannot say which one it means is the failure mode this whole | ||
| * package exists to argue against. | ||
| * | ||
| * - `valid` — checked, and the signature matches. | ||
| * - `mismatch` — checked, and it does not. The signed bytes changed after | ||
| * signing. | ||
| * - `malformed` — this is not a well-formed Graneth receipt: no signature, | ||
| * no attestation, or an attestation with a signed field | ||
| * removed. The DOCUMENT is at fault, and it has not been | ||
| * shown to be genuine. | ||
| * - `unverifiable` — no check took place, and the obstacle is on the reader's | ||
| * side: no key, the wrong key, an unreadable file. This one | ||
| * — and only this one — says nothing about the receipt. | ||
| * | ||
| * The line between the last two is load-bearing, not taxonomy. `unverifiable` | ||
| * is the state a reader may be reassured about, so whoever can steer a document | ||
| * INTO it can choose the sentence a reader sees. Nothing about a document's own | ||
| * contents is allowed to land there. | ||
| */ | ||
| export type VerifyOutcome = "valid" | "mismatch" | "malformed" | "unverifiable"; | ||
| export interface VerifyResult { | ||
| /** `outcome === "valid"`. Unchanged meaning — existing callers keep working. */ | ||
| valid: boolean; | ||
| /** Which of the three states this is. Prefer this over `!valid`. */ | ||
| outcome: VerifyOutcome; | ||
| /** keyId derived from the public key you supplied. Compare it yourself if you don't trust `valid` alone. */ | ||
| keyId: string; | ||
| /** Present only when valid is false: why. */ | ||
| /** Present unless valid: why. */ | ||
| reason?: string; | ||
@@ -56,0 +86,0 @@ } |
+116
-26
@@ -53,6 +53,99 @@ /** | ||
| } | ||
| function fail(keyId, reason) { | ||
| return { valid: false, keyId, reason }; | ||
| /** | ||
| * No check took place, and the obstacle is on the READER'S side: no key, the | ||
| * wrong key, a file we could not read, an algorithm this build does not | ||
| * implement. Nothing here is a statement about the receipt's contents — which | ||
| * is exactly why nothing that IS about its contents may land in this state. | ||
| */ | ||
| function unverifiable(keyId, reason) { | ||
| return { valid: false, outcome: "unverifiable", keyId, reason }; | ||
| } | ||
| /** The check ran and the signature does not cover these bytes. The only accusation. */ | ||
| function mismatch(keyId, reason) { | ||
| return { valid: false, outcome: "mismatch", keyId, reason }; | ||
| } | ||
| /** | ||
| * The document is not a well-formed Graneth receipt. Distinct from | ||
| * `unverifiable` because the fault is the FILE'S, and a reader must not be | ||
| * reassured about it — see the note on `signedKeyId` for who gets to choose | ||
| * which of those two a reader sees. | ||
| */ | ||
| function malformed(keyId, reason) { | ||
| return { valid: false, outcome: "malformed", keyId, reason }; | ||
| } | ||
| /** | ||
| * Which key the receipt says signed it — read ONLY from the signed object. | ||
| * | ||
| * ── WHY THE ENVELOPE IS NOT CONSULTED ─────────────────────────────────────── | ||
| * `keyId` sits inside the signed object (attestationKey.ts:117 — | ||
| * `SignedAttestationObject` requires it, and `buildSignedObject` always sets | ||
| * it), so it is covered by the signature. The copy in the receipt envelope is | ||
| * not covered by anything, and reading it here would hand an editor of the | ||
| * file the choice of which verdict the reader sees: strip the signed keyId, | ||
| * write a foreign one into the envelope, and the tool reports "this is not the | ||
| * key that signed it" — a reassuring sentence, chosen by the person who | ||
| * altered the document. | ||
| * | ||
| * The envelope copy is still fine for *selecting* a key to try, because a bad | ||
| * selection can only end in a non-passing state. It is not fine for deciding | ||
| * what to tell the reader. | ||
| */ | ||
| function signedKeyId(so) { | ||
| return typeof so.keyId === "string" ? so.keyId : undefined; | ||
| } | ||
| /** | ||
| * Does this object present itself as a Graneth attestation? A genuine v1 always | ||
| * carries version, algorithm AND keyId together; an object claiming the first | ||
| * two while missing the third has had a signed field removed. | ||
| */ | ||
| function claimsToBeAnAttestation(so) { | ||
| return so.version !== undefined || so.algorithm !== undefined; | ||
| } | ||
| /** | ||
| * Everything a document must be before running cryptography on it is worth the | ||
| * electricity. Split out of verifyReceipt so the accusing branch is the only | ||
| * thing left in that function that decides an accusation. | ||
| */ | ||
| function prepare(receipt, keyId) { | ||
| const defect = shapeDefect(receipt, keyId); | ||
| if (defect) | ||
| return defect; | ||
| const r = receipt; | ||
| const signedObject = r.attestation; | ||
| const so = signedObject; | ||
| const declaredAlgorithm = r.algorithm ?? so.algorithm; | ||
| if (declaredAlgorithm && declaredAlgorithm !== "ed25519") { | ||
| return unverifiable(keyId, `this build only checks ed25519, and the receipt declares ${declaredAlgorithm}`); | ||
| } | ||
| const declaredKeyId = signedKeyId(so); | ||
| if (declaredKeyId && declaredKeyId !== keyId) { | ||
| return unverifiable(keyId, `this is not the key that signed the receipt — it was signed with keyId ${declaredKeyId}, and you supplied ${keyId}. ` + | ||
| "Download the current key document, which also lists retired keys."); | ||
| } | ||
| return { signedObject, signature: r.signature, declaredKeyId }; | ||
| } | ||
| /** | ||
| * The ways a file can fail to be a receipt at all. Every one of these is the | ||
| * DOCUMENT's defect, so none of them may be reported as "we could not check". | ||
| */ | ||
| function shapeDefect(receipt, keyId) { | ||
| if (!receipt || typeof receipt !== "object") { | ||
| return malformed(keyId, "this is not a JSON object, so it is not a receipt"); | ||
| } | ||
| const r = receipt; | ||
| const signedObject = r.attestation; | ||
| if (!signedObject || typeof signedObject !== "object") { | ||
| return malformed(keyId, "there is no signed `attestation` object here — this is not a Graneth receipt"); | ||
| } | ||
| if (typeof r.signature !== "string" || r.signature.length === 0) { | ||
| return malformed(keyId, "this document carries no `signature` — it has never been signed by anyone"); | ||
| } | ||
| const so = signedObject; | ||
| if (!signedKeyId(so) && claimsToBeAnAttestation(so)) { | ||
| return malformed(keyId, "the signed object declares a version and an algorithm but names no keyId. Every receipt Graneth issues " + | ||
| "names its signing key INSIDE the signature — so this field has been removed since it was issued"); | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Verify a Graneth governance receipt against a public key, entirely offline. | ||
@@ -75,24 +168,8 @@ * | ||
| catch (err) { | ||
| return fail("", `invalid public key PEM: ${err.message}`); | ||
| return unverifiable("", `the public key you supplied is not usable: ${err.message}`); | ||
| } | ||
| if (!receipt || typeof receipt !== "object") { | ||
| return fail(keyId, "receipt is not a JSON object"); | ||
| } | ||
| const r = receipt; | ||
| const signedObject = r.attestation; | ||
| if (!signedObject || typeof signedObject !== "object") { | ||
| return fail(keyId, "receipt is missing the signed `attestation` object"); | ||
| } | ||
| const signature = r.signature; | ||
| if (typeof signature !== "string" || signature.length === 0) { | ||
| return fail(keyId, "receipt is missing the `signature` field"); | ||
| } | ||
| const so = signedObject; | ||
| const declaredAlgorithm = r.algorithm ?? so.algorithm; | ||
| if (declaredAlgorithm && declaredAlgorithm !== "ed25519") { | ||
| return fail(keyId, `unsupported algorithm: ${declaredAlgorithm}`); | ||
| } | ||
| if (typeof so.keyId === "string" && so.keyId !== keyId) { | ||
| return fail(keyId, `public key does not match — receipt was signed with keyId ${so.keyId}, supplied key is ${keyId}`); | ||
| } | ||
| const prepared = prepare(receipt, keyId); | ||
| if ("outcome" in prepared) | ||
| return prepared; | ||
| const { signedObject, signature, declaredKeyId } = prepared; | ||
| const canonical = canonicalize(signedObject); | ||
@@ -104,8 +181,21 @@ let signatureOk = false; | ||
| catch (err) { | ||
| return fail(keyId, `verification threw: ${err.message}`); | ||
| return unverifiable(keyId, `the check could not be run: ${err.message}`); | ||
| } | ||
| if (!signatureOk) { | ||
| return fail(keyId, "Ed25519 signature does not verify against the supplied public key — receipt is forged or tampered"); | ||
| /** | ||
| * A mismatch means one of two things, and only a declared keyId tells them | ||
| * apart: the bytes changed after signing, or this is simply the wrong key. | ||
| * | ||
| * Reaching here without one means the object does not even claim to be an | ||
| * attestation (the claiming-but-nameless case is malformed, above). It is | ||
| * still the document's defect, not ours — an unsigned-by-anyone blob whose | ||
| * signature does not verify is not something we "could not check". | ||
| */ | ||
| if (!declaredKeyId) { | ||
| return malformed(keyId, "the signature does not match, and this document names no signing key at all — " + | ||
| "there is nothing here that could be attributed to Graneth"); | ||
| } | ||
| return mismatch(keyId, "the signed bytes are not the bytes in this file — it was altered after it was signed"); | ||
| } | ||
| return { valid: true, keyId }; | ||
| return { valid: true, outcome: "valid", keyId }; | ||
| } |
+1
-1
| { | ||
| "name": "@graneth/verify", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "description": "Offline verifier for Graneth's signed governance receipts — checks the Ed25519 signature against a public key you already have. Zero network calls, zero runtime dependencies.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+59
-4
@@ -18,8 +18,60 @@ # @graneth/verify | ||
| `--key` accepts either that key document or a raw PEM public key. Exits 0 on a | ||
| valid signature, 1 otherwise, with a human-readable reason. | ||
| `--key` accepts either that key document or a raw PEM public key. | ||
| ## Four outcomes, not two | ||
| | Exit | Word | What it means | | ||
| | --- | --- | --- | | ||
| | `0` | `PASS` | The signature was checked, and it is valid. | | ||
| | `1` | `FAIL` | The signature was checked, and the file does not match it — the document was altered after it was signed. | | ||
| | `2` | `UNCHECKED` | Nothing was checked, and the obstacle is on **your** side: a missing or wrong key, an unreadable file, a mistake in the command. This one says nothing about the receipt. | | ||
| | `3` | `MALFORMED` | This is not a well-formed Graneth receipt — no signature, or a signed field removed since it was issued. Not evidence of anything. | | ||
| Only `0` means verified. | ||
| Forgetting `--key`, pointing at a key file that isn't there, or using a key | ||
| document downloaded before the signing key was rotated are all mistakes on the | ||
| *reader's* side — and a verifier that answers them with the same word it uses for | ||
| a forgery is telling the reader something false about a receipt that may be | ||
| perfectly good. | ||
| `2` and `3` are separated for the opposite reason, and the line between them is | ||
| load-bearing. `UNCHECKED` is the one state a reader is explicitly reassured | ||
| about, so anyone who can steer a document *into* it gets to choose the sentence | ||
| the reader sees. | ||
| The line is drawn once, by artifact rather than by case: | ||
| > **Before** the tool holds bytes claimed to be a receipt — no path, no file, an | ||
| > unreachable URL, a mistyped command — the obstacle is **yours**, and | ||
| > `UNCHECKED` is honest. **Once** it holds those bytes, a defect *in* them is the | ||
| > **document's**, and it is `MALFORMED`. | ||
| Not because we can tell who damaged the bytes: because one corrupted byte is | ||
| indistinguishable, from in here, from an edited one — and the person editing a | ||
| receipt is the one who benefits from the doubt. The default side is the one | ||
| whose sentence is not a lie under either reading, and *"this is not a | ||
| well-formed Graneth receipt"* is true even when you simply pointed at the wrong | ||
| file. | ||
| Your copy of the **key** stays your artifact end to end, so every defect in it — | ||
| unreadable, not JSON, no matching key — is `UNCHECKED`. The same unparseable | ||
| file is therefore `MALFORMED` when passed as a receipt and `UNCHECKED` when | ||
| passed with `--key`. | ||
| Two consequences of the same rule: every receipt Graneth issues names its | ||
| signing key **inside** the signature, so a document declaring a version and an | ||
| algorithm but no `keyId` has had that field removed and is `MALFORMED`; and the | ||
| copy of `keyId` in the receipt envelope is never consulted for a verdict, | ||
| because no signature covers it. | ||
| `--help` also exits `2`, because exit `0` is reserved for a receipt that verified. | ||
| Programmatically, `verifyReceipt()` returns `outcome: "valid" | "mismatch" | | ||
| "malformed" | "unverifiable"` alongside the original `valid` boolean, which still | ||
| means exactly `outcome === "valid"`. | ||
| ## What a PASS tells you — and what it does not | ||
| ``` | ||
| ```text | ||
| PASS — Ed25519 signature is valid. | ||
@@ -35,3 +87,3 @@ scan #4821 acme-corp/payments-api | ||
| ``` | ||
| ```text | ||
| PASS — Ed25519 signature is valid. | ||
@@ -47,2 +99,5 @@ scan #4821 | ||
| `NOT ATTESTED` is a different axis from PASS/FAIL/UNCHECKED: it describes what a | ||
| *valid* signature covers, not whether a check happened. | ||
| Programmatic use: `import { verifyReceipt } from "@graneth/verify"`. | ||
@@ -49,0 +104,0 @@ |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
41908
91.05%745
77.8%105
110%4
33.33%