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..3e207bd 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" @@ -267,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/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 new file mode 100644 index 0000000..8a53923 --- /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: Accepted +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: 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 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. + +## Producer / consumer contract + +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. + +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 + +**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` | 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 +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** (matches `make release`): + +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. + +## 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 + +- 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 visibility flip. Changing DDR-0001. Hand-maintained hex in +`RELEASE_NOTES.md`. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 71babf9..dfab886 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 | 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/atomic-install-pair.sh b/scripts/atomic-install-pair.sh new file mode 100755 index 0000000..817385e --- /dev/null +++ b/scripts/atomic-install-pair.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# 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 + +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 +} + +trap rollback EXIT INT TERM HUP + +if ! mv -f "$NEW_NDJSON" "$DEST_NDJSON"; then + 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 + echo "error: failed to install txt pin; restored previous pair" >&2 + exit 1 +fi +if ! mv -f "$NEW_TXT" "$DEST_TXT"; then + 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/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..368faa3 --- /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..." + +ARCHIVES=() +for f in "decernor_${VERSION}_"*.tar.gz "decernor_${VERSION}_"*.zip; do + if [ -f "$f" ]; then + ARCHIVES+=("$f") + fi +done +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 + +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..d77c0b6 --- /dev/null +++ b/scripts/insert-expected-fingerprints.sh @@ -0,0 +1,114 @@ +#!/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" +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" "$STAGING" <<'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) +# 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 + +"$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" +echo "[ok] wrote $ROOT/keys/expected-fingerprints.txt" 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/validate-pin-pair.sh b/scripts/validate-pin-pair.sh new file mode 100755 index 0000000..a7fc1e6 --- /dev/null +++ b/scripts/validate-pin-pair.sh @@ -0,0 +1,79 @@ +#!/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 + +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 + +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 + +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" diff --git a/scripts/verify-public-keys.sh b/scripts/verify-public-keys.sh new file mode 100755 index 0000000..7ca3ab2 --- /dev/null +++ b/scripts/verify-public-keys.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# 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} +STAGED_TXT="$DIR/expected-fingerprints.txt" +STAGED_NDJSON="$DIR/expected-fingerprints.ndjson" +SCHEMA="$ROOT/schemas/fingerprint-record.v0.schema.json" + +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" + +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 + +"$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 + 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 - "$STAGED_TXT" "$GPG_JSON" "$MINI_JSON" <<'PY' +import json +import pathlib +import sys + +txt_path, gpg_raw, mini_raw = sys.argv[1:4] +want = {} +for line in pathlib.Path(txt_path).read_text().splitlines(): + 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 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 + 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 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] staged pins, schema, TXT/NDJSON, and recomputed publics agree") +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" diff --git a/tests/release/ceremony_test.sh b/tests/release/ceremony_test.sh new file mode 100755 index 0000000..6b292d3 --- /dev/null +++ b/tests/release/ceremony_test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# 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: $*"; } + +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" +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" + +# 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" + 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" + out="$("$VERIFY" "$stage" 2>&1)" && status=0 || status=$? + if [ "$status" -eq 0 ]; then + fail "verify accepted staged TXT that differs from keys/" + else + 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 + +# 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 "$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 + 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" + +# 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 + 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 + expect_err "$out" "exactly two fields" "validate refuses extra TXT token" + fi + 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 + expect_err "$out" "exactly two records" "validate refuses extra NDJSON record" + fi + rm -rf "$mut" +fi + +if [ "$FAIL" -ne 0 ]; then + note "$FAIL ceremony probe(s) failed" + exit 1 +fi +note "all ceremony probes passed"