---
title: 'Build Artifact Signing and Verification'
source: 'https://academia.sh/en/courses/ci-cd/build-artifact-signing-and-verification'
course: 'Continuous Integration and Delivery'
language: en
updated: '2026-09-13T05:08:19+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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

```js
// 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("component-3@2.3", "component-3@1.4"));
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.

```js
// 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.

```js
// 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?
