Skip to content
academia.sh

Lesson 07 / 11

Build Artifact Signing

A signature is produced and verified with a real key pair, and what it proves is separated from what it does not: how many of eight claims the signature proves, how many artifacts become suspect retroactively once a key is compromised, and how the signature's coverage and the verifier's testable field diverge as the declaration expands.

Published Updated

In this lesson

  • Tests eight claims against a real signature verification and counts how many the signature proves
  • Measures how many artifacts become retroactively suspect by log type once a key is compromised
  • Expands the declaration's fields and separates two numbers: the field the signature covers and the field that can be recomputed
Contents

The previous lesson generated the inventory and closed on one of its four unanswered questions: which build artifact does this inventory describe? The same gap is not confined to the inventory. The build artifact the pipeline produces is written to a registry, pulled from there, and distributed; at every handoff the object is assumed to have stayed the same, and there is no proof that it has.

This lesson produces that proof with a real key pair. In the Continuous Integration and Delivery course the signing chain was already built, and where in the pipeline verification happens and how many subjects hold the key were measured there; that measurement is not repeated here. The axis here is the claim: for an artifact that passes verification, which sentences can be said about it, and how many of them are sentences the signature actually rests on.

SC6. 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 ever written out. SC7. The set of objects in the verifier’s hands is three items: the declaration, the signature, and the build artifact’s bytes. The source tree, the build definition, and the inventory are not in its hands.

Eight Claims

// measurement-network/claims.mjs — what a signature proves and does not prove: eight claims are 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);
// Key pairs are regenerated on every run; no real key or secret is ever written out.
const AUTHORIZED = generateKeyPairSync("ed25519");
const OTHER = generateKeyPairSync("ed25519");    // recognized, but not an authorized subject for this service
const TRUSTED = [AUTHORIZED.publicKey, OTHER.publicKey];   // keys the verifier recognizes

const OUTPUT = Buffer.concat([Buffer.from("subscriber-portal body\n"), Buffer.alloc(32768, "u")]);
const writeDeclaration = (c) => Buffer.from(["service=subscriber-portal", `output=${short(c)}`].join("\n"));
const DECLARATION = writeDeclaration(OUTPUT);
const SIGNATURE = sign(null, digest(DECLARATION), AUTHORIZED.privateKey);

// The set of objects in the verifier's hands: declaration, signature, build artifact. No other object.
const check = (declaration, signature, output) =>
  TRUSTED.some((a) => verify(null, digest(declaration), a, signature)) &&
  declaration.includes(`output=${short(output)}`);

// One fictional scenario per claim: when verification passes, is the claim true?
const CLAIMS = [
  ["the declaration was signed with a recognized key", () => [check(DECLARATION, SIGNATURE, OUTPUT), true]],
  ["the declaration's bytes have not changed since signing",
    () => [check(Buffer.from(DECLARATION.toString().replace("subscriber", "meter")), SIGNATURE, OUTPUT), false]],
  ["the described output's bytes have not changed", () => {
    const tampered = Buffer.from(OUTPUT); tampered[12000] = 0x41;      // a single byte
    return [check(DECLARATION, SIGNATURE, tampered), false];
  }],
  ["the signer was authorized for this service", () =>
    [check(DECLARATION, sign(null, digest(DECLARATION), OTHER.privateKey), OUTPUT), false]],
  ["the output was produced from this source commit", () => [check(DECLARATION, SIGNATURE, OUTPUT), false]],
  ["the signature was produced on this date", () => [check(DECLARATION, SIGNATURE, OUTPUT), false]],
  ["the output passed the pipeline's checks", () => [check(DECLARATION, SIGNATURE, OUTPUT), false]],
  ["the components inside it are the ones in the inventory", () => [check(DECLARATION, SIGNATURE, OUTPUT), false]],
];

const S = [56, 14, 13, 0];
const row = (s) => console.log(s.map((v, i) => String(v).padEnd(S[i])).join(""));
row(["claim", "verification", "claim true", "does signature prove it"]);
let proven = 0;
for (const [name, run] of CLAIMS) {
  const [passes, isTrue] = run();
  const proves = passes === isTrue;            // does verification move in the same direction as the claim
  if (proves) proven++;
  row([name, passes ? "passes" : "fails", isTrue ? "yes" : "no",
    proves ? "yes" : "NO"]);
}
console.log("");
console.log(`declaration ${DECLARATION.length} bytes, signature ${SIGNATURE.length} bytes, output ${OUTPUT.length} bytes`);
console.log(`${proven} of eight claims are proven by the signature, ${CLAIMS.length - proven} ` +
  `are not proven: verification passes, claim false`);
claim                                                   verification  claim true   does signature prove it
the declaration was signed with a recognized key        passes        yes          yes
the declaration's bytes have not changed since signing  fails         no           yes
the described output's bytes have not changed           fails         no           yes
the signer was authorized for this service              passes        no           NO
the output was produced from this source commit         passes        no           NO
the signature was produced on this date                 passes        no           NO
the output passed the pipeline's checks                 passes        no           NO
the components inside it are the ones in the inventory  passes        no           NO

declaration 45 bytes, signature 64 bytes, output 32791 bytes
3 of eight claims are proven by the signature, 5 are not proven: verification passes, claim false

The first three rows give the real work a signature does. When verification passes, three sentences are certain: the declaration was signed with a recognized key, the declaration’s bytes have not changed since signing, and the bytes of the output the declaration binds by digest have not changed. The first two are tested with the 45-byte declaration, the third by rereading the 32,791-byte output. These are the three sentences the signature proves.

In the remaining five rows verification passes and the claim is false in every case. In the fourth row the declaration is signed with a valid key, but the signer is not this service’s authorized subject; the verifier says “a key I recognize” and passes it — authorization is a decision the key list carries, not the signature. In the fifth, sixth, seventh, and eighth rows the declaration never makes those sentences at all: not one word about the source commit, the moment the signature was produced, the pipeline’s checks, or the components inside the output appears in the 45-byte declaration. Verification passes because there is no field for verification to look at.

This distinction comes down to one sentence: the signature says “this output was signed with this key,” not “this output is safe.” Whether the second sentence is true has nothing to do with the signature; it depends on what is written in the declaration underneath the signature, who filled that writing in, and whether the verifier can test that field at all.

The cost of the three proven sentences is not equal either. The first two are tested with the declaration alone: a 45-byte declaration and a 64-byte signature, 109 bytes read in total. The third requires rereading the output’s bytes — 32,791 bytes, that is, 301 times as much. This ratio determines where verification can be done at all: a verifier that does not have the object in hand can never make the third sentence, and a gate that holds only the declaration lets through an output that is verified yet has a single byte changed.

When the Key Is Compromised

Even the three sentences a signature proves depend on one condition: that only the authorized subject could use the private key. Once that condition fails, the number to ask is this — how many outputs become suspect?

SC8. A hundred and twenty fictional pipeline runs each produce and sign one output; the subject who compromises the key signs four more outputs, and none of those four is a pipeline run. SC9. The append-only transparency log digests every entry together with the previous entry’s chain digest; inserting an entry into the past breaks the chain.

// measurement-network/key-compromise.mjs — how many outputs become suspect once a key is compromised
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 KEY = generateKeyPairSync("ed25519");   // regenerated on every run

// Fiction: a hundred and twenty pipeline runs, each producing and signing one output. The run ID is recorded in the declaration.
const RUNS = 120, FORGED = 4;
const makeRecord = (no, id) => {
  const output = Buffer.alloc(4096, String.fromCharCode(97 + (no % 26)));
  const declaration = Buffer.from(`service=subscriber-portal\nrun=${id}\noutput=${short(output)}`);
  return { no, id, declaration, signature: sign(null, digest(declaration), KEY.privateKey) };
};
const real = Array.from({ length: RUNS }, (_, i) => makeRecord(i + 1, `r-${i + 1}`));

// The subject who compromises the key signs four outputs; none of them is a pipeline run.
const forged = Array.from({ length: FORGED }, (_, i) => makeRecord(1000 + i, `x-${i + 1}`));

// Append-only transparency log: every entry also digests the previous chain digest.
const chainAll = (records) => records.reduce((z, k) =>
  [...z, digest(Buffer.concat([z.at(-1) ?? Buffer.alloc(32), k.declaration, k.signature]))], []);
const CHAIN = chainAll(real);

// Three log types. Forged records are inserted into the plain list, and into the same spot in the chain.
const inserted = [...real.slice(0, 60), ...forged, ...real.slice(60)];
const brokenIndex = chainAll(inserted).findIndex((z, i) => i < CHAIN.length && !z.equals(CHAIN[i]));
const passed = [...real, ...forged]
  .filter((k) => verify(null, digest(k.declaration), KEY.publicKey, k.signature)).length;

const S = [34, 19, 14, 0];
const row = (s) => console.log(s.map((v, i) => String(v).padEnd(S[i])).join(""));
row(["log type", "pass verification", "insertion", "suspect outputs"]);
row(["no log, signature only", passed, "not seen", `${passed}/${passed}`]);
row(["plain run list", passed, "not seen", `${passed}/${passed}`]);
row(["chained transparency log", passed,
  brokenIndex >= 0 ? `index ${brokenIndex}` : "not seen", `${FORGED}/${passed}`]);

// The chain freezes history: an entry inserted into the past yields a break, one appended at the end cannot hide.
console.log("");
console.log(`signed outputs ${real.length} + ${forged.length} = ${passed}; ` +
  `all with the same key, all pass verification`);
console.log(`chain ${CHAIN.length} entries; the ${FORGED} entries inserted into the past ` +
  `break the chain at index ${brokenIndex}, leaving no trace in the plain list`);
console.log(`retroactive suspicion: ${passed} outputs with no log, ${FORGED} outputs with a chained log`);
log type                          pass verification  insertion     suspect outputs
no log, signature only            124                not seen      124/124
plain run list                    124                not seen      124/124
chained transparency log          124                index 60      4/124

signed outputs 120 + 4 = 124; all with the same key, all pass verification
chain 120 entries; the 4 entries inserted into the past break the chain at index 60, leaving no trace in the plain list
retroactive suspicion: 124 outputs with no log, 4 outputs with a chained log

The first column is identical across all three rows, and it is the harshest number in the lesson: 124 of 124 outputs pass verification. Four forged outputs are signed with the real key; there is nothing that lets the verifier tell them apart, because the signature only says “this was signed with this key,” and that is true.

The difference shows up in the last column. In a log-free setup, once the key compromise is discovered there is no way to know which signature was produced when; no output can be given the sentence “this was signed before the compromise,” and all 124 of 124 outputs become suspect. This is the real cost of keeping no log: the loss is not in the signature itself, but in the size of the work that has to follow it.

A plain run list does not change this. A list is an ordinary file; four entries are inserted and no trace remains — the list now shows all four as if they were pipeline runs too. In the chained transparency log the same insertion breaks the chain at index 60. What the chain does is not prevent signing — it cannot do that either. What it does is freeze history: a signature produced afterward can only be appended at the end, never placed into the past. The number of suspect outputs drops from 124 to 4, and those four are the unmatched entries at the end of the log.

The gap between the two numbers is not about detection, it is about the scope of the suspicion. In both setups the same four forged outputs are produced, and in both, verification lets them through. What changes is the work to be done once the incident is understood: in the log-free setup, all 124 outputs must be rebuilt and re-signed; in the chained log, only 4. This is where the time signing adds to the pipeline actually gets paid back — signing costs a few seconds per run, keeping no log costs a rebuild of 120 outputs in a single incident.

When the Declaration Expands

Four of the five unproven claims came from those fields not being in the declaration at all. Is adding the fields enough?

SC10. The declaration is expanded to nine fields and four fields are deliberately given a wrong value; the declaration is genuinely signed. The only object in the verifier’s hands is still the build artifact.

// measurement-network/declaration-expansion.mjs — as the declaration expands, signature coverage and testability diverge
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");
const OUTPUT = Buffer.concat([Buffer.from("subscriber-portal body\n"), Buffer.alloc(32768, "u")]);

// The verifier holds only the build artifact; the source tree, build definition, and inventory are not in its hands.
const ON_HAND = { output: OUTPUT };
// Nine fields. `correct` is the field's real value, `written` is the value put in the declaration.
const FIELDS = [
  ["service", "subscriber-portal", "subscriber-portal"],
  ["output", short(OUTPUT), short(Buffer.alloc(8, "z"))],        // deliberately wrong
  ["source", "9f31c0aa4b12", "0000deadbeef"],                    // deliberately wrong
  ["build-definition", "51ba7c0e93d4", "51ba7c0e93d4"],
  ["inventory", "c4e0a71f2b88", "c4e0a71f2b88"],
  ["base-image", "7ad3e1c95006", "111122223333"],                // deliberately wrong
  ["run", "r-118", "r-118"],
  ["time", "build-start", "build-start"],
  ["checks", "two findings open", "no findings"],                // deliberately wrong
];

const declaration = Buffer.from(FIELDS.map(([a, , w]) => `${a}=${w}`).join("\n"));
const signature = sign(null, digest(declaration), A.privateKey);
const sigPasses = verify(null, digest(declaration), A.publicKey, signature);

const S = [17, 23, 24, 0];
const row = (s) => console.log(s.map((v, i) => String(v).padEnd(S[i])).join(""));
row(["field", "value in declaration", "recomputable", "caught"]);
let testable = 0, wrong = 0, caught = 0;
for (const [name, correct, written] of FIELDS) {
  // A field can only be tested if the verifier can recompute it from an object it holds.
  const computable = name in ON_HAND;
  const isCorrect = correct === written;
  if (computable) testable++;
  if (!isCorrect) wrong++;
  const catches = computable && !isCorrect && short(ON_HAND[name]) !== written;
  if (catches) caught++;
  row([name, isCorrect ? "correct" : "WRONG", computable ? "yes" : "no",
    catches ? "yes" : (isCorrect ? "-" : "NO")]);
}
console.log("");
console.log(`declaration ${FIELDS.length} fields, ${declaration.length} bytes; signature verification ` +
  `${sigPasses ? "passes" : "fails"}`);
console.log(`field covered by signature ${FIELDS.length}/${FIELDS.length}, ` +
  `field the verifier can test ${testable}/${FIELDS.length}`);
console.log(`of ${wrong} fields deliberately written wrong, ${caught} ${caught === 1 ? "is" : "are"} caught, ` +
  `${wrong - caught} pass through signed along with the declaration`);
field            value in declaration   recomputable            caught
service          correct                no                      -
output           WRONG                  yes                     yes
source           WRONG                  no                      NO
build-definition correct                no                      -
inventory        correct                no                      -
base-image       WRONG                  no                      NO
run              correct                no                      -
time             correct                no                      -
checks           WRONG                  no                      NO

declaration 9 fields, 188 bytes; signature verification passes
field covered by signature 9/9, field the verifier can test 1/9
of 4 fields deliberately written wrong, 1 is caught, 3 pass through signed along with the declaration

The declaration grew from two fields to nine, and the field covered by the signature became 9/9: none of the nine can be changed after signing. The field the verifier can test stayed at 1/9. That gap is the lesson’s final distinction: signature coverage and testability are two separate quantities. The first is a decision made by the party writing the declaration, the second is a property of the set of objects the verifier holds.

The last row is the consequence of this. Four fields are deliberately written wrong; only one — the output digest — is caught, because the verifier can read the output’s bytes and recompute it. The other three are not caught, and worse, they pass through signed. A signed field looks more trustworthy than an unsigned one; but the signature only says “this value has not been changed since,” not “this value was written correctly.”

Summary

  • For an artifact that passes verification, 3 of eight claims are proven by the signature: the declaration was signed with a recognized key, the declaration’s bytes have not changed, and the bytes of the output bound by digest have not changed.
  • In the remaining 5 claims verification passes and the claim is false: authorization is a decision of the key list, and the source commit, the signing moment, the check result, and the component list never appear in the 45-byte declaration at all.
  • Once the key is compromised, 124 of 124 outputs pass verification; in a log-free setup none of them can be given the sentence “this was signed before the compromise,” and all 124 become suspect.
  • Four entries are inserted into a plain run list without a trace; the same insertion into a chained transparency log breaks the chain at index 60 and drops suspect outputs to 4. The chain does not prevent signing, it freezes history.
  • Once the declaration expands to nine fields, the field covered by the signature is 9/9 and the field the verifier can test is 1/9; three of the four deliberately wrong fields pass through signed.

Next Step

The signature is now in place, and what it proves and does not prove is written down. The last measurement left one question open: if the verifier cannot test eight of nine fields, what is the point of writing those eight fields into the declaration at all?

The answer lies not in the fields themselves, but in who fills them in and what they can be compared against. If a field’s value can be recomputed from an object the verifier can independently obtain, that field is a verifiable field; if it cannot, it is an attested field, and the attested field’s correctness comes down to trusting whoever wrote it. The next lesson builds, field by field, the document that describes where and how the artifact was built, and extracts a single number from it: how many of this document’s fields are independently verifiable, and how many remain only attested?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close