Lesson 20 / 21
Build Artifact Signing and Verification
The chain running from the source commit to the deployed output is built with a real key pair, and two questions are turned into numbers: however many forged outputs get through depends on the point in the chain where verification happens, and how many subjects with access to that key can sign.
Published Updated
In this lesson
- Builds the four-link chain and measures separately what each check catches at each link
- Moves verification's location and counts how many forged outputs get through and how many valid deployments get stopped
- Measures how the number of subjects with access to the key bounds the assurance the signature gives
Contents
The previous lesson produced the inventory and ended with a gap: there was no link at all between the 107-record file and the output it describes. The same gap exists at every link in the chain. Between the source commit the pipeline reads and the build output it produces, between that output and the bytes written to the repository, between the bytes in the repository and what gets deployed — at every handoff the object changes hands, and there is no proof that it changed hands.
This lesson builds that link and turns two questions into numbers: however many forged outputs get through depends on the point in the chain where verification happens, and how many subjects can produce the signature. M22/K02 built the signing chain inside the repository and counted the path an unverified link opens up; that setup is not repeated here. The axis here is where verification happens and who holds the key.
RD25. Signing is done with a real key pair, but the key is regenerated on every run; what gets printed is the verification result, not the signature itself. No real key or secret is written. RD26. The chain is four links: source commit, build output, inventory, and statement; the statement binds three digests, the signature binds the statement.
The Chain’s Four Links
// chain.mjs — source commit, build output, inventory, statement: the chain is built and tested import { generateKeyPairSync, sign, verify, createHash } from "node:crypto"; const digest = (b) => createHash("sha256").update(b).digest(); const short = (b) => digest(b).toString("hex").slice(0, 12); // Fictional key pair regenerated on every run; no real key or secret is written. const { publicKey: pub, privateKey: priv } = generateKeyPairSync("ed25519"); const { privateKey: otherPriv } = generateKeyPairSync("ed25519"); // Link 1: source commit — the tree contents are hashed. Link 2: build output. Link 3: inventory. const source = Buffer.from(["app.js", "config.json", "measure.js"] .map((y) => `${y}\n// fictional regional measurement network\n`).join("")); const output = Buffer.concat([Buffer.from("body\n"), Buffer.alloc(24576, "u")]); const inventory = Buffer.from( // previous lesson's inventory, 107 records Array.from({ length: 107 }, (_, i) => `component-${i}@2.3`).join("\n")); // Link 4: the statement binds three digests; the signature binds the statement. const writeStatement = (version, src, out, inv) => Buffer.from([`service=collector`, `version=${version}`, `source=${short(src)}`, `output=${short(out)}`, `inventory=${short(inv)}`].join("\n")); const STATEMENT = writeStatement(41, source, output, inventory); const SIGNATURE = sign(null, digest(STATEMENT), priv); console.log(`statement ${STATEMENT.length} bytes, signature ${SIGNATURE.length} bytes; ` + `output ${output.length} bytes, inventory ${inventory.length} bytes`); // The verifier runs three separate checks: signature, output digest, inventory digest. const check = (b, i, pk, out, inv) => ({ signature: verify(null, digest(b), pk, i), output: b.includes(`output=${short(out)}`), inventory: b.includes(`inventory=${short(inv)}`), }); const corruptedOutput = Buffer.from(output); corruptedOutput[9000] = 0x41; // one byte const corruptedInventory = Buffer.from(inventory.toString().replace("[email protected]", "[email protected]")); const SCENARIO = [ ["unmodified", STATEMENT, SIGNATURE, pub, output, inventory], ["version changed in statement", writeStatement(39, source, output, inventory), SIGNATURE, pub, output, inventory], ["output's single byte changed", STATEMENT, SIGNATURE, pub, corruptedOutput, inventory], ["inventory changed", STATEMENT, SIGNATURE, pub, output, corruptedInventory], ["signed with another key", STATEMENT, sign(null, digest(STATEMENT), otherPriv), pub, output, inventory], ]; const COLUMN = [31, 11, 15, 19, 0]; const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join("")); console.log(""); row(["scenario", "signature", "output digest", "inventory digest", "accepted"]); for (const [name, b, i, pk, out, inv] of SCENARIO) { const s = check(b, i, pk, out, inv); row([name, s.signature, s.output, s.inventory, s.signature && s.output && s.inventory]); }
statement 91 bytes, signature 64 bytes; output 24581 bytes, inventory 1815 bytes scenario signature output digest inventory digest accepted unmodified true true true true version changed in statement false true true false output's single byte changed true false true false inventory changed true true false false signed with another key false true true false
The link’s cost is two items: a 91-byte statement and a 64-byte signature. What they bind is a 24,581-byte output and a 1,815-byte inventory; 155 bytes nails down 26 kilobytes.
The table shows exactly where the chain comes apart. Each of the three checks catches exactly one scenario, and the others miss that scenario. The signature check fails when the version in the statement is changed and when the signature comes from another key — it catches two of the four forgeries. But when a single byte of the output changes, the signature is still valid: the statement is not corrupted, what is corrupted is what the statement describes. The same holds when a single version number in the inventory is changed — the signature still verifies.
This is the gate’s marginal value applied inside the chain. When the signature check is used alone, two of the four forgeries get through. The output-digest check catches only its own scenario, the inventory-digest check only its own. All three together hold all four, and the cost of that is three digest computations.
One detail determines all three checks: two of them require the object itself. The signature can be tested with the statement alone, but the output digest requires re-reading the output’s bytes, and the inventory digest requires re-reading the inventory’s bytes. A verifier that does not have the object in hand cannot perform those two checks — and the question of where verification happens comes exactly from this.
Where Verification Happens
RD27. The deployment attempts are fictional; every attempt is genuinely signed, forgery is
applied after signing, and seed 31071 is visible. RD28. After the thirtieth attempt the
key is rotated and the service’s authorized subject changes.
// deployment.mjs — however many forged outputs get through depends on where verification happens import { generateKeyPairSync, sign, verify, createHash } from "node:crypto"; const digest = (b) => createHash("sha256").update(b).digest(); const short = (b) => digest(b).toString("hex").slice(0, 12); const A = generateKeyPairSync("ed25519"); // the service's first authorized subject const B = generateKeyPairSync("ed25519"); // trusted, but unauthorized for this service const C = generateKeyPairSync("ed25519"); // the authorized subject after rotation const der = (k) => k.export({ type: "spki", format: "der" }).toString("base64"); const TRUSTED = [A, B, C].map((k) => k.publicKey); // keys the verifier recognizes const generator = (seed) => () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; const r = generator(31071); // seed visible const FORGERY = ["output tampered in the repository", "inventory tampered", "another subject's signature", "redeployment of an old version"]; // Fictional deployment attempts: each is genuinely signed, forgery is applied AFTER signing. // Key rotated after attempt 30: the authorized subject is C, not A. const ATTEMPT = []; for (let i = 0, version = 40; i < 60; i++) { const kind = r() < 0.6 ? "valid" : FORGERY[Math.floor(r() * FORGERY.length)]; const next = kind === "redeployment of an old version" ? version - 3 : ++version; let output = Buffer.alloc(4096, String.fromCharCode(97 + (i % 26))); let inv = Buffer.from(`inventory-${i}\n` + "record\n".repeat(107)); const signer = kind === "another subject's signature" ? B : (i < 30 ? A : C); const statement = Buffer.from(`service=collector\nversion=${next}\n` + `output=${short(output)}\ninventory=${short(inv)}`); const signature = sign(null, digest(statement), signer.privateKey); if (kind === "output tampered in the repository") output = Buffer.alloc(4096, "z"); if (kind === "inventory tampered") inv = Buffer.concat([inv, Buffer.from("extra\n")]); ATTEMPT.push({ kind, version: next, output, inv, statement, signature, pub: signer.publicKey }); } // Five disciplines: the chain is the same, what changes is WHERE verification happens and the subject list. const STALE = [A].map((k) => der(k.publicKey)), CURRENT = [A, C].map((k) => der(k.publicKey)); const DISCIPLINE = [ ["no verification", {}], ["only at pipeline end", { end: 1 }], ["at deploy time: signature and digests", { signature: 1, digest: 1 }], ["+ subject list (stale)", { signature: 1, digest: 1, subject: STALE, fresh: 1 }], ["+ subject list (current)", { signature: 1, digest: 1, subject: CURRENT, fresh: 1 }], ]; const FORGED_COUNT = ATTEMPT.filter((d) => d.kind !== "valid").length; const VALID_COUNT = ATTEMPT.length - FORGED_COUNT; const COLUMN = [40, 18, 18, 0]; const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join("")); row(["discipline", "forged accepted", "valid stopped", "forgery types that passed"]); for (const [name, k] of DISCIPLINE) { let lastVersion = 40, validPassed = 0; const passed = []; for (const d of ATTEMPT) { // Pipeline-end verification: the output was correct the moment it was produced; forgery applied later. if (k.signature && !TRUSTED.some((a) => verify(null, digest(d.statement), a, d.signature))) continue; if (k.digest && !(d.statement.includes(`output=${short(d.output)}`) && d.statement.includes(`inventory=${short(d.inv)}`))) continue; if (k.subject && !k.subject.includes(der(d.pub))) continue; if (k.fresh && d.version <= lastVersion) continue; lastVersion = Math.max(lastVersion, d.version); d.kind === "valid" ? validPassed++ : passed.push(d.kind); } row([name, `${passed.length}/${FORGED_COUNT}`, `${VALID_COUNT - validPassed}/${VALID_COUNT}`, [...new Set(passed)].map((t) => t.split(" ")[0]).join(", ") || "-"]); } console.log(""); console.log(`valid ${VALID_COUNT}; ` + FORGERY.map((t) => `${t.split(" ")[0]} ${ATTEMPT.filter((d) => d.kind === t).length}`).join(", "));
discipline forged accepted valid stopped forgery types that passed no verification 18/18 0/42 inventory, redeployment, another, output only at pipeline end 18/18 0/42 inventory, redeployment, another, output at deploy time: signature and digests 9/18 0/42 redeployment, another + subject list (stale) 0/18 18/42 - + subject list (current) 0/18 0/42 - valid 42; output 4, inventory 5, another 6, redeployment 3
The first two rows are this lesson’s harshest number: whether there is no verification at all, or verification happens only at the end of the pipeline, eighteen of the eighteen forgeries get through. The pipeline verifies the output it itself produced and finds it correct — because at that moment the output genuinely is correct. Forgery is applied after the signing. A gate that stands before the event it means to catch has a marginal value of zero, and that comes not from the gate being weak, but from it standing in the wrong place.
The third row moves verification to the moment of deployment, and the number is cut in half: 9 of 18 forgeries get through. The nine held are the output tampered in the repository (4) and the inventory tampered (5) — both are caught because the object’s bytes are re-read. The nine that get through are the ones whose signature is valid. In six of them the signature is from another subject’s valid key; the verifier says “a key I recognize signed this” and lets it through. In three, everything is genuine, only stale: a past version’s statement and signature still verify today.
The fourth row adds two rules — the signer must be the service’s authorized subject, the version must be greater than the last deployed one — and the forgery count falls to zero. But a second number appears in the same row: 18 of 42 valid deployments get stopped. The key was rotated after the thirtieth attempt, and the verifier’s subject list stayed on the old key. The gate holds all of the forgeries and also holds 43% of the valid deployments.
This writes the gate’s third number. Verification itself is a digest computation and a list lookup; the time it adds is negligible. What causes the wait is not verification, it is the freshness of the list: after the key rotates, every valid deployment stops until the list is updated. The behavior a gate that stops eighteen consecutive valid deployments produces in a team is a familiar one — the gate gets bypassed once, and the bypass becomes permanent. The last row runs the same chain with the current list: 0 forgeries, 0 wrongful stops.
In How Many Subjects Does the Key Sit
The fourth row leaves a question open: is “authorized subject” a singular thing? It is anyone who can produce the signature — anyone with access to the private key.
RD29. The storage models are fictional; the number of subjects with access to the key is the model’s input.
// key.mjs — the key itself is a gate: how many subjects can sign import { generateKeyPairSync, sign, verify, createHash } from "node:crypto"; const digest = (b) => createHash("sha256").update(b).digest(); const der = (k) => k.export({ type: "spki", format: "der" }).toString("base64"); const statement = (i) => Buffer.from(`service=collector\nversion=${100 + i}`); // Fictional storage models: whoever can reach where the key sits, that subject can sign. const STORAGE = [ ["private key in the repository", 23], ["environment variable on the pipeline runner", 11], ["signing service, three subjects with call authority", 3], ["in hardware, a single automated subject", 1], ]; const COLUMN = [55, 10, 8, 13, 0]; const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join("")); row(["storage", "subjects", "shared", "per-subject", "distinguishable"]); for (const [name, n] of STORAGE) { // (a) Shared single key: n subjects sign with the same private key. const singleKey = generateKeyPairSync("ed25519"); const sharedCount = Array.from({ length: n }, (_, i) => sign(null, digest(statement(i)), singleKey.privateKey)) .filter((im, i) => verify(null, digest(statement(i)), singleKey.publicKey, im)).length; // (b) Per-subject key: the authorized list is a single subject, the rest sign with their own keys. const keys = Array.from({ length: n }, () => generateKeyPairSync("ed25519")); const LIST = [der(keys[0].publicKey)]; const perSubjectCount = keys .map((a, i) => [a, sign(null, digest(statement(i)), a.privateKey), i]) .filter(([a, im, i]) => LIST.includes(der(a.publicKey)) && verify(null, digest(statement(i)), a.publicKey, im)).length; // Distinguishable signer: the number of distinct public keys the verifier sees across n signatures. const distinctSeen = (d) => new Set(d).size; row([name, n, `${sharedCount}/${n}`, `${perSubjectCount}/${n}`, `${distinctSeen(Array(n).fill(der(singleKey.publicKey)))} -> ${distinctSeen(keys.map((a) => der(a.publicKey)))}`]); }
storage subjects shared per-subject distinguishable private key in the repository 23 23/23 1/23 1 -> 23 environment variable on the pipeline runner 11 11/11 1/11 1 -> 11 signing service, three subjects with call authority 3 3/3 1/3 1 -> 3 in hardware, a single automated subject 1 1/1 1/1 1 -> 1
With a shared single key, every subject with access to the key produces a signature that verifies: 23 of 23, 11 of 11. The verifier can reject none of them, because they are all the same key. The last column turns this into a quantity — across 23 signatures, the verifier sees a single public key. There is no path from a signature to a signer; the sentence “the authorized subject signed this” is an unmeasurable sentence.
A per-subject key and a one-name authorized list make the accepted count independent of the number of subjects: acceptance is 1 whether there are 23, 11, or 3. The number of distinguishable signers, on the other hand, equals the number of subjects. This difference is achieved with the same algorithm, the same signature length; the only thing that changes is how many subjects the key sits in.
M22/K02 showed that signing does not eliminate trust, it carries it over to the key. The table here gives the magnitude of that carry-over. While the key sits in the repository, the signature says “one of the 23 subjects who can read the repository signed this”; while it sits in hardware, in a single automated subject, it says “that subject signed this.” The difference between the two sentences is a factor of 23, and, as with the previous lesson’s inventory coverage, the upper bound on the gate’s strength is set not by the gate itself, but by a number outside it.
Summary
- The chain is four links, and the cost of the bond is a 91-byte statement and a 64-byte signature; what they bind is a 24,581-byte output and a 1,815-byte inventory.
- Each of the three checks catches exactly one scenario: the signature check holds two of four forgeries, the output digest and the inventory digest each hold one of the remaining two. Two of them require re-reading the object’s bytes.
- With no verification, 18 of 18 forgeries get through; with verification only at pipeline end, 18 also get through, because forgery is applied after signing. A gate standing in the wrong place has a marginal value of zero.
- When signature and digests are checked at deploy time, 9 forgeries get through: 6 are another subject’s valid signature, 3 are the redeployment of a past version. With a subject list and a freshness rule, the forgery count falls to 0.
- A stale subject list holds all of the forgeries but also stops 18 of 42 valid deployments; what causes the wait is not verification, it is the freshness of the list.
- With a shared key, 23 of 23 subjects’ signatures are accepted and the verifier sees a single public key; with a per-subject key, acceptance falls to 1 and the number of distinguishable signers rises to 23.
Next Step
The chain is now built, tested at deployment time, and the key sits in a single subject. The measured numbers hold up to a point: 0 forgeries, 0 wrongful stops, a 155-byte bond. But that single subject is not a person, it is the pipeline itself — and every number in this section stands on the assumption that the pipeline runs honestly.
That assumption has never been counted so far. The workflow definition the pipeline runs comes from the repository, and how many subjects can change the repository? The pipeline runner accesses the signing key, the build-output repository, and the deployment target all at once; can a dependency pulled in by one stage use all of those accesses? The next lesson turns this course’s gates onto the machine that places the gates and asks the same three numbers for the pipeline itself: what does it hold, what does it miss, how long does it hold things up?
To keep your progress and take notes, Log in
My notes
Log in to take notes.