From 35aa0d1474121ecb511d2543de9a928a54702bc0 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 17:16:04 -0400 Subject: [PATCH 1/7] docs: propose committed signing-anchor layout Record where fingerprint pins live (keys/) and that they must enter the signed checksum set the same way release notes do. Changes: - Add PDR-0001 (proposed) - Index the record in docs/decisions/README.md Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- .../PDR-0001-committed-signing-anchors.md | 150 ++++++++++++++++++ docs/decisions/README.md | 12 +- 2 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/PDR-0001-committed-signing-anchors.md diff --git a/docs/decisions/PDR-0001-committed-signing-anchors.md b/docs/decisions/PDR-0001-committed-signing-anchors.md new file mode 100644 index 0000000..1527fc9 --- /dev/null +++ b/docs/decisions/PDR-0001-committed-signing-anchors.md @@ -0,0 +1,150 @@ +--- +id: PDR-0001 +title: Committed signing anchors and signed-payload inclusion +status: Proposed +date: 2026-08-18 +deciders: + - cxotech + - "@3leapsdave" +relates-to: + - ddr-0001-fingerprint-record-contract.md + - crucible ADR-0003 (taxonomy: PDR = revisable ways-of-working) +--- + +# PDR-0001 — Committed signing anchors and signed-payload inclusion + +**Status: Proposed.** Process for *where* Decernor writes release-signing +identity and *how* that file becomes part of a signed GitHub release. + +This is a **PDR**, not an EPR or DDR: + +- **Not EPR.** "Never hand-type hex" is the durable rule, but *where the + file lives* and *which sibling files we keep* are revisable. +- **Not DDR.** Record shape is already [DDR-0001](ddr-0001-fingerprint-record-contract.md). + This record is the ceremony and layout. +- **Not ADR.** No new runtime component. + +## What lanytehq asked for, and what we locked + +CHAN-TASK-010 stood down because `fingerprint` did not emit one GPG +contract value. DEC-011 locked the emitter: + +- GPG contract = sole `openpgp-fingerprint-v1` with `key_role=primary` + on that named public file (uppercase 40-hex). +- Minisign contract = `minisign-public-blob-sha256-v1` **lowercase + 64-hex in the record**. Verifiers copy that field; they do not run + `xxd | head -c 20`. +- Inserter maps `decernor fingerprint` JSON/NDJSON → pin file. No + `gpg --show-keys`, no hand-typed hex. + +lanytehq's consumer path is `keys/expected-fingerprints.txt` plus +`verify-public-keys.sh`. We dogfood **that** path here first. + +## Where the material is written + +**In the git tree (the pin, not the keys):** + +```text +keys/ + README.md + expected-fingerprints.ndjson # decernor stdout (receipt) + expected-fingerprints.txt # verifier contract (derived) +``` + +| File | Role | +|------|------| +| `expected-fingerprints.ndjson` | Exact stdout of `decernor fingerprint` on the **exported public files** (`--format ndjson --path-mode none --class public`). GPG run uses `--kind gpg --gpg-role primary`. Minisign run uses `--kind minisign` and keeps the `minisign-public-blob-sha256-v1` record (not the key-id record). Schema-valid DDR-0001 records. | +| `expected-fingerprints.txt` | Two lines, whitespace-separated: `gpg <40-hex>` and `minisign <64-hex>`. Values are **copied verbatim** from those records. This is what `verify-public-keys.sh` compares. | +| `README.md` | Points at the inserter; does not restate hex. | + +**Never committed:** private keys, keystore trees, minisign secret +files, exported `.pub` / `.asc`, **or any filesystem path to those +things.** Public key **files** ride the GitHub release only (the in-repo +pin blesses the per-release pub, so the pub cannot vouch for itself). + +### Env vars only (hard rule) + +Scripts, Makefiles, workflows, and docs in this repo name **variable +identifiers**, never paths: + +- `DECERNOR_MINISIGN_PUB` / `DECERNOR_MINISIGN_KEY` +- `DECERNOR_GPG_HOMEDIR` / `DECERNOR_PGP_KEY_ID` + +The operator loads those from a **host-local** profile (outside this +tree). CI and `make` fail closed if a required variable is empty. No +`$HOME/…`, no `~/vault`, no checked-in `.env`. + +### Net-new payload file (existing workflows will not see it) + +gonimbus / sfetch / waitprims `release-checksums` today hash **binary +artifacts** (`${BINARY}-*`). They do **not** pick up a new pin file +unless we add a step. Release notes already have an explicit +`release-notes` copy; fingerprints need the same kind of **new** +target, for example `release-stage-anchors`, that: + +1. Generates or copies `expected-fingerprints.txt` and + `.ndjson` into `dist/release/` +2. Runs **before** `release-checksums` + +Do not assume "put the file in `dist/release/` and the old script will +hash it." Extend the checksum input set, or the pin stays unsigned +commentary. + +**Order:** + +1. Download/build binaries +2. `release-notes` (existing pattern) +3. **`release-stage-anchors`** (new) — pin files + exported pubs +4. `release-checksums` (must include the new files) +5. `release-sign` (minisign required; PGP optional) +6. Upload provenance with the draft + +A pin that is only in git and never in `SHA256SUMS` is commentary. A pin +that is in `SHA256SUMS` and then signed is the load-bearing artifact. + +## How insert actually works + +Required env (set in the operator shell, not in git): +`DECERNOR_GPG_HOMEDIR`, `DECERNOR_PGP_KEY_ID`, `DECERNOR_MINISIGN_PUB`. +Same org keyset as other 3leaps tools; only the prefix changes. + +```text +# 1. Export publics to a temp dir from those env vars +# (copy $DECERNOR_MINISIGN_PUB; gpg --homedir $DECERNOR_GPG_HOMEDIR +# --armor --export $DECERNOR_PGP_KEY_ID). +# Never walk a keystore. Never fingerprint a secret file or a homedir tree. + +# 2. Emit records (fail closed; both-or-none write). +decernor fingerprint "$ASC" --class public --kind gpg \ + --format ndjson --path-mode none --gpg-role primary +decernor fingerprint "$PUB" --class public --kind minisign \ + --format ndjson --path-mode none + +# 3. Write keys/*.ndjson from those records. +# Derive keys/*.txt from fingerprint fields (no second parser). +# Refuse if GPG count != 1 primary or minisign blob-SHA count != 1. + +# 4. verify-public-keys.sh: exported pubs match the txt lines. +# validate: each ndjson line against fingerprint-record.v0. +``` + +Rekey = new export + same script. Do not immortalize today's hex in +docs or commit messages. + +## Tag for the first signed cut + +Do **not** move `v0.1.2`. That tag already triggered CI and a draft. +The signed kit is a new cut: **`v0.1.3`**. + +## Consequences + +- lanytehq can copy `keys/` + inserter + "copy into dist before + checksums" without inventing a layout. +- `make release-verify-keys` asserts the pin, not "a pub file exists." +- Chanvoy CHAN-TASK-010 can re-arm against the same file names and + DEC-011 record filters. + +## Out of scope + +Public flip. Changing DDR-0001. synthcorpus pin move. Hand-maintained +hex in `RELEASE_NOTES.md`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 71babf9..2e564e3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -1,8 +1,10 @@ # Decernor Decision Records -Architecture (ADR), design (DDR), and security (SDR) decision records for decernor. +Types follow crucible ADR-0003 (`ADR` / `DDR` / `SecDR` / `PDR` / `EPR`). +Older records keep their original filenames. -| ID | Title | Status | Date | -| --------------------------------------------------- | ---------------------------------------------- | -------- | ---------- | -| [ADR-0001](adr-0001-symlink-policy.md) | Symlink Policy (named inputs + walk traversal) | Accepted | 2026-06-11 | -| [DDR-0001](ddr-0001-fingerprint-record-contract.md) | Fingerprint Record Contract | Accepted | 2026-06-11 | +| ID | Title | Status | Date | +| -- | ----- | ------ | ---- | +| [ADR-0001](adr-0001-symlink-policy.md) | Symlink policy | Accepted | 2026-06-11 | +| [DDR-0001](ddr-0001-fingerprint-record-contract.md) | Fingerprint record contract | Accepted | 2026-06-11 | +| [PDR-0001](PDR-0001-committed-signing-anchors.md) | Committed signing anchors + signed-payload inclusion | Proposed | 2026-08-18 | From 3f23c03c38f76a20f03fd0396d4c6c2ffbefd44e Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 17:22:08 -0400 Subject: [PATCH 2/7] chore: add signed-release ceremony for v0.1.3 Add a waitprims-style signing DAG and generate fingerprint pins from decernor records. Stage those pins into dist before checksums. Changes: - Add release-insert-anchors, release-stage-anchors, and signing targets - Commit keys/expected-fingerprints generated by decernor fingerprint - Bump VERSION and notes to 0.1.3 - Accept PDR-0001 Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- .fulmen/app.yaml | 2 +- CHANGELOG.md | 14 +++ Makefile | 79 +++++++++++++ RELEASE_CHECKLIST.md | 75 +++++-------- RELEASE_NOTES.md | 35 +++--- VERSION | 2 +- .../PDR-0001-committed-signing-anchors.md | 4 +- docs/decisions/README.md | 2 +- docs/releases/README.md | 3 +- docs/releases/v0.1.3.md | 13 +++ internal/assets/appidentity/app.yaml | 2 +- keys/README.md | 13 +++ keys/expected-fingerprints.ndjson | 2 + keys/expected-fingerprints.txt | 2 + scripts/download-release-assets.sh | 16 +++ scripts/export-release-keys.sh | 30 +++++ scripts/generate-checksums.sh | 58 ++++++++++ scripts/insert-expected-fingerprints.sh | 106 ++++++++++++++++++ scripts/release-guard-tag-version.sh | 24 ++++ scripts/sign-release-assets.sh | 52 +++++++++ scripts/stage-release-anchors.sh | 21 ++++ scripts/upload-release-assets.sh | 56 +++++++++ scripts/verify-public-keys.sh | 72 ++++++++++++ scripts/verify-signatures.sh | 49 ++++++++ 24 files changed, 655 insertions(+), 77 deletions(-) create mode 100644 docs/releases/v0.1.3.md create mode 100644 keys/README.md create mode 100644 keys/expected-fingerprints.ndjson create mode 100644 keys/expected-fingerprints.txt create mode 100755 scripts/download-release-assets.sh create mode 100755 scripts/export-release-keys.sh create mode 100755 scripts/generate-checksums.sh create mode 100755 scripts/insert-expected-fingerprints.sh create mode 100755 scripts/release-guard-tag-version.sh create mode 100755 scripts/sign-release-assets.sh create mode 100755 scripts/stage-release-anchors.sh create mode 100755 scripts/upload-release-assets.sh create mode 100755 scripts/verify-public-keys.sh create mode 100755 scripts/verify-signatures.sh diff --git a/.fulmen/app.yaml b/.fulmen/app.yaml index 26a4cc8..349a0f4 100644 --- a/.fulmen/app.yaml +++ b/.fulmen/app.yaml @@ -12,6 +12,6 @@ app: # Config file name (without extension); resolves to ~/.config/3leaps/decernor.yaml. config_name: decernor description: "Local key-material hygiene and readiness checks" - version: "0.1.2" + version: "0.1.3" metadata: repository_category: cli diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3c469..e8742d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.1.3] - 2026-08-18 + +First **signed** private cut. Fingerprint pins are generated by +`decernor fingerprint` and staged into the checksum set before signing +([PDR-0001](docs/decisions/PDR-0001-committed-signing-anchors.md)). + +### Added + +- Waitprims-style release DAG: download, notes, **stage-anchors**, + checksums, sign, export-keys, verify, upload. +- `make release-insert-anchors` writes `keys/expected-fingerprints.*` + from env-only bindings (no key paths in the tree). + ## [0.1.2] - 2026-08-18 First tagged snapshot of the Decernor CLI. Private repository; unsigned draft @@ -46,4 +59,5 @@ release is the intended publish shape for this cut. - Upstream baseline changelog and release-note history inherited from the template. +[0.1.3]: https://github.com/3leaps/decernor/releases/tag/v0.1.3 [0.1.2]: https://github.com/3leaps/decernor/releases/tag/v0.1.2 diff --git a/Makefile b/Makefile index 48aa8fc..064eca1 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ .PHONY: all help bootstrap bootstrap-force hooks-ensure tools sync dependencies verify-dependencies version version-set version-bump-major version-bump-minor version-bump-patch .PHONY: lint test build install build-all package package-sign verify-release-key clean fmt fmt-check check-all precommit prepush pr-final license-audit .PHONY: release-check release-prepare release-build release-preflight release-notes-check doctor validate-app-identity +.PHONY: release-clean release-download release-notes release-stage-anchors release-insert-anchors +.PHONY: release-checksums release-sign release-export-keys release-verify-checksums +.PHONY: release-verify-signatures release-verify-keys release-verify release-upload release +.PHONY: release-guard-tag-version .PHONY: sync-embedded-identity verify-embedded-identity test-standalone-binary cdrl-verify # Binary and version information @@ -10,6 +14,13 @@ ifeq ($(OS),Windows_NT) BINARY_EXT := .exe endif VERSION := $(shell cat VERSION 2>/dev/null || echo "dev") +DECERNOR_RELEASE_TAG ?= v$(VERSION) +export DECERNOR_RELEASE_TAG +DECERNOR_MINISIGN_KEY ?= +DECERNOR_MINISIGN_PUB ?= +DECERNOR_PGP_KEY_ID ?= +DECERNOR_GPG_HOMEDIR ?= +DIST_RELEASE := dist/release COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE) @@ -207,12 +218,80 @@ release-notes-check: ## Verify VERSION, identity yaml, and notes files for this echo "✅ Release notes check passed ($$V)" release-preflight: release-notes-check verify-embedded-identity fmt-check lint test ## Non-mutating tag gate + @if [ ! -f keys/expected-fingerprints.txt ] || [ ! -f keys/expected-fingerprints.ndjson ]; then \ + echo "❌ missing keys/expected-fingerprints.{txt,ndjson} — run make release-insert-anchors"; exit 1; \ + fi @echo "✅ Release preflight passed" release-check: release-preflight ## Alias for release-preflight release-prepare: release-preflight ## Alias for release-preflight +release-guard-tag-version: ## Verify DECERNOR_RELEASE_TAG matches VERSION + @./scripts/release-guard-tag-version.sh + +release-clean: ## Remove dist/release + rm -rf $(DIST_RELEASE) + @echo "[ok] $(DIST_RELEASE) cleaned" + +release-download: ## Download unsigned archives from GitHub + @if [ -z "$(DECERNOR_RELEASE_TAG)" ] || [ "$(DECERNOR_RELEASE_TAG)" = "v" ]; then \ + echo "error: set DECERNOR_RELEASE_TAG=vX.Y.Z" >&2; exit 2; \ + fi + @./scripts/download-release-assets.sh $(DECERNOR_RELEASE_TAG) $(DIST_RELEASE) + +release-notes: ## Copy docs/releases/vX.Y.Z.md into dist before checksums + @src="docs/releases/$(DECERNOR_RELEASE_TAG).md"; \ + if [ ! -f "$$src" ]; then echo "❌ missing $$src" >&2; exit 1; fi; \ + mkdir -p "$(DIST_RELEASE)"; \ + cp "$$src" "$(DIST_RELEASE)/release-notes-$(DECERNOR_RELEASE_TAG).md"; \ + echo "[ok] copied $$src into the checksum set" + +release-insert-anchors: ## Generate keys/ pins from DECERNOR_* env + decernor fingerprint + @./scripts/insert-expected-fingerprints.sh + +release-stage-anchors: ## Copy committed pins into dist before checksums (net-new) + @./scripts/stage-release-anchors.sh $(DIST_RELEASE) + +release-checksums: ## Generate SHA256SUMS and SHA512SUMS (archives + notes + pins) + @./scripts/generate-checksums.sh $(DIST_RELEASE) $(DECERNOR_RELEASE_TAG) + +release-sign: ## Sign checksum manifests (requires DECERNOR_MINISIGN_KEY) + @if [ -z "$(DECERNOR_MINISIGN_KEY)" ]; then \ + echo "error: DECERNOR_MINISIGN_KEY is not set" >&2; exit 2; \ + fi + @./scripts/sign-release-assets.sh $(DECERNOR_RELEASE_TAG) $(DIST_RELEASE) + +release-export-keys: ## Export public signing keys (DECERNOR_MINISIGN_PUB + GPG env) + @./scripts/export-release-keys.sh $(DIST_RELEASE) + +release-verify-checksums: ## Verify SHA256SUMS against staged files + @cd $(DIST_RELEASE) && shasum -a 256 -c SHA256SUMS + +release-verify-signatures: ## Verify minisign/PGP signatures + @./scripts/verify-signatures.sh $(DIST_RELEASE) + +release-verify-keys: ## Public-only + pin match via decernor + @./scripts/verify-public-keys.sh $(DIST_RELEASE) + +release-verify: release-verify-checksums release-verify-signatures release-verify-keys + @echo "[ok] All release verifications passed" + +release-upload: release-verify ## Upload signed provenance (draft unchanged) + @./scripts/upload-release-assets.sh $(DECERNOR_RELEASE_TAG) $(DIST_RELEASE) + +# Serialized walk. Leaves stay independent. Stage anchors before checksums. +release: release-guard-tag-version ## Full signing workflow (after CI draft) + $(MAKE) release-clean + $(MAKE) release-download + $(MAKE) release-notes + $(MAKE) release-stage-anchors + $(MAKE) release-checksums + $(MAKE) release-sign + $(MAKE) release-export-keys + $(MAKE) release-upload + @echo "[ok] Release $(DECERNOR_RELEASE_TAG) complete" + release-build: build-all ## Build release artifacts (binaries + checksums) @echo "✅ Release build complete" diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 9197016..0d3eeae 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,74 +1,53 @@ # Release Checklist -Maintainer walk for each `vX.Y.Z` tag. Decernor is a Go CLI. There is no -crate publish, no bindings tag, and no FFI tarball. +Maintainer walk for each `vX.Y.Z` tag. Decernor is a Go CLI. -This repository is **private**. A successful first cut is a private annotated -tag plus a **draft** GitHub release. Signing is optional until org public keys -are on disk and fingerprints are inserted from `decernor fingerprint` (not -hand-typed). +Bindings are **environment variables only** (`DECERNOR_MINISIGN_KEY`, +`DECERNOR_MINISIGN_PUB`, `DECERNOR_GPG_HOMEDIR`, `DECERNOR_PGP_KEY_ID`). +No key paths in this tree. Load them from a host-local profile. -## Prerequisites - -- `gh` authenticated with push access to `3leaps/decernor` -- Local `main` matches `origin/main` -- `gpg` / `minisign` only if this cut will sign +See [PDR-0001](docs/decisions/PDR-0001-committed-signing-anchors.md). ## 1. Write / prep - [ ] `VERSION` is the tag without the `v` prefix - [ ] `.fulmen/app.yaml` `app.version` matches `VERSION` -- [ ] Embedded identity is in sync: `make sync-embedded-identity` then - `make verify-embedded-identity` -- [ ] `CHANGELOG.md` has a `## [X.Y.Z]` section and an empty `[Unreleased]` -- [ ] `RELEASE_NOTES.md` is the landing page for this cut -- [ ] `docs/releases/vX.Y.Z.md` is **that cut only** +- [ ] `make sync-embedded-identity && make verify-embedded-identity` +- [ ] Pins exist (`make release-insert-anchors` after env is loaded) +- [ ] `CHANGELOG.md` has `## [X.Y.Z]`; `RELEASE_NOTES.md` has `## vX.Y.Z` +- [ ] `docs/releases/vX.Y.Z.md` is that cut only - [ ] `make release-preflight` passes +- [ ] PR merge; CI green on `main` -Commit the prep on a `chore/release-vX.Y.Z` branch. Conventional subject only -(no private planning ids). Open a PR; merge after CI is green. - -## 2. Tag (after the notes PR is on `main`) +## 2. Tag ```bash -git switch main -git pull --ff-only origin main +git switch main && git pull --ff-only origin main test "$(cat VERSION)" = "X.Y.Z" make release-preflight git tag -a "vX.Y.Z" -m "vX.Y.Z" git push origin "vX.Y.Z" ``` -Do not tag until CI on that `main` commit is green. - -## 3. Draft GitHub release - -`release.yml` builds on `v*` and opens a **draft** with `dist/release/*`. +Wait for the Release workflow to draft unsigned archives. -- [ ] Confirm the workflow finished -- [ ] Confirm `VERSION` matched the tag (workflow fails closed if not) -- [ ] Leave the release **draft** unless this cut is explicitly a publish +## 3. Sign / upload (MFA host) -## 4. Signing (optional, later) - -Only when both org public files exist: - -```text -decernor fingerprint --class public --kind gpg --format json \ - --path-mode none --gpg-role primary -decernor fingerprint --class public --kind minisign \ - --format json --path-mode none +```bash +# env already loaded: DECERNOR_* +export DECERNOR_RELEASE_TAG=vX.Y.Z +make release ``` -Write `keys/expected-fingerprints.txt` from those records (verbatim hex). -Do not wrap `gpg --show-keys` or `xxd | head`. Then `make package-sign` and -`make verify-release-key`. Undraft only after verify is green. +That walk is: clean → download → **notes** → **stage-anchors** → +checksums → sign → export-keys → verify → upload. + +`release-stage-anchors` is **net-new**. Checksum scripts will not pick +up the pin files unless this runs first. -If publics are missing, **stop after step 3**. Unsigned private draft is -success for that cut. +The GitHub release stays **draft**. Undraft is a separate maintainer step. -## 5. Out of scope for a private 0.1.x tag +## 4. Rekey -- Flipping the GitHub repository public -- crates.io / Homebrew / Scoop -- Hand-typed fingerprints +New export + `make release-insert-anchors` + new cut. Do not edit hex +by hand. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bd2b88f..7d60b67 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,30 +1,21 @@ # Release Notes -Landing page for the latest Decernor cut. Per-cut payload for GitHub: -[`docs/releases/v0.1.2.md`](docs/releases/v0.1.2.md). - -## v0.1.2 — 2026-08-18 +Landing page for the latest Decernor cut. Per-cut payload: +[`docs/releases/v0.1.3.md`](docs/releases/v0.1.3.md). -First tagged snapshot. **Private** repository. Draft GitHub release; -unsigned unless org public keys are inserted from `decernor fingerprint`. +## v0.1.3 — 2026-08-18 -**Shipped verbs:** `scan`, `guardread`, `fingerprint`/`fp`, `validate` -(contract-base + classification gate), `readiness validate-config`, -`version`, `envinfo`, `doctor`. +First **signed** private release. Pins in `keys/expected-fingerprints.txt` +are produced by `decernor fingerprint` (not hand-typed) and copied into +`dist/release/` **before** checksums, then signed with the rest of the +payload. -**Fingerprint contract:** successful GPG records carry `key_role` and -`key_id`. `--gpg-role primary` is the unique-primary selector (exit 3, no -stdout, if that named file has 0 or more than one primary). Minisign -trust-anchor field is lowercase 64-hex `minisign-public-blob-sha256-v1`. +Signing uses `DECERNOR_*` environment variables only. No key paths live +in this repository. -**Not in this cut:** static readiness evaluation, proof checks, -`derive-public-key`, `migrate`, embedded ceremony docs, public GitHub -visibility. +Still private. Draft GitHub release until a maintainer undrafts. -### Verify +## v0.1.2 — 2026-08-18 -```sh -make check-all -make build -decernor version # 0.1.2 -``` +First tagged snapshot (unsigned draft). See +[`docs/releases/v0.1.2.md`](docs/releases/v0.1.2.md). diff --git a/VERSION b/VERSION index d917d3e..b1e80bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.1.3 diff --git a/docs/decisions/PDR-0001-committed-signing-anchors.md b/docs/decisions/PDR-0001-committed-signing-anchors.md index 1527fc9..bd46b52 100644 --- a/docs/decisions/PDR-0001-committed-signing-anchors.md +++ b/docs/decisions/PDR-0001-committed-signing-anchors.md @@ -1,7 +1,7 @@ --- id: PDR-0001 title: Committed signing anchors and signed-payload inclusion -status: Proposed +status: Accepted date: 2026-08-18 deciders: - cxotech @@ -13,7 +13,7 @@ relates-to: # PDR-0001 — Committed signing anchors and signed-payload inclusion -**Status: Proposed.** Process for *where* Decernor writes release-signing +**Status: Accepted.** Process for *where* Decernor writes release-signing identity and *how* that file becomes part of a signed GitHub release. This is a **PDR**, not an EPR or DDR: diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2e564e3..f54ffaa 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -7,4 +7,4 @@ Older records keep their original filenames. | -- | ----- | ------ | ---- | | [ADR-0001](adr-0001-symlink-policy.md) | Symlink policy | Accepted | 2026-06-11 | | [DDR-0001](ddr-0001-fingerprint-record-contract.md) | Fingerprint record contract | Accepted | 2026-06-11 | -| [PDR-0001](PDR-0001-committed-signing-anchors.md) | Committed signing anchors + signed-payload inclusion | Proposed | 2026-08-18 | +| [PDR-0001](PDR-0001-committed-signing-anchors.md) | Committed signing anchors + signed-payload inclusion | Accepted | 2026-08-18 | diff --git a/docs/releases/README.md b/docs/releases/README.md index e74593b..a0cecc1 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -2,4 +2,5 @@ Per-cut notes for tagged releases. Landing page: [`RELEASE_NOTES.md`](../../RELEASE_NOTES.md). -- [v0.1.2](v0.1.2.md) — first tagged snapshot (private) +- [v0.1.3](v0.1.3.md) — first signed private cut +- [v0.1.2](v0.1.2.md) — first tagged snapshot (unsigned) diff --git a/docs/releases/v0.1.3.md b/docs/releases/v0.1.3.md new file mode 100644 index 0000000..f07d2f7 --- /dev/null +++ b/docs/releases/v0.1.3.md @@ -0,0 +1,13 @@ +# v0.1.3 — 2026-08-18 + +First **signed** private release. + +Fingerprint pins are generated with `decernor fingerprint` on exported +public files and staged into the checksum set before signing. Bindings +are `DECERNOR_*` environment variables only. + +**Shipped verbs** are unchanged from v0.1.2: `scan`, `guardread`, +`fingerprint`/`fp`, `validate`, `readiness validate-config`, `version`, +`envinfo`, `doctor`. + +Still private. Do not treat an undrafted GitHub release as a public launch. diff --git a/internal/assets/appidentity/app.yaml b/internal/assets/appidentity/app.yaml index 26a4cc8..349a0f4 100644 --- a/internal/assets/appidentity/app.yaml +++ b/internal/assets/appidentity/app.yaml @@ -12,6 +12,6 @@ app: # Config file name (without extension); resolves to ~/.config/3leaps/decernor.yaml. config_name: decernor description: "Local key-material hygiene and readiness checks" - version: "0.1.2" + version: "0.1.3" metadata: repository_category: cli diff --git a/keys/README.md b/keys/README.md new file mode 100644 index 0000000..4fd9976 --- /dev/null +++ b/keys/README.md @@ -0,0 +1,13 @@ +# Release signing pins + +`expected-fingerprints.txt` is the verifier contract. It is generated by +`make release-insert-anchors` from `decernor fingerprint` on **exported +public** files. Hex is never hand-typed. + +`expected-fingerprints.ndjson` is the matching DDR-0001 receipt. + +Do not commit private keys, keystore trees, or public key files here. +Public `.pub` / `.asc` files ride the GitHub release only. + +Bindings are environment variables (`DECERNOR_MINISIGN_PUB`, +`DECERNOR_GPG_HOMEDIR`, `DECERNOR_PGP_KEY_ID`). No paths in this tree. diff --git a/keys/expected-fingerprints.ndjson b/keys/expected-fingerprints.ndjson new file mode 100644 index 0000000..37b8be6 --- /dev/null +++ b/keys/expected-fingerprints.ndjson @@ -0,0 +1,2 @@ +{"schema_version":"v0","kind":"gpg","class":"public","algorithm":"openpgp-fingerprint","fingerprint":"94BB7811D4AD49B2310E0C08FA0651DE91B828ED","fingerprint_scheme":"openpgp-fingerprint-v1","key_id":"FA0651DE91B828ED","key_role":"primary","confidence":"high"} +{"schema_version":"v0","kind":"minisign","class":"public","algorithm":"sha256","fingerprint":"a8d82e7dc34532f7dddee9e39142fc98747354687faa3e834ca79cb2bf8e5eed","fingerprint_scheme":"minisign-public-blob-sha256-v1","key_id":"C0A14274D3B544DE","confidence":"high"} diff --git a/keys/expected-fingerprints.txt b/keys/expected-fingerprints.txt new file mode 100644 index 0000000..36c920b --- /dev/null +++ b/keys/expected-fingerprints.txt @@ -0,0 +1,2 @@ +gpg 94BB7811D4AD49B2310E0C08FA0651DE91B828ED +minisign a8d82e7dc34532f7dddee9e39142fc98747354687faa3e834ca79cb2bf8e5eed diff --git a/scripts/download-release-assets.sh b/scripts/download-release-assets.sh new file mode 100755 index 0000000..aea2a09 --- /dev/null +++ b/scripts/download-release-assets.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Download unsigned archive assets from the GitHub draft release. +set -euo pipefail + +TAG=${1:?"usage: download-release-assets.sh [dest_dir]"} +DEST=${2:-dist/release} + +echo "Downloading release assets for $TAG to $DEST..." +mkdir -p "$DEST" + +gh release download "$TAG" --dir "$DEST" --clobber \ + --pattern 'decernor_*.tar.gz' \ + --pattern 'decernor_*.zip' + +echo "Downloaded to $DEST:" +ls -la "$DEST" diff --git a/scripts/export-release-keys.sh b/scripts/export-release-keys.sh new file mode 100755 index 0000000..fa0ef7d --- /dev/null +++ b/scripts/export-release-keys.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Export public signing keys into dist/release. Env vars only. +set -euo pipefail + +DIR=${1:-dist/release} +mkdir -p "$DIR" + +echo "Exporting public keys to $DIR..." + +if [ -z "${DECERNOR_MINISIGN_PUB:-}" ]; then + echo "error: DECERNOR_MINISIGN_PUB is not set" >&2 + exit 2 +fi +if [ ! -f "$DECERNOR_MINISIGN_PUB" ]; then + echo "error: DECERNOR_MINISIGN_PUB is not a readable file" >&2 + exit 2 +fi +cp "$DECERNOR_MINISIGN_PUB" "$DIR/decernor-minisign.pub" +echo "[ok] Exported $DIR/decernor-minisign.pub" + +if [ -z "${DECERNOR_PGP_KEY_ID:-}" ]; then + echo "error: DECERNOR_PGP_KEY_ID is not set" >&2 + exit 2 +fi +GPG_OPTS=(--batch --no-tty --armor --export "$DECERNOR_PGP_KEY_ID") +if [ -n "${DECERNOR_GPG_HOMEDIR:-}" ]; then + GPG_OPTS=(--homedir "$DECERNOR_GPG_HOMEDIR" "${GPG_OPTS[@]}") +fi +gpg "${GPG_OPTS[@]}" >"$DIR/decernor-release-signing-key.asc" +echo "[ok] Exported $DIR/decernor-release-signing-key.asc" diff --git a/scripts/generate-checksums.sh b/scripts/generate-checksums.sh new file mode 100755 index 0000000..4e46282 --- /dev/null +++ b/scripts/generate-checksums.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Generate SHA256SUMS and SHA512SUMS for this tag's payload. +# Requires staged release notes and fingerprint pins (net-new). +set -euo pipefail + +DIR=${1:-dist/release} +TAG=${2:-${DECERNOR_RELEASE_TAG:-}} + +if [ ! -d "$DIR" ]; then + echo "Error: Directory $DIR does not exist" >&2 + exit 1 +fi + +if [ -z "$TAG" ] || [ "$TAG" = "v" ]; then + echo "Error: No release tag. Pass the tag or set DECERNOR_RELEASE_TAG=vX.Y.Z" >&2 + exit 1 +fi + +VERSION="${TAG#v}" +NOTES="release-notes-${TAG}.md" +PIN_TXT="expected-fingerprints.txt" +PIN_NDJSON="expected-fingerprints.ndjson" + +cd "$DIR" + +for required in "$NOTES" "$PIN_TXT" "$PIN_NDJSON"; do + if [ ! -f "$required" ]; then + echo "Error: $required not in $DIR" >&2 + echo "Copy notes and pins before checksums:" >&2 + echo " make release-notes" >&2 + echo " make release-stage-anchors" >&2 + exit 1 + fi +done + +echo "Generating checksums in $DIR for $TAG..." + +CHECKSUM_FILES=() +for f in "$NOTES" "$PIN_TXT" "$PIN_NDJSON" \ + "decernor_${VERSION}_"*.tar.gz \ + "decernor_${VERSION}_"*.zip; do + if [ -f "$f" ]; then + CHECKSUM_FILES+=("$f") + fi +done + +if [ ${#CHECKSUM_FILES[@]} -lt 3 ]; then + echo "Error: no archive candidates for $TAG in $DIR" >&2 + exit 1 +fi + +printf '%s\n' "${CHECKSUM_FILES[@]}" | LC_ALL=C sort | xargs shasum -a 256 >SHA256SUMS +printf '%s\n' "${CHECKSUM_FILES[@]}" | LC_ALL=C sort | xargs shasum -a 512 >SHA512SUMS + +echo "Generated SHA256SUMS:" +cat SHA256SUMS +echo "" +echo "[ok] Checksums generated" diff --git a/scripts/insert-expected-fingerprints.sh b/scripts/insert-expected-fingerprints.sh new file mode 100755 index 0000000..4d7da1f --- /dev/null +++ b/scripts/insert-expected-fingerprints.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Write keys/expected-fingerprints.{ndjson,txt} from decernor records. +# Requires DECERNOR_MINISIGN_PUB, DECERNOR_GPG_HOMEDIR, DECERNOR_PGP_KEY_ID. +# Never accepts a filesystem path as a flag. Env vars only. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +need() { + if [ -z "${!1:-}" ]; then + echo "error: $1 is not set" >&2 + exit 2 + fi +} + +need DECERNOR_MINISIGN_PUB +need DECERNOR_GPG_HOMEDIR +need DECERNOR_PGP_KEY_ID + +if [ ! -f "$DECERNOR_MINISIGN_PUB" ]; then + echo "error: DECERNOR_MINISIGN_PUB is not a readable file" >&2 + exit 2 +fi +if [ ! -d "$DECERNOR_GPG_HOMEDIR" ]; then + echo "error: DECERNOR_GPG_HOMEDIR is not a directory" >&2 + exit 2 +fi + +DECERNOR_BIN="${DECERNOR_BIN:-}" +if [ -z "$DECERNOR_BIN" ]; then + if [ -x "$ROOT/bin/decernor" ]; then + DECERNOR_BIN="$ROOT/bin/decernor" + elif command -v decernor >/dev/null 2>&1; then + DECERNOR_BIN="$(command -v decernor)" + else + echo "error: decernor binary not found (build it or set DECERNOR_BIN)" >&2 + exit 2 + fi +fi + +WORKDIR="$(mktemp -d)" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +PUB="$WORKDIR/decernor.pub" +ASC="$WORKDIR/decernor.asc" +cp "$DECERNOR_MINISIGN_PUB" "$PUB" +gpg --batch --no-tty --homedir "$DECERNOR_GPG_HOMEDIR" --armor --export "$DECERNOR_PGP_KEY_ID" >"$ASC" + +GPG_NDJSON="$WORKDIR/gpg.ndjson" +MINI_NDJSON="$WORKDIR/mini.ndjson" + +"$DECERNOR_BIN" fingerprint "$ASC" --class public --kind gpg \ + --format ndjson --path-mode none --gpg-role primary >"$GPG_NDJSON" +"$DECERNOR_BIN" fingerprint "$PUB" --class public --kind minisign \ + --format ndjson --path-mode none >"$MINI_NDJSON" + +python3 - "$GPG_NDJSON" "$MINI_NDJSON" "$ROOT/keys" <<'PY' +import json +import pathlib +import sys + +gpg_path, mini_path, out_dir = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3]) + + +def load_ndjson(path): + records = [] + for line in pathlib.Path(path).read_text().splitlines(): + line = line.strip() + if line: + records.append(json.loads(line)) + return records + + +gpg = load_ndjson(gpg_path) +if len(gpg) != 1: + raise SystemExit(f"error: expected exactly one GPG primary record, got {len(gpg)}") +g = gpg[0] +if g.get("fingerprint_scheme") != "openpgp-fingerprint-v1" or g.get("key_role") != "primary": + raise SystemExit("error: GPG record is not openpgp-fingerprint-v1 primary") +gpg_fp = g.get("fingerprint") or "" +if len(gpg_fp) != 40 or any(c not in "0123456789ABCDEF" for c in gpg_fp): + raise SystemExit("error: GPG fingerprint is not uppercase 40-hex") + +mini = [ + r + for r in load_ndjson(mini_path) + if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1" +] +if len(mini) != 1: + raise SystemExit(f"error: expected exactly one minisign blob-SHA record, got {len(mini)}") +m = mini[0] +mini_fp = m.get("fingerprint") or "" +if len(mini_fp) != 64 or any(c not in "0123456789abcdef" for c in mini_fp): + raise SystemExit("error: minisign fingerprint is not lowercase 64-hex") + +out_dir.mkdir(parents=True, exist_ok=True) +ndjson_path = out_dir / "expected-fingerprints.ndjson" +txt_path = out_dir / "expected-fingerprints.txt" +# One GPG primary + the trust-anchor minisign record only. +ndjson_path.write_text(json.dumps(g, separators=(",", ":")) + "\n" + json.dumps(m, separators=(",", ":")) + "\n") +txt_path.write_text(f"gpg {gpg_fp}\nminisign {mini_fp}\n") +print(f"[ok] wrote {ndjson_path}") +print(f"[ok] wrote {txt_path}") +PY diff --git a/scripts/release-guard-tag-version.sh b/scripts/release-guard-tag-version.sh new file mode 100755 index 0000000..e5eb0e5 --- /dev/null +++ b/scripts/release-guard-tag-version.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Verify DECERNOR_RELEASE_TAG matches VERSION. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +if [ ! -f VERSION ]; then + echo "error: VERSION file not found" >&2 + exit 1 +fi +version="$(tr -d ' \t\r\n' &2 + exit 1 +fi +echo "[ok] release guard: tag matches VERSION ($tag)" diff --git a/scripts/sign-release-assets.sh b/scripts/sign-release-assets.sh new file mode 100755 index 0000000..7d0126b --- /dev/null +++ b/scripts/sign-release-assets.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Sign checksum manifests. Requires DECERNOR_MINISIGN_KEY. PGP optional. +set -euo pipefail + +TAG=${1:?"usage: sign-release-assets.sh [dir]"} +DIR=${2:-dist/release} + +if [ ! -d "$DIR" ]; then + echo "Error: Directory $DIR does not exist" >&2 + exit 1 +fi + +if [ -z "${DECERNOR_MINISIGN_KEY:-}" ]; then + echo "error: DECERNOR_MINISIGN_KEY is not set" >&2 + exit 2 +fi +if [ ! -f "$DECERNOR_MINISIGN_KEY" ]; then + echo "error: DECERNOR_MINISIGN_KEY is not a readable file" >&2 + exit 2 +fi + +cd "$DIR" + +for manifest in SHA256SUMS SHA512SUMS; do + if [ ! -f "$manifest" ]; then + echo "Error: $manifest not found in $DIR" >&2 + echo "Run: make release-checksums" >&2 + exit 1 + fi +done + +echo "Signing release $TAG..." +for manifest in SHA256SUMS SHA512SUMS; do + minisign -S -s "$DECERNOR_MINISIGN_KEY" \ + -m "$manifest" \ + -t "decernor $TAG" \ + -x "${manifest}.minisig" + echo "[ok] Created ${manifest}.minisig" +done + +if [ -n "${DECERNOR_PGP_KEY_ID:-}" ]; then + GPG_OPTS=(--armor --detach-sign --local-user "$DECERNOR_PGP_KEY_ID") + if [ -n "${DECERNOR_GPG_HOMEDIR:-}" ]; then + GPG_OPTS=(--homedir "$DECERNOR_GPG_HOMEDIR" "${GPG_OPTS[@]}") + fi + for manifest in SHA256SUMS SHA512SUMS; do + gpg "${GPG_OPTS[@]}" --output "${manifest}.asc" "$manifest" + echo "[ok] Created ${manifest}.asc" + done +else + echo "[--] PGP signing skipped (DECERNOR_PGP_KEY_ID not set)" +fi diff --git a/scripts/stage-release-anchors.sh b/scripts/stage-release-anchors.sh new file mode 100755 index 0000000..7707bac --- /dev/null +++ b/scripts/stage-release-anchors.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copy committed fingerprint pins into dist/release before checksums. +# Net-new step: existing checksum scripts do not invent this file. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIR="${1:-dist/release}" + +NDJSON="$ROOT/keys/expected-fingerprints.ndjson" +TXT="$ROOT/keys/expected-fingerprints.txt" + +if [ ! -f "$NDJSON" ] || [ ! -f "$TXT" ]; then + echo "error: missing keys/expected-fingerprints.ndjson or .txt" >&2 + echo "run: make release-insert-anchors" >&2 + exit 2 +fi + +mkdir -p "$DIR" +cp "$NDJSON" "$DIR/expected-fingerprints.ndjson" +cp "$TXT" "$DIR/expected-fingerprints.txt" +echo "[ok] staged fingerprint pins into $DIR" diff --git a/scripts/upload-release-assets.sh b/scripts/upload-release-assets.sh new file mode 100755 index 0000000..ff8e2a6 --- /dev/null +++ b/scripts/upload-release-assets.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Upload signed provenance. Leaves the GitHub release as a draft. +set -euo pipefail + +TAG=${1:?"usage: upload-release-assets.sh [dir]"} +DIR=${2:-dist/release} + +if [ ! -d "$DIR" ]; then + echo "Error: Directory $DIR does not exist" >&2 + exit 1 +fi + +cd "$DIR" + +REQUIRED_FILES=( + "SHA256SUMS" + "SHA256SUMS.minisig" + "SHA512SUMS" + "SHA512SUMS.minisig" + "decernor-minisign.pub" + "expected-fingerprints.txt" + "expected-fingerprints.ndjson" + "release-notes-${TAG}.md" +) + +for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$file" ]; then + echo "Error: Required file missing: $file" >&2 + exit 1 + fi +done + +UPLOAD_FILES=( + "SHA256SUMS" + "SHA256SUMS.minisig" + "SHA512SUMS" + "SHA512SUMS.minisig" + "decernor-minisign.pub" + "expected-fingerprints.txt" + "expected-fingerprints.ndjson" + "release-notes-${TAG}.md" +) + +for optional in "SHA256SUMS.asc" "SHA512SUMS.asc" "decernor-release-signing-key.asc"; do + if [ -f "$optional" ]; then + UPLOAD_FILES+=("$optional") + fi +done + +echo "Uploading files:" +printf ' %s\n' "${UPLOAD_FILES[@]}" +gh release upload "$TAG" "${UPLOAD_FILES[@]}" --clobber +gh release edit "$TAG" --notes-file "release-notes-${TAG}.md" + +echo "[ok] Release $TAG assets uploaded (draft unchanged)" +echo "Publish when ready: gh release edit $TAG --draft=false" diff --git a/scripts/verify-public-keys.sh b/scripts/verify-public-keys.sh new file mode 100755 index 0000000..dcf26f9 --- /dev/null +++ b/scripts/verify-public-keys.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Public-only scan + pin match via decernor records (no second extractor). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIR=${1:-dist/release} +PIN_TXT="$ROOT/keys/expected-fingerprints.txt" + +if [ ! -f "$PIN_TXT" ]; then + echo "error: missing $PIN_TXT" >&2 + exit 2 +fi + +PUB="$DIR/decernor-minisign.pub" +ASC="$DIR/decernor-release-signing-key.asc" + +if [ ! -f "$PUB" ] || [ ! -f "$ASC" ]; then + echo "error: exported public keys missing in $DIR" >&2 + exit 2 +fi + +if grep -Eqi "PRIVATE|SECRET|BEGIN PGP PRIVATE KEY|minisign secret key" "$PUB" "$ASC"; then + echo "error: exported key file appears to contain private material" >&2 + exit 1 +fi + +DECERNOR_BIN="${DECERNOR_BIN:-}" +if [ -z "$DECERNOR_BIN" ]; then + if [ -x "$ROOT/bin/decernor" ]; then + DECERNOR_BIN="$ROOT/bin/decernor" + elif command -v decernor >/dev/null 2>&1; then + DECERNOR_BIN="$(command -v decernor)" + else + echo "error: decernor binary not found" >&2 + exit 2 + fi +fi + +GPG_JSON="$("$DECERNOR_BIN" fingerprint "$ASC" --class public --kind gpg --format json --path-mode none --gpg-role primary)" +MINI_JSON="$("$DECERNOR_BIN" fingerprint "$PUB" --class public --kind minisign --format json --path-mode none)" + +python3 - "$PIN_TXT" "$GPG_JSON" "$MINI_JSON" <<'PY' +import json +import sys + +pin_path, gpg_raw, mini_raw = sys.argv[1], sys.argv[2], sys.argv[3] +want = {} +for line in open(pin_path): + line = line.strip() + if not line or line.startswith("#"): + continue + algo, fp, *_ = line.split() + want[algo] = fp + +gpg = json.loads(gpg_raw) +if len(gpg) != 1 or gpg[0].get("key_role") != "primary": + raise SystemExit("error: GPG pin check did not yield one primary record") +if gpg[0].get("fingerprint") != want.get("gpg"): + raise SystemExit("error: GPG fingerprint does not match keys/expected-fingerprints.txt") + +mini = [ + r + for r in json.loads(mini_raw) + if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1" +] +if len(mini) != 1: + raise SystemExit("error: minisign pin check did not yield one blob-SHA record") +if mini[0].get("fingerprint") != want.get("minisign"): + raise SystemExit("error: minisign fingerprint does not match keys/expected-fingerprints.txt") + +print("[ok] exported publics match committed fingerprint pins") +PY diff --git a/scripts/verify-signatures.sh b/scripts/verify-signatures.sh new file mode 100755 index 0000000..f168477 --- /dev/null +++ b/scripts/verify-signatures.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Verify minisign (required) and PGP (if present) signatures on SUMS. +set -euo pipefail + +DIR=${1:-dist/release} +cd "$DIR" + +ERRORS=0 + +if [ ! -f "decernor-minisign.pub" ]; then + echo "[!!] decernor-minisign.pub not found" >&2 + exit 1 +fi + +for manifest in SHA256SUMS SHA512SUMS; do + if [ ! -f "$manifest" ] || [ ! -f "${manifest}.minisig" ]; then + echo "[!!] missing $manifest or ${manifest}.minisig" >&2 + ERRORS=$((ERRORS + 1)) + continue + fi + if minisign -Vm "$manifest" -p decernor-minisign.pub; then + echo "[ok] $manifest minisign valid" + else + echo "[!!] $manifest minisign INVALID" >&2 + ERRORS=$((ERRORS + 1)) + fi +done + +if [ -f "decernor-release-signing-key.asc" ]; then + GNUPGHOME=$(mktemp -d) + export GNUPGHOME + trap 'rm -rf "$GNUPGHOME"' EXIT + gpg --import decernor-release-signing-key.asc >/dev/null 2>&1 + for manifest in SHA256SUMS SHA512SUMS; do + if [ -f "${manifest}.asc" ]; then + if gpg --verify "${manifest}.asc" "$manifest" >/dev/null 2>&1; then + echo "[ok] $manifest PGP valid" + else + echo "[!!] $manifest PGP INVALID" >&2 + ERRORS=$((ERRORS + 1)) + fi + fi + done +fi + +if [ "$ERRORS" -ne 0 ]; then + exit 1 +fi +echo "[ok] All signatures verified" From 5cbad6b3909d93dd483bab7ad6ddaa405eb4d353 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 17:26:08 -0400 Subject: [PATCH 3/7] chore: format decision records for fmt-check Changes: - Normalize PDR-0001 and decisions README for goneat format Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- .../PDR-0001-committed-signing-anchors.md | 16 ++++++++-------- docs/decisions/README.md | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/decisions/PDR-0001-committed-signing-anchors.md b/docs/decisions/PDR-0001-committed-signing-anchors.md index bd46b52..35e14a1 100644 --- a/docs/decisions/PDR-0001-committed-signing-anchors.md +++ b/docs/decisions/PDR-0001-committed-signing-anchors.md @@ -13,13 +13,13 @@ relates-to: # PDR-0001 — Committed signing anchors and signed-payload inclusion -**Status: Accepted.** Process for *where* Decernor writes release-signing -identity and *how* that file becomes part of a signed GitHub release. +**Status: Accepted.** Process for _where_ Decernor writes release-signing +identity and _how_ that file becomes part of a signed GitHub release. This is a **PDR**, not an EPR or DDR: -- **Not EPR.** "Never hand-type hex" is the durable rule, but *where the - file lives* and *which sibling files we keep* are revisable. +- **Not EPR.** "Never hand-type hex" is the durable rule, but _where the + file lives_ and _which sibling files we keep_ are revisable. - **Not DDR.** Record shape is already [DDR-0001](ddr-0001-fingerprint-record-contract.md). This record is the ceremony and layout. - **Not ADR.** No new runtime component. @@ -51,11 +51,11 @@ keys/ expected-fingerprints.txt # verifier contract (derived) ``` -| File | Role | -|------|------| +| File | Role | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expected-fingerprints.ndjson` | Exact stdout of `decernor fingerprint` on the **exported public files** (`--format ndjson --path-mode none --class public`). GPG run uses `--kind gpg --gpg-role primary`. Minisign run uses `--kind minisign` and keeps the `minisign-public-blob-sha256-v1` record (not the key-id record). Schema-valid DDR-0001 records. | -| `expected-fingerprints.txt` | Two lines, whitespace-separated: `gpg <40-hex>` and `minisign <64-hex>`. Values are **copied verbatim** from those records. This is what `verify-public-keys.sh` compares. | -| `README.md` | Points at the inserter; does not restate hex. | +| `expected-fingerprints.txt` | Two lines, whitespace-separated: `gpg <40-hex>` and `minisign <64-hex>`. Values are **copied verbatim** from those records. This is what `verify-public-keys.sh` compares. | +| `README.md` | Points at the inserter; does not restate hex. | **Never committed:** private keys, keystore trees, minisign secret files, exported `.pub` / `.asc`, **or any filesystem path to those diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f54ffaa..dfab886 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -3,8 +3,8 @@ Types follow crucible ADR-0003 (`ADR` / `DDR` / `SecDR` / `PDR` / `EPR`). Older records keep their original filenames. -| ID | Title | Status | Date | -| -- | ----- | ------ | ---- | -| [ADR-0001](adr-0001-symlink-policy.md) | Symlink policy | Accepted | 2026-06-11 | -| [DDR-0001](ddr-0001-fingerprint-record-contract.md) | Fingerprint record contract | Accepted | 2026-06-11 | -| [PDR-0001](PDR-0001-committed-signing-anchors.md) | Committed signing anchors + signed-payload inclusion | Accepted | 2026-08-18 | +| ID | Title | Status | Date | +| --------------------------------------------------- | ---------------------------------------------------- | -------- | ---------- | +| [ADR-0001](adr-0001-symlink-policy.md) | Symlink policy | Accepted | 2026-06-11 | +| [DDR-0001](ddr-0001-fingerprint-record-contract.md) | Fingerprint record contract | Accepted | 2026-06-11 | +| [PDR-0001](PDR-0001-committed-signing-anchors.md) | Committed signing anchors + signed-payload inclusion | Accepted | 2026-08-18 | From 0ddc2c278c3f1d191e745d64b431661eea06d9dc Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 17:40:19 -0400 Subject: [PATCH 4/7] fix: close signed-release ceremony contract gaps Require archives in checksums, verify staged pins, and install the fingerprint pair atomically. Align the process record with the executable walk. Changes: - Fail generate-checksums when no archives are present - Verify dist/release pins, schema, and recomputed publics - Stage insert outputs then replace both pin files as a pair - Add ceremony probes and reconcile PDR-0001 Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- Makefile | 1 + .../PDR-0001-committed-signing-anchors.md | 64 +++++++-------- scripts/generate-checksums.sh | 16 ++-- scripts/insert-expected-fingerprints.sh | 66 +++++++++++++-- scripts/verify-public-keys.sh | 82 +++++++++++++++---- tests/release/ceremony_test.sh | 52 ++++++++++++ 6 files changed, 215 insertions(+), 66 deletions(-) create mode 100755 tests/release/ceremony_test.sh diff --git a/Makefile b/Makefile index 064eca1..3e207bd 100644 --- a/Makefile +++ b/Makefile @@ -346,6 +346,7 @@ verify-release-key: ## Verify exported public key contains no private material test: verify-embedded-identity ## Run all tests @echo "Running test suite..." $(GOTEST) ./... -v -cover + @bash tests/release/ceremony_test.sh lint: ## Run lint checks with goneat @if [ -z "$(GONEAT_BIN)" ]; then echo "❌ goneat not found. Run 'make bootstrap' first."; exit 1; fi diff --git a/docs/decisions/PDR-0001-committed-signing-anchors.md b/docs/decisions/PDR-0001-committed-signing-anchors.md index 35e14a1..8a53923 100644 --- a/docs/decisions/PDR-0001-committed-signing-anchors.md +++ b/docs/decisions/PDR-0001-committed-signing-anchors.md @@ -24,21 +24,17 @@ This is a **PDR**, not an EPR or DDR: This record is the ceremony and layout. - **Not ADR.** No new runtime component. -## What lanytehq asked for, and what we locked +## Producer / consumer contract -CHAN-TASK-010 stood down because `fingerprint` did not emit one GPG -contract value. DEC-011 locked the emitter: +The emitter already defines one selectable GPG contract value (sole +`openpgp-fingerprint-v1` with `key_role=primary`, uppercase 40-hex) and +one minisign trust-anchor (`minisign-public-blob-sha256-v1`, lowercase +64-hex in the record). Downstream release repos consume a two-line pin +file plus a public-key verifier. This repository dogfoods that consumer +path first so other tools can copy it. -- GPG contract = sole `openpgp-fingerprint-v1` with `key_role=primary` - on that named public file (uppercase 40-hex). -- Minisign contract = `minisign-public-blob-sha256-v1` **lowercase - 64-hex in the record**. Verifiers copy that field; they do not run - `xxd | head -c 20`. -- Inserter maps `decernor fingerprint` JSON/NDJSON → pin file. No - `gpg --show-keys`, no hand-typed hex. - -lanytehq's consumer path is `keys/expected-fingerprints.txt` plus -`verify-public-keys.sh`. We dogfood **that** path here first. +The inserter maps `decernor fingerprint` output onto the pin files. No +second OpenPGP or minisign extractor. No hand-typed hex. ## Where the material is written @@ -51,11 +47,11 @@ keys/ expected-fingerprints.txt # verifier contract (derived) ``` -| File | Role | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `expected-fingerprints.ndjson` | Exact stdout of `decernor fingerprint` on the **exported public files** (`--format ndjson --path-mode none --class public`). GPG run uses `--kind gpg --gpg-role primary`. Minisign run uses `--kind minisign` and keeps the `minisign-public-blob-sha256-v1` record (not the key-id record). Schema-valid DDR-0001 records. | -| `expected-fingerprints.txt` | Two lines, whitespace-separated: `gpg <40-hex>` and `minisign <64-hex>`. Values are **copied verbatim** from those records. This is what `verify-public-keys.sh` compares. | -| `README.md` | Points at the inserter; does not restate hex. | +| File | Role | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `expected-fingerprints.ndjson` | Selected-record receipt: the GPG primary record and the minisign public-blob SHA-256 record from `decernor fingerprint` on the **exported public files** (`--format ndjson --path-mode none --class public`). Not raw dual-record minisign stdout. Schema-valid DDR-0001 records. | +| `expected-fingerprints.txt` | Two lines, whitespace-separated: `gpg <40-hex>` and `minisign <64-hex>`. Values are **copied verbatim** from those records. This is what `verify-public-keys.sh` compares. | +| `README.md` | Points at the inserter; does not restate hex. | **Never committed:** private keys, keystore trees, minisign secret files, exported `.pub` / `.asc`, **or any filesystem path to those @@ -90,14 +86,17 @@ Do not assume "put the file in `dist/release/` and the old script will hash it." Extend the checksum input set, or the pin stays unsigned commentary. -**Order:** +**Order** (matches `make release`): -1. Download/build binaries -2. `release-notes` (existing pattern) -3. **`release-stage-anchors`** (new) — pin files + exported pubs -4. `release-checksums` (must include the new files) -5. `release-sign` (minisign required; PGP optional) -6. Upload provenance with the draft +1. Download unsigned archives +2. `release-notes` — copy per-cut notes into `dist/release/` +3. **`release-stage-anchors`** — copy committed pin files into `dist/release/` +4. `release-checksums` — must include notes + both pin files + **at least one archive** +5. `release-sign` — sign the SUMS (minisign required; PGP optional) +6. `release-export-keys` — export publics **after** signing (they are upload + companions, not SUMS members; the pin in SUMS blesses them) +7. Verify (checksums, signatures, **staged** pins vs recomputed publics) +8. Upload provenance; draft stays draft A pin that is only in git and never in `SHA256SUMS` is commentary. A pin that is in `SHA256SUMS` and then signed is the load-bearing artifact. @@ -138,13 +137,14 @@ The signed kit is a new cut: **`v0.1.3`**. ## Consequences -- lanytehq can copy `keys/` + inserter + "copy into dist before - checksums" without inventing a layout. -- `make release-verify-keys` asserts the pin, not "a pub file exists." -- Chanvoy CHAN-TASK-010 can re-arm against the same file names and - DEC-011 record filters. +- Other release repos can copy `keys/` + inserter + + `release-stage-anchors` without inventing a layout. +- `make release-verify-keys` asserts the **staged** pin against + recomputed publics, not "a pub file exists." +- The signed set is archives + notes + pin pair. Exported pubs ride + beside it and are checked against that pin. ## Out of scope -Public flip. Changing DDR-0001. synthcorpus pin move. Hand-maintained -hex in `RELEASE_NOTES.md`. +Public visibility flip. Changing DDR-0001. Hand-maintained hex in +`RELEASE_NOTES.md`. diff --git a/scripts/generate-checksums.sh b/scripts/generate-checksums.sh index 4e46282..368faa3 100755 --- a/scripts/generate-checksums.sh +++ b/scripts/generate-checksums.sh @@ -35,20 +35,20 @@ done echo "Generating checksums in $DIR for $TAG..." -CHECKSUM_FILES=() -for f in "$NOTES" "$PIN_TXT" "$PIN_NDJSON" \ - "decernor_${VERSION}_"*.tar.gz \ - "decernor_${VERSION}_"*.zip; do +ARCHIVES=() +for f in "decernor_${VERSION}_"*.tar.gz "decernor_${VERSION}_"*.zip; do if [ -f "$f" ]; then - CHECKSUM_FILES+=("$f") + ARCHIVES+=("$f") fi done - -if [ ${#CHECKSUM_FILES[@]} -lt 3 ]; then - echo "Error: no archive candidates for $TAG in $DIR" >&2 +if [ ${#ARCHIVES[@]} -eq 0 ]; then + echo "Error: no archives for $TAG in $DIR (notes+pins are not a release)" >&2 exit 1 fi +CHECKSUM_FILES=("$NOTES" "$PIN_TXT" "$PIN_NDJSON") +CHECKSUM_FILES+=("${ARCHIVES[@]}") + printf '%s\n' "${CHECKSUM_FILES[@]}" | LC_ALL=C sort | xargs shasum -a 256 >SHA256SUMS printf '%s\n' "${CHECKSUM_FILES[@]}" | LC_ALL=C sort | xargs shasum -a 512 >SHA512SUMS diff --git a/scripts/insert-expected-fingerprints.sh b/scripts/insert-expected-fingerprints.sh index 4d7da1f..26163d9 100755 --- a/scripts/insert-expected-fingerprints.sh +++ b/scripts/insert-expected-fingerprints.sh @@ -50,13 +50,15 @@ gpg --batch --no-tty --homedir "$DECERNOR_GPG_HOMEDIR" --armor --export "$DECERN GPG_NDJSON="$WORKDIR/gpg.ndjson" MINI_NDJSON="$WORKDIR/mini.ndjson" +STAGING="$WORKDIR/pair" +mkdir -p "$STAGING" "$DECERNOR_BIN" fingerprint "$ASC" --class public --kind gpg \ --format ndjson --path-mode none --gpg-role primary >"$GPG_NDJSON" "$DECERNOR_BIN" fingerprint "$PUB" --class public --kind minisign \ --format ndjson --path-mode none >"$MINI_NDJSON" -python3 - "$GPG_NDJSON" "$MINI_NDJSON" "$ROOT/keys" <<'PY' +python3 - "$GPG_NDJSON" "$MINI_NDJSON" "$STAGING" <<'PY' import json import pathlib import sys @@ -96,11 +98,59 @@ if len(mini_fp) != 64 or any(c not in "0123456789abcdef" for c in mini_fp): raise SystemExit("error: minisign fingerprint is not lowercase 64-hex") out_dir.mkdir(parents=True, exist_ok=True) -ndjson_path = out_dir / "expected-fingerprints.ndjson" -txt_path = out_dir / "expected-fingerprints.txt" -# One GPG primary + the trust-anchor minisign record only. -ndjson_path.write_text(json.dumps(g, separators=(",", ":")) + "\n" + json.dumps(m, separators=(",", ":")) + "\n") -txt_path.write_text(f"gpg {gpg_fp}\nminisign {mini_fp}\n") -print(f"[ok] wrote {ndjson_path}") -print(f"[ok] wrote {txt_path}") +# Selected-record receipt (primary + blob SHA only), not raw dual-record stdout. +(out_dir / "expected-fingerprints.ndjson").write_text( + json.dumps(g, separators=(",", ":")) + "\n" + json.dumps(m, separators=(",", ":")) + "\n" +) +(out_dir / "expected-fingerprints.txt").write_text(f"gpg {gpg_fp}\nminisign {mini_fp}\n") PY + +SCHEMA="$ROOT/schemas/fingerprint-record.v0.schema.json" +while IFS= read -r line; do + [ -n "$line" ] || continue + printf '%s\n' "$line" >"$WORKDIR/one.json" + "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$WORKDIR/one.json" >/dev/null +done <"$STAGING/expected-fingerprints.ndjson" + +KEYS="$ROOT/keys" +mkdir -p "$KEYS" +NEW_NDJSON="$KEYS/expected-fingerprints.ndjson.new" +NEW_TXT="$KEYS/expected-fingerprints.txt.new" +DEST_NDJSON="$KEYS/expected-fingerprints.ndjson" +DEST_TXT="$KEYS/expected-fingerprints.txt" +BAK_NDJSON="$KEYS/expected-fingerprints.ndjson.bak" +BAK_TXT="$KEYS/expected-fingerprints.txt.bak" + +cp "$STAGING/expected-fingerprints.ndjson" "$NEW_NDJSON" +cp "$STAGING/expected-fingerprints.txt" "$NEW_TXT" + +rollback() { + rm -f "$NEW_NDJSON" "$NEW_TXT" + if [ -f "$BAK_NDJSON" ]; then + mv -f "$BAK_NDJSON" "$DEST_NDJSON" + fi + if [ -f "$BAK_TXT" ]; then + mv -f "$BAK_TXT" "$DEST_TXT" + fi +} + +if [ -f "$DEST_NDJSON" ]; then + cp "$DEST_NDJSON" "$BAK_NDJSON" +fi +if [ -f "$DEST_TXT" ]; then + cp "$DEST_TXT" "$BAK_TXT" +fi + +if ! mv -f "$NEW_NDJSON" "$DEST_NDJSON"; then + rollback + echo "error: failed to install ndjson pin" >&2 + exit 1 +fi +if ! mv -f "$NEW_TXT" "$DEST_TXT"; then + rollback + echo "error: failed to install txt pin; restored previous pair" >&2 + exit 1 +fi +rm -f "$BAK_NDJSON" "$BAK_TXT" +echo "[ok] wrote $DEST_NDJSON" +echo "[ok] wrote $DEST_TXT" diff --git a/scripts/verify-public-keys.sh b/scripts/verify-public-keys.sh index dcf26f9..94d75b7 100755 --- a/scripts/verify-public-keys.sh +++ b/scripts/verify-public-keys.sh @@ -1,16 +1,32 @@ #!/usr/bin/env bash -# Public-only scan + pin match via decernor records (no second extractor). +# Verify staged pins in dist/release (the signed objects), not only keys/. +# Public-only scan + DDR-0001 validation + TXT/NDJSON/recomputed equality. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIR=${1:-dist/release} -PIN_TXT="$ROOT/keys/expected-fingerprints.txt" +STAGED_TXT="$DIR/expected-fingerprints.txt" +STAGED_NDJSON="$DIR/expected-fingerprints.ndjson" +SCHEMA="$ROOT/schemas/fingerprint-record.v0.schema.json" -if [ ! -f "$PIN_TXT" ]; then - echo "error: missing $PIN_TXT" >&2 +if [ ! -f "$STAGED_TXT" ] || [ ! -f "$STAGED_NDJSON" ]; then + echo "error: missing staged pins in $DIR" >&2 exit 2 fi +if [ -f "$ROOT/keys/expected-fingerprints.txt" ]; then + if ! cmp -s "$STAGED_TXT" "$ROOT/keys/expected-fingerprints.txt"; then + echo "error: staged TXT differs from keys/expected-fingerprints.txt" >&2 + exit 1 + fi +fi +if [ -f "$ROOT/keys/expected-fingerprints.ndjson" ]; then + if ! cmp -s "$STAGED_NDJSON" "$ROOT/keys/expected-fingerprints.ndjson"; then + echo "error: staged NDJSON differs from keys/expected-fingerprints.ndjson" >&2 + exit 1 + fi +fi + PUB="$DIR/decernor-minisign.pub" ASC="$DIR/decernor-release-signing-key.asc" @@ -36,27 +52,57 @@ if [ -z "$DECERNOR_BIN" ]; then fi fi +while IFS= read -r line; do + [ -n "$line" ] || continue + printf '%s\n' "$line" >"$DIR/.pin-one.json" + "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$DIR/.pin-one.json" >/dev/null +done <"$STAGED_NDJSON" +rm -f "$DIR/.pin-one.json" + GPG_JSON="$("$DECERNOR_BIN" fingerprint "$ASC" --class public --kind gpg --format json --path-mode none --gpg-role primary)" MINI_JSON="$("$DECERNOR_BIN" fingerprint "$PUB" --class public --kind minisign --format json --path-mode none)" -python3 - "$PIN_TXT" "$GPG_JSON" "$MINI_JSON" <<'PY' +python3 - "$STAGED_TXT" "$STAGED_NDJSON" "$GPG_JSON" "$MINI_JSON" <<'PY' import json +import pathlib import sys -pin_path, gpg_raw, mini_raw = sys.argv[1], sys.argv[2], sys.argv[3] +txt_path, ndjson_path, gpg_raw, mini_raw = sys.argv[1:5] +lines = [ln.strip() for ln in pathlib.Path(txt_path).read_text().splitlines() if ln.strip() and not ln.startswith("#")] +if len(lines) != 2: + raise SystemExit("error: staged TXT must have exactly two non-comment lines") want = {} -for line in open(pin_path): - line = line.strip() - if not line or line.startswith("#"): - continue - algo, fp, *_ = line.split() +for line in lines: + parts = line.split() + if len(parts) < 2: + raise SystemExit("error: staged TXT line is malformed") + algo, fp = parts[0], parts[1] + if algo in want: + raise SystemExit(f"error: duplicate {algo} line in staged TXT") want[algo] = fp +if set(want) != {"gpg", "minisign"}: + raise SystemExit("error: staged TXT must contain exactly one gpg and one minisign line") + +records = [] +for line in pathlib.Path(ndjson_path).read_text().splitlines(): + if line.strip(): + records.append(json.loads(line)) +gpg_rec = [r for r in records if r.get("fingerprint_scheme") == "openpgp-fingerprint-v1"] +mini_rec = [r for r in records if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1"] +if len(gpg_rec) != 1 or gpg_rec[0].get("key_role") != "primary": + raise SystemExit("error: staged NDJSON must contain one GPG primary record") +if len(mini_rec) != 1: + raise SystemExit("error: staged NDJSON must contain one minisign blob-SHA record") +if gpg_rec[0].get("fingerprint") != want["gpg"]: + raise SystemExit("error: staged NDJSON GPG fingerprint != staged TXT") +if mini_rec[0].get("fingerprint") != want["minisign"]: + raise SystemExit("error: staged NDJSON minisign fingerprint != staged TXT") gpg = json.loads(gpg_raw) if len(gpg) != 1 or gpg[0].get("key_role") != "primary": - raise SystemExit("error: GPG pin check did not yield one primary record") -if gpg[0].get("fingerprint") != want.get("gpg"): - raise SystemExit("error: GPG fingerprint does not match keys/expected-fingerprints.txt") + raise SystemExit("error: GPG recompute did not yield one primary record") +if gpg[0].get("fingerprint") != want["gpg"]: + raise SystemExit("error: recomputed GPG fingerprint does not match staged TXT") mini = [ r @@ -64,9 +110,9 @@ mini = [ if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1" ] if len(mini) != 1: - raise SystemExit("error: minisign pin check did not yield one blob-SHA record") -if mini[0].get("fingerprint") != want.get("minisign"): - raise SystemExit("error: minisign fingerprint does not match keys/expected-fingerprints.txt") + raise SystemExit("error: minisign recompute did not yield one blob-SHA record") +if mini[0].get("fingerprint") != want["minisign"]: + raise SystemExit("error: recomputed minisign fingerprint does not match staged TXT") -print("[ok] exported publics match committed fingerprint pins") +print("[ok] staged pins, schema, TXT/NDJSON, and recomputed publics agree") PY diff --git a/tests/release/ceremony_test.sh b/tests/release/ceremony_test.sh new file mode 100755 index 0000000..0b0c4a5 --- /dev/null +++ b/tests/release/ceremony_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Focused probes for the release ceremony boundary (entarch P1s). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +GEN="$ROOT/scripts/generate-checksums.sh" +VERIFY="$ROOT/scripts/verify-public-keys.sh" +FAIL=0 + +note() { printf '%s\n' "$*"; } +fail() { note "FAIL: $*"; FAIL=$((FAIL + 1)); } +pass() { note "PASS: $*"; } + +# P1: notes+pins without archives must fail. +workdir="$(mktemp -d)" +mkdir -p "$workdir" +printf 'notes\n' >"$workdir/release-notes-v0.1.3.md" +printf 'gpg A\nminisign B\n' >"$workdir/expected-fingerprints.txt" +printf '{}\n' >"$workdir/expected-fingerprints.ndjson" +if "$GEN" "$workdir" v0.1.3 >/dev/null 2>&1; then + fail "generate-checksums accepted zero archives" +else + pass "generate-checksums refuses provenance-only dir" +fi +rm -rf "$workdir" + +# P1: staged TXT != keys/ must fail (when keys/ exist). +# Use a temp repo-root illusion via verifying DIR only vs ROOT keys. +# If keys/ pins exist in this checkout, copy them then mutate staged TXT. +if [ -f "$ROOT/keys/expected-fingerprints.txt" ]; then + stage="$(mktemp -d)" + cp "$ROOT/keys/expected-fingerprints.txt" "$stage/expected-fingerprints.txt" + cp "$ROOT/keys/expected-fingerprints.ndjson" "$stage/expected-fingerprints.ndjson" + # dummy pubs so the script gets past file-exists; then cmp on txt fires first + printf 'not-a-key\n' >"$stage/decernor-minisign.pub" + printf 'not-a-key\n' >"$stage/decernor-release-signing-key.asc" + printf 'gpg DEADBEEF\nminisign deadbeef\n' >"$stage/expected-fingerprints.txt" + if "$VERIFY" "$stage" >/dev/null 2>&1; then + fail "verify accepted staged TXT that differs from keys/" + else + pass "verify refuses staged TXT that differs from keys/" + fi + rm -rf "$stage" +else + note "SKIP: keys/ pins not in tree" +fi + +if [ "$FAIL" -ne 0 ]; then + note "$FAIL ceremony probe(s) failed" + exit 1 +fi +note "all ceremony probes passed" From fa195b6d7b1c3511b95ed104602a49e983e1032a Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 17:47:02 -0400 Subject: [PATCH 5/7] fix: first-use pin rollback and exact pin shapes Remove a newly installed pin when the second install fails and no prior pair existed. Require exactly two TXT fields and two NDJSON records on the staged payload. Changes: - Add atomic-install-pair with first-use rollback - Tighten staged TXT/NDJSON shape checks - Validate schema records via a temp file and trap - Extend ceremony probes for writer and shape faults Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/atomic-install-pair.sh | 61 +++++++++++++++++++++++++ scripts/insert-expected-fingerprints.sh | 45 ++---------------- scripts/verify-public-keys.sh | 24 ++++++---- tests/release/ceremony_test.sh | 41 +++++++++++++++++ 4 files changed, 119 insertions(+), 52 deletions(-) create mode 100755 scripts/atomic-install-pair.sh diff --git a/scripts/atomic-install-pair.sh b/scripts/atomic-install-pair.sh new file mode 100755 index 0000000..1580aaf --- /dev/null +++ b/scripts/atomic-install-pair.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Atomically replace dest/expected-fingerprints.{ndjson,txt} from a staging pair. +# First-use: if the second install fails, remove any newly installed dest. +# Usage: atomic-install-pair.sh +set -euo pipefail + +STAGING=${1:?} +DEST=${2:?} +SRC_NDJSON="$STAGING/expected-fingerprints.ndjson" +SRC_TXT="$STAGING/expected-fingerprints.txt" +DEST_NDJSON="$DEST/expected-fingerprints.ndjson" +DEST_TXT="$DEST/expected-fingerprints.txt" +NEW_NDJSON="$DEST/expected-fingerprints.ndjson.new" +NEW_TXT="$DEST/expected-fingerprints.txt.new" +BAK_NDJSON="$DEST/expected-fingerprints.ndjson.bak" +BAK_TXT="$DEST/expected-fingerprints.txt.bak" + +if [ ! -f "$SRC_NDJSON" ] || [ ! -f "$SRC_TXT" ]; then + echo "error: staging pair incomplete" >&2 + exit 2 +fi +mkdir -p "$DEST" + +had_ndjson=0 +had_txt=0 +[ -f "$DEST_NDJSON" ] && had_ndjson=1 && cp "$DEST_NDJSON" "$BAK_NDJSON" +[ -f "$DEST_TXT" ] && had_txt=1 && cp "$DEST_TXT" "$BAK_TXT" + +cp "$SRC_NDJSON" "$NEW_NDJSON" +cp "$SRC_TXT" "$NEW_TXT" + +rollback() { + rm -f "$NEW_NDJSON" "$NEW_TXT" + if [ "$had_ndjson" -eq 1 ]; then + mv -f "$BAK_NDJSON" "$DEST_NDJSON" + else + rm -f "$DEST_NDJSON" + fi + if [ "$had_txt" -eq 1 ]; then + mv -f "$BAK_TXT" "$DEST_TXT" + else + rm -f "$DEST_TXT" + fi +} + +if ! mv -f "$NEW_NDJSON" "$DEST_NDJSON"; then + rollback + echo "error: failed to install ndjson pin" >&2 + exit 1 +fi +if [ "${DECERNOR_TEST_FAIL_SECOND:-}" = 1 ]; then + rollback + echo "error: failed to install txt pin; restored previous pair" >&2 + exit 1 +fi +if ! mv -f "$NEW_TXT" "$DEST_TXT"; then + rollback + echo "error: failed to install txt pin; restored previous pair" >&2 + exit 1 +fi +rm -f "$BAK_NDJSON" "$BAK_TXT" diff --git a/scripts/insert-expected-fingerprints.sh b/scripts/insert-expected-fingerprints.sh index 26163d9..68abd10 100755 --- a/scripts/insert-expected-fingerprints.sh +++ b/scripts/insert-expected-fingerprints.sh @@ -112,45 +112,6 @@ while IFS= read -r line; do "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$WORKDIR/one.json" >/dev/null done <"$STAGING/expected-fingerprints.ndjson" -KEYS="$ROOT/keys" -mkdir -p "$KEYS" -NEW_NDJSON="$KEYS/expected-fingerprints.ndjson.new" -NEW_TXT="$KEYS/expected-fingerprints.txt.new" -DEST_NDJSON="$KEYS/expected-fingerprints.ndjson" -DEST_TXT="$KEYS/expected-fingerprints.txt" -BAK_NDJSON="$KEYS/expected-fingerprints.ndjson.bak" -BAK_TXT="$KEYS/expected-fingerprints.txt.bak" - -cp "$STAGING/expected-fingerprints.ndjson" "$NEW_NDJSON" -cp "$STAGING/expected-fingerprints.txt" "$NEW_TXT" - -rollback() { - rm -f "$NEW_NDJSON" "$NEW_TXT" - if [ -f "$BAK_NDJSON" ]; then - mv -f "$BAK_NDJSON" "$DEST_NDJSON" - fi - if [ -f "$BAK_TXT" ]; then - mv -f "$BAK_TXT" "$DEST_TXT" - fi -} - -if [ -f "$DEST_NDJSON" ]; then - cp "$DEST_NDJSON" "$BAK_NDJSON" -fi -if [ -f "$DEST_TXT" ]; then - cp "$DEST_TXT" "$BAK_TXT" -fi - -if ! mv -f "$NEW_NDJSON" "$DEST_NDJSON"; then - rollback - echo "error: failed to install ndjson pin" >&2 - exit 1 -fi -if ! mv -f "$NEW_TXT" "$DEST_TXT"; then - rollback - echo "error: failed to install txt pin; restored previous pair" >&2 - exit 1 -fi -rm -f "$BAK_NDJSON" "$BAK_TXT" -echo "[ok] wrote $DEST_NDJSON" -echo "[ok] wrote $DEST_TXT" +"$ROOT/scripts/atomic-install-pair.sh" "$STAGING" "$ROOT/keys" +echo "[ok] wrote $ROOT/keys/expected-fingerprints.ndjson" +echo "[ok] wrote $ROOT/keys/expected-fingerprints.txt" diff --git a/scripts/verify-public-keys.sh b/scripts/verify-public-keys.sh index 94d75b7..944ec82 100755 --- a/scripts/verify-public-keys.sh +++ b/scripts/verify-public-keys.sh @@ -52,12 +52,14 @@ if [ -z "$DECERNOR_BIN" ]; then fi fi +ONE_JSON="$(mktemp)" +cleanup_one() { rm -f "$ONE_JSON"; } +trap cleanup_one EXIT while IFS= read -r line; do [ -n "$line" ] || continue - printf '%s\n' "$line" >"$DIR/.pin-one.json" - "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$DIR/.pin-one.json" >/dev/null + printf '%s\n' "$line" >"$ONE_JSON" + "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$ONE_JSON" >/dev/null done <"$STAGED_NDJSON" -rm -f "$DIR/.pin-one.json" GPG_JSON="$("$DECERNOR_BIN" fingerprint "$ASC" --class public --kind gpg --format json --path-mode none --gpg-role primary)" MINI_JSON="$("$DECERNOR_BIN" fingerprint "$PUB" --class public --kind minisign --format json --path-mode none)" @@ -68,15 +70,15 @@ import pathlib import sys txt_path, ndjson_path, gpg_raw, mini_raw = sys.argv[1:5] -lines = [ln.strip() for ln in pathlib.Path(txt_path).read_text().splitlines() if ln.strip() and not ln.startswith("#")] -if len(lines) != 2: - raise SystemExit("error: staged TXT must have exactly two non-comment lines") +physical = pathlib.Path(txt_path).read_text().splitlines() +if len(physical) != 2: + raise SystemExit("error: staged TXT must be exactly two physical lines") want = {} -for line in lines: +for line in physical: parts = line.split() - if len(parts) < 2: - raise SystemExit("error: staged TXT line is malformed") - algo, fp = parts[0], parts[1] + if len(parts) != 2: + raise SystemExit("error: staged TXT line must have exactly two fields") + algo, fp = parts if algo in want: raise SystemExit(f"error: duplicate {algo} line in staged TXT") want[algo] = fp @@ -87,6 +89,8 @@ records = [] for line in pathlib.Path(ndjson_path).read_text().splitlines(): if line.strip(): records.append(json.loads(line)) +if len(records) != 2: + raise SystemExit("error: staged NDJSON must contain exactly two records") gpg_rec = [r for r in records if r.get("fingerprint_scheme") == "openpgp-fingerprint-v1"] mini_rec = [r for r in records if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1"] if len(gpg_rec) != 1 or gpg_rec[0].get("key_role") != "primary": diff --git a/tests/release/ceremony_test.sh b/tests/release/ceremony_test.sh index 0b0c4a5..2441996 100755 --- a/tests/release/ceremony_test.sh +++ b/tests/release/ceremony_test.sh @@ -45,6 +45,47 @@ else note "SKIP: keys/ pins not in tree" fi +# P1: first-use second-install failure must not leave a half pair. +pair="$(mktemp -d)" +empty="$(mktemp -d)" +printf '{}\n{}\n' >"$pair/expected-fingerprints.ndjson" +printf 'gpg A\nminisign B\n' >"$pair/expected-fingerprints.txt" +if DECERNOR_TEST_FAIL_SECOND=1 "$ROOT/scripts/atomic-install-pair.sh" "$pair" "$empty" >/dev/null 2>&1; then + fail "first-use second-install failure returned success" +else + if [ -e "$empty/expected-fingerprints.ndjson" ] || [ -e "$empty/expected-fingerprints.txt" ]; then + fail "first-use rollback left a half pair" + else + pass "first-use second-install failure leaves no dest files" + fi +fi +rm -rf "$pair" "$empty" + +# P2: extra TXT token / extra NDJSON record. +if [ -f "$ROOT/keys/expected-fingerprints.txt" ]; then + stage="$(mktemp -d)" + cp "$ROOT/keys/expected-fingerprints.txt" "$stage/expected-fingerprints.txt" + cp "$ROOT/keys/expected-fingerprints.ndjson" "$stage/expected-fingerprints.ndjson" + printf 'not-a-key\n' >"$stage/decernor-minisign.pub" + printf 'not-a-key\n' >"$stage/decernor-release-signing-key.asc" + # extra field: still two lines but third token + awk '{print $0, "extra"}' "$ROOT/keys/expected-fingerprints.txt" >"$stage/expected-fingerprints.txt" + if "$VERIFY" "$stage" >/dev/null 2>&1; then + fail "verify accepted extra TXT token" + else + pass "verify refuses extra TXT token" + fi + cp "$ROOT/keys/expected-fingerprints.txt" "$stage/expected-fingerprints.txt" + # extra NDJSON record + cat "$ROOT/keys/expected-fingerprints.ndjson" "$ROOT/keys/expected-fingerprints.ndjson" >"$stage/expected-fingerprints.ndjson" + if "$VERIFY" "$stage" >/dev/null 2>&1; then + fail "verify accepted extra NDJSON record" + else + pass "verify refuses extra NDJSON record" + fi + rm -rf "$stage" +fi + if [ "$FAIL" -ne 0 ]; then note "$FAIL ceremony probe(s) failed" exit 1 From 58f18e451f7b78965ea71ae2be09c674f9edbf92 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 17:56:40 -0400 Subject: [PATCH 6/7] fix: install-window rollback and testable pin-pair shapes Arm process-lifetime rollback for the pin install window and extract shape/schema checks so ceremony probes assert the specific refusal. Changes: - Trap EXIT/INT/TERM/HUP until both dest files are installed - Add validate-pin-pair helper (shape + schema, no keys/ compare) - Probe signal-after-first-install and exact shape errors Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/atomic-install-pair.sh | 16 +++-- scripts/insert-expected-fingerprints.sh | 9 +-- scripts/validate-pin-pair.sh | 78 ++++++++++++++++++++++ scripts/verify-public-keys.sh | 46 ++----------- tests/release/ceremony_test.sh | 88 +++++++++++++++++-------- 5 files changed, 158 insertions(+), 79 deletions(-) create mode 100755 scripts/validate-pin-pair.sh diff --git a/scripts/atomic-install-pair.sh b/scripts/atomic-install-pair.sh index 1580aaf..817385e 100755 --- a/scripts/atomic-install-pair.sh +++ b/scripts/atomic-install-pair.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# Atomically replace dest/expected-fingerprints.{ndjson,txt} from a staging pair. -# First-use: if the second install fails, remove any newly installed dest. +# Replace dest/expected-fingerprints.{ndjson,txt} from a staging pair. +# Install-window rollback on error or INT/TERM/HUP: restore prior dest files +# or remove newly installed dest when none existed. Two dest files are not +# power-loss atomic; a crash mid-pair can still leave residue. # Usage: atomic-install-pair.sh set -euo pipefail @@ -43,19 +45,23 @@ rollback() { fi } +trap rollback EXIT INT TERM HUP + if ! mv -f "$NEW_NDJSON" "$DEST_NDJSON"; then - rollback echo "error: failed to install ndjson pin" >&2 exit 1 fi +if [ "${DECERNOR_TEST_KILL_AFTER_FIRST:-}" = 1 ]; then + kill -s TERM $$ +fi if [ "${DECERNOR_TEST_FAIL_SECOND:-}" = 1 ]; then - rollback echo "error: failed to install txt pin; restored previous pair" >&2 exit 1 fi if ! mv -f "$NEW_TXT" "$DEST_TXT"; then - rollback echo "error: failed to install txt pin; restored previous pair" >&2 exit 1 fi + +trap - EXIT INT TERM HUP rm -f "$BAK_NDJSON" "$BAK_TXT" diff --git a/scripts/insert-expected-fingerprints.sh b/scripts/insert-expected-fingerprints.sh index 68abd10..d77c0b6 100755 --- a/scripts/insert-expected-fingerprints.sh +++ b/scripts/insert-expected-fingerprints.sh @@ -105,12 +105,9 @@ out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "expected-fingerprints.txt").write_text(f"gpg {gpg_fp}\nminisign {mini_fp}\n") PY -SCHEMA="$ROOT/schemas/fingerprint-record.v0.schema.json" -while IFS= read -r line; do - [ -n "$line" ] || continue - printf '%s\n' "$line" >"$WORKDIR/one.json" - "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$WORKDIR/one.json" >/dev/null -done <"$STAGING/expected-fingerprints.ndjson" +"$ROOT/scripts/validate-pin-pair.sh" \ + "$STAGING/expected-fingerprints.txt" \ + "$STAGING/expected-fingerprints.ndjson" "$ROOT/scripts/atomic-install-pair.sh" "$STAGING" "$ROOT/keys" echo "[ok] wrote $ROOT/keys/expected-fingerprints.ndjson" diff --git a/scripts/validate-pin-pair.sh b/scripts/validate-pin-pair.sh new file mode 100755 index 0000000..b8c8f2b --- /dev/null +++ b/scripts/validate-pin-pair.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Validate pin-pair shape and DDR-0001 schema. No pub recompute, no keys/ cmp. +# Usage: validate-pin-pair.sh [schema] +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TXT=${1:?} +NDJSON=${2:?} +SCHEMA=${3:-"$ROOT/schemas/fingerprint-record.v0.schema.json"} + +if [ ! -f "$TXT" ] || [ ! -f "$NDJSON" ]; then + echo "error: pin pair incomplete" >&2 + exit 2 +fi +if [ ! -f "$SCHEMA" ]; then + echo "error: schema not found: $SCHEMA" >&2 + exit 2 +fi + +DECERNOR_BIN="${DECERNOR_BIN:-}" +if [ -z "$DECERNOR_BIN" ]; then + if [ -x "$ROOT/bin/decernor" ]; then + DECERNOR_BIN="$ROOT/bin/decernor" + elif command -v decernor >/dev/null 2>&1; then + DECERNOR_BIN="$(command -v decernor)" + else + echo "error: decernor binary not found" >&2 + exit 2 + fi +fi + +ONE_JSON="$(mktemp)" +cleanup_one() { rm -f "$ONE_JSON"; } +trap cleanup_one EXIT +while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + printf '%s\n' "$line" >"$ONE_JSON" + "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$ONE_JSON" >/dev/null +done <"$NDJSON" + +python3 - "$TXT" "$NDJSON" <<'PY' +import json +import pathlib +import sys + +txt_path, ndjson_path = sys.argv[1], sys.argv[2] +physical = pathlib.Path(txt_path).read_text().splitlines() +if len(physical) != 2: + raise SystemExit("error: pin TXT must be exactly two physical lines") +want = {} +for line in physical: + parts = line.split() + if len(parts) != 2: + raise SystemExit("error: pin TXT line must have exactly two fields") + algo, fp = parts + if algo in want: + raise SystemExit(f"error: duplicate {algo} line in pin TXT") + want[algo] = fp +if set(want) != {"gpg", "minisign"}: + raise SystemExit("error: pin TXT must contain exactly one gpg and one minisign line") + +records = [] +for line in pathlib.Path(ndjson_path).read_text().splitlines(): + if line.strip(): + records.append(json.loads(line)) +if len(records) != 2: + raise SystemExit("error: pin NDJSON must contain exactly two records") +gpg_rec = [r for r in records if r.get("fingerprint_scheme") == "openpgp-fingerprint-v1"] +mini_rec = [r for r in records if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1"] +if len(gpg_rec) != 1 or gpg_rec[0].get("key_role") != "primary": + raise SystemExit("error: pin NDJSON must contain one GPG primary record") +if len(mini_rec) != 1: + raise SystemExit("error: pin NDJSON must contain one minisign blob-SHA record") +if gpg_rec[0].get("fingerprint") != want["gpg"]: + raise SystemExit("error: pin NDJSON GPG fingerprint != pin TXT") +if mini_rec[0].get("fingerprint") != want["minisign"]: + raise SystemExit("error: pin NDJSON minisign fingerprint != pin TXT") +PY diff --git a/scripts/verify-public-keys.sh b/scripts/verify-public-keys.sh index 944ec82..7ca3ab2 100755 --- a/scripts/verify-public-keys.sh +++ b/scripts/verify-public-keys.sh @@ -40,6 +40,8 @@ if grep -Eqi "PRIVATE|SECRET|BEGIN PGP PRIVATE KEY|minisign secret key" "$PUB" " exit 1 fi +"$ROOT/scripts/validate-pin-pair.sh" "$STAGED_TXT" "$STAGED_NDJSON" "$SCHEMA" + DECERNOR_BIN="${DECERNOR_BIN:-}" if [ -z "$DECERNOR_BIN" ]; then if [ -x "$ROOT/bin/decernor" ]; then @@ -52,55 +54,19 @@ if [ -z "$DECERNOR_BIN" ]; then fi fi -ONE_JSON="$(mktemp)" -cleanup_one() { rm -f "$ONE_JSON"; } -trap cleanup_one EXIT -while IFS= read -r line; do - [ -n "$line" ] || continue - printf '%s\n' "$line" >"$ONE_JSON" - "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$ONE_JSON" >/dev/null -done <"$STAGED_NDJSON" - GPG_JSON="$("$DECERNOR_BIN" fingerprint "$ASC" --class public --kind gpg --format json --path-mode none --gpg-role primary)" MINI_JSON="$("$DECERNOR_BIN" fingerprint "$PUB" --class public --kind minisign --format json --path-mode none)" -python3 - "$STAGED_TXT" "$STAGED_NDJSON" "$GPG_JSON" "$MINI_JSON" <<'PY' +python3 - "$STAGED_TXT" "$GPG_JSON" "$MINI_JSON" <<'PY' import json import pathlib import sys -txt_path, ndjson_path, gpg_raw, mini_raw = sys.argv[1:5] -physical = pathlib.Path(txt_path).read_text().splitlines() -if len(physical) != 2: - raise SystemExit("error: staged TXT must be exactly two physical lines") +txt_path, gpg_raw, mini_raw = sys.argv[1:4] want = {} -for line in physical: - parts = line.split() - if len(parts) != 2: - raise SystemExit("error: staged TXT line must have exactly two fields") - algo, fp = parts - if algo in want: - raise SystemExit(f"error: duplicate {algo} line in staged TXT") +for line in pathlib.Path(txt_path).read_text().splitlines(): + algo, fp = line.split() want[algo] = fp -if set(want) != {"gpg", "minisign"}: - raise SystemExit("error: staged TXT must contain exactly one gpg and one minisign line") - -records = [] -for line in pathlib.Path(ndjson_path).read_text().splitlines(): - if line.strip(): - records.append(json.loads(line)) -if len(records) != 2: - raise SystemExit("error: staged NDJSON must contain exactly two records") -gpg_rec = [r for r in records if r.get("fingerprint_scheme") == "openpgp-fingerprint-v1"] -mini_rec = [r for r in records if r.get("fingerprint_scheme") == "minisign-public-blob-sha256-v1"] -if len(gpg_rec) != 1 or gpg_rec[0].get("key_role") != "primary": - raise SystemExit("error: staged NDJSON must contain one GPG primary record") -if len(mini_rec) != 1: - raise SystemExit("error: staged NDJSON must contain one minisign blob-SHA record") -if gpg_rec[0].get("fingerprint") != want["gpg"]: - raise SystemExit("error: staged NDJSON GPG fingerprint != staged TXT") -if mini_rec[0].get("fingerprint") != want["minisign"]: - raise SystemExit("error: staged NDJSON minisign fingerprint != staged TXT") gpg = json.loads(gpg_raw) if len(gpg) != 1 or gpg[0].get("key_role") != "primary": diff --git a/tests/release/ceremony_test.sh b/tests/release/ceremony_test.sh index 2441996..6b292d3 100755 --- a/tests/release/ceremony_test.sh +++ b/tests/release/ceremony_test.sh @@ -1,17 +1,30 @@ #!/usr/bin/env bash -# Focused probes for the release ceremony boundary (entarch P1s). +# Focused probes for the release ceremony boundary. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" GEN="$ROOT/scripts/generate-checksums.sh" VERIFY="$ROOT/scripts/verify-public-keys.sh" +VALIDATE="$ROOT/scripts/validate-pin-pair.sh" +INSTALL="$ROOT/scripts/atomic-install-pair.sh" FAIL=0 note() { printf '%s\n' "$*"; } fail() { note "FAIL: $*"; FAIL=$((FAIL + 1)); } pass() { note "PASS: $*"; } -# P1: notes+pins without archives must fail. +expect_err() { + local haystack=$1 + local needle=$2 + local label=$3 + if printf '%s\n' "$haystack" | grep -Fq "$needle"; then + pass "$label" + else + fail "$label (wanted: $needle; got: $haystack)" + fi +} + +# notes+pins without archives must fail. workdir="$(mktemp -d)" mkdir -p "$workdir" printf 'notes\n' >"$workdir/release-notes-v0.1.3.md" @@ -24,33 +37,32 @@ else fi rm -rf "$workdir" -# P1: staged TXT != keys/ must fail (when keys/ exist). -# Use a temp repo-root illusion via verifying DIR only vs ROOT keys. -# If keys/ pins exist in this checkout, copy them then mutate staged TXT. +# staged TXT != keys/ must fail (when keys/ exist). if [ -f "$ROOT/keys/expected-fingerprints.txt" ]; then stage="$(mktemp -d)" cp "$ROOT/keys/expected-fingerprints.txt" "$stage/expected-fingerprints.txt" cp "$ROOT/keys/expected-fingerprints.ndjson" "$stage/expected-fingerprints.ndjson" - # dummy pubs so the script gets past file-exists; then cmp on txt fires first printf 'not-a-key\n' >"$stage/decernor-minisign.pub" printf 'not-a-key\n' >"$stage/decernor-release-signing-key.asc" printf 'gpg DEADBEEF\nminisign deadbeef\n' >"$stage/expected-fingerprints.txt" - if "$VERIFY" "$stage" >/dev/null 2>&1; then + out="$("$VERIFY" "$stage" 2>&1)" && status=0 || status=$? + if [ "$status" -eq 0 ]; then fail "verify accepted staged TXT that differs from keys/" else - pass "verify refuses staged TXT that differs from keys/" + expect_err "$out" "staged TXT differs from keys/expected-fingerprints.txt" \ + "verify refuses staged TXT that differs from keys/" fi rm -rf "$stage" else note "SKIP: keys/ pins not in tree" fi -# P1: first-use second-install failure must not leave a half pair. +# first-use second-install failure must not leave a half pair. pair="$(mktemp -d)" empty="$(mktemp -d)" printf '{}\n{}\n' >"$pair/expected-fingerprints.ndjson" printf 'gpg A\nminisign B\n' >"$pair/expected-fingerprints.txt" -if DECERNOR_TEST_FAIL_SECOND=1 "$ROOT/scripts/atomic-install-pair.sh" "$pair" "$empty" >/dev/null 2>&1; then +if DECERNOR_TEST_FAIL_SECOND=1 "$INSTALL" "$pair" "$empty" >/dev/null 2>&1; then fail "first-use second-install failure returned success" else if [ -e "$empty/expected-fingerprints.ndjson" ] || [ -e "$empty/expected-fingerprints.txt" ]; then @@ -61,29 +73,49 @@ else fi rm -rf "$pair" "$empty" -# P2: extra TXT token / extra NDJSON record. +# signal after first dest install must roll back (install-window trap). +pair="$(mktemp -d)" +empty="$(mktemp -d)" +printf '{}\n{}\n' >"$pair/expected-fingerprints.ndjson" +printf 'gpg A\nminisign B\n' >"$pair/expected-fingerprints.txt" +set +e +DECERNOR_TEST_KILL_AFTER_FIRST=1 "$INSTALL" "$pair" "$empty" >/dev/null 2>&1 +set -e +if [ -e "$empty/expected-fingerprints.ndjson" ] || [ -e "$empty/expected-fingerprints.txt" ] || + [ -e "$empty/expected-fingerprints.ndjson.new" ] || [ -e "$empty/expected-fingerprints.txt.new" ]; then + fail "signal-after-first-install left dest residue" +else + pass "signal-after-first-install leaves no dest files" +fi +rm -rf "$pair" "$empty" + +# extra TXT token: helper must emit the two-field error (not keys/ cmp). if [ -f "$ROOT/keys/expected-fingerprints.txt" ]; then - stage="$(mktemp -d)" - cp "$ROOT/keys/expected-fingerprints.txt" "$stage/expected-fingerprints.txt" - cp "$ROOT/keys/expected-fingerprints.ndjson" "$stage/expected-fingerprints.ndjson" - printf 'not-a-key\n' >"$stage/decernor-minisign.pub" - printf 'not-a-key\n' >"$stage/decernor-release-signing-key.asc" - # extra field: still two lines but third token - awk '{print $0, "extra"}' "$ROOT/keys/expected-fingerprints.txt" >"$stage/expected-fingerprints.txt" - if "$VERIFY" "$stage" >/dev/null 2>&1; then - fail "verify accepted extra TXT token" + mut="$(mktemp -d)" + awk '{print $0, "extra"}' "$ROOT/keys/expected-fingerprints.txt" >"$mut/expected-fingerprints.txt" + cp "$ROOT/keys/expected-fingerprints.ndjson" "$mut/expected-fingerprints.ndjson" + out="$("$VALIDATE" "$mut/expected-fingerprints.txt" "$mut/expected-fingerprints.ndjson" 2>&1)" && status=0 || status=$? + if [ "$status" -eq 0 ]; then + fail "validate accepted extra TXT token" else - pass "verify refuses extra TXT token" + expect_err "$out" "exactly two fields" "validate refuses extra TXT token" fi - cp "$ROOT/keys/expected-fingerprints.txt" "$stage/expected-fingerprints.txt" - # extra NDJSON record - cat "$ROOT/keys/expected-fingerprints.ndjson" "$ROOT/keys/expected-fingerprints.ndjson" >"$stage/expected-fingerprints.ndjson" - if "$VERIFY" "$stage" >/dev/null 2>&1; then - fail "verify accepted extra NDJSON record" + rm -rf "$mut" +fi + +# extra NDJSON record: helper must emit the two-record error (not keys/ cmp). +if [ -f "$ROOT/keys/expected-fingerprints.ndjson" ]; then + mut="$(mktemp -d)" + cp "$ROOT/keys/expected-fingerprints.txt" "$mut/expected-fingerprints.txt" + cat "$ROOT/keys/expected-fingerprints.ndjson" "$ROOT/keys/expected-fingerprints.ndjson" \ + >"$mut/expected-fingerprints.ndjson" + out="$("$VALIDATE" "$mut/expected-fingerprints.txt" "$mut/expected-fingerprints.ndjson" 2>&1)" && status=0 || status=$? + if [ "$status" -eq 0 ]; then + fail "validate accepted extra NDJSON record" else - pass "verify refuses extra NDJSON record" + expect_err "$out" "exactly two records" "validate refuses extra NDJSON record" fi - rm -rf "$stage" + rm -rf "$mut" fi if [ "$FAIL" -ne 0 ]; then From 2af4c73ac9b45e4e970525dfcfffc0f1956c4713 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Tue, 18 Aug 2026 18:00:20 -0400 Subject: [PATCH 7/7] fix: check pin-pair shape before requiring decernor Ceremony shape probes must fail on the two-field / two-record errors without a built binary. Schema validation still runs after shape. Generated by Grok 4.6 via Grok Build under supervision of @3leapsdave Co-Authored-By: Grok 4.6 Role: cxotech Committer-of-Record: Dave Thompson [@3leapsdave] --- scripts/validate-pin-pair.sh | 43 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/scripts/validate-pin-pair.sh b/scripts/validate-pin-pair.sh index b8c8f2b..a7fc1e6 100755 --- a/scripts/validate-pin-pair.sh +++ b/scripts/validate-pin-pair.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Validate pin-pair shape and DDR-0001 schema. No pub recompute, no keys/ cmp. +# Shape checks run first so they do not depend on a built decernor binary. # Usage: validate-pin-pair.sh [schema] set -euo pipefail @@ -17,27 +18,6 @@ if [ ! -f "$SCHEMA" ]; then exit 2 fi -DECERNOR_BIN="${DECERNOR_BIN:-}" -if [ -z "$DECERNOR_BIN" ]; then - if [ -x "$ROOT/bin/decernor" ]; then - DECERNOR_BIN="$ROOT/bin/decernor" - elif command -v decernor >/dev/null 2>&1; then - DECERNOR_BIN="$(command -v decernor)" - else - echo "error: decernor binary not found" >&2 - exit 2 - fi -fi - -ONE_JSON="$(mktemp)" -cleanup_one() { rm -f "$ONE_JSON"; } -trap cleanup_one EXIT -while IFS= read -r line || [ -n "$line" ]; do - [ -n "$line" ] || continue - printf '%s\n' "$line" >"$ONE_JSON" - "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$ONE_JSON" >/dev/null -done <"$NDJSON" - python3 - "$TXT" "$NDJSON" <<'PY' import json import pathlib @@ -76,3 +56,24 @@ if gpg_rec[0].get("fingerprint") != want["gpg"]: if mini_rec[0].get("fingerprint") != want["minisign"]: raise SystemExit("error: pin NDJSON minisign fingerprint != pin TXT") PY + +DECERNOR_BIN="${DECERNOR_BIN:-}" +if [ -z "$DECERNOR_BIN" ]; then + if [ -x "$ROOT/bin/decernor" ]; then + DECERNOR_BIN="$ROOT/bin/decernor" + elif command -v decernor >/dev/null 2>&1; then + DECERNOR_BIN="$(command -v decernor)" + else + echo "error: decernor binary not found" >&2 + exit 2 + fi +fi + +ONE_JSON="$(mktemp)" +cleanup_one() { rm -f "$ONE_JSON"; } +trap cleanup_one EXIT +while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + printf '%s\n' "$line" >"$ONE_JSON" + "$DECERNOR_BIN" validate --schema "$SCHEMA" --data "$ONE_JSON" >/dev/null +done <"$NDJSON"