clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Sign a release for its repo and tag, not only its sums

cat committed 3e0e625
SHA256SUMS.sig signed SHA256SUMS alone, so an older release's files and
signature verified as a newer one's, or as a copy's in another repo. It
now signs a short header and then SHA256SUMS' bytes: "forge-release 1",
"repo <owner>/<repo>" and "tag <tag>" lines. SHA256SUMS itself stays
plain sha256sum -c input.

pre-receive rebuilds the message from the repo it is pushed to and the
ref's tag. forge release verify takes the repo and tag from --repo and
--tag, else --url, else the download path the files were saved under,
else the remote for the repo, and says which release it verified. The
releases page checks the same message.
commit
3e0e6259bd67e28e82c05677fe81384015ff01d4
parent
ccae97705e83ea43f50be202780cf21959dc271e
author
cat <admin@dev.site>
kind
feat risk score 2.4 of 3, Jev's estimate
13 files changed, +225 -83
MREADME.md47+++++++++++++------
Mbin/forge74+++++++++++++++++++++++-------
Mguide/index.html2+-
Mlib/releases.ts20+++++---
Mlib/repo.ts2+-
Mtest/lib.sh18++++++-
Mtest/scenarios/release-admin.sh2+
Atest/scenarios/release-identity.sh41+++++++++++++++++
Mtest/scenarios/release-private.sh3+-
Mtest/scenarios/release-publish.sh5++-
Mtest/scenarios/release-signature.sh27+++++++----
Mtest/scenarios/release-tag.sh1+
Mtest/scenarios/release-verify.sh66+++++++++++++++-----------
M README.md +33 -14
@@ -340,19 +340,38 @@ commit whose tree is the built output.
340340 ```sh341341 git tag v1.0342342 forge release publish v1.0 dist/greet-linux-amd64 dist/greet-1.0.tar.gz343-forge release verify ~/Downloads/greet-linux-amd64 # SHA256SUMS and its .sig next to it343+forge release verify --url https://git.example.com/dami/greet/releases/download/v1.0/ \344+ ~/Downloads/greet-linux-amd64 # SHA256SUMS and its .sig next to it344345 forge release ls # tag, file, size, sha256345346 forge release rm v1.0346347 ```347348 348-`publish` writes `SHA256SUMS` in `sha256sum`'s format, signs it with349-`ssh-keygen -Y sign -n forge-release` using the key that signs CI results,350-and makes a commit with trailers `Tag`, `Source-Commit` (the tag's commit)351-and `Built-By`, signed like a site deploy. It pushes the commit, the tag and352-your CI notes in one atomic push. The commit has no parent, so publishing353-under the tag again replaces the files and the old ones aren't kept354-reachable. `verify` checks files against `SHA256SUMS` and its signature355-against the forge's keys: `--signers <file>`, or `ssh <host> signers` with349+`publish` writes `SHA256SUMS` in `sha256sum`'s format, so `sha256sum -c`350+reads it, and signs it for the release with `ssh-keygen -Y sign -n351+forge-release`, using the key that signs CI results. What it signs is352+`SHA256SUMS` behind a short header naming the repo and tag:353+354+```355+forge-release 1356+repo dami/greet357+tag v1.0358+<the bytes of SHA256SUMS>359+```360+361+so the signature holds for that release only: v1.0's files can't pass for362+v1.1's, nor a copy in another repo for the original. The signature is363+`SHA256SUMS.sig`. Then it makes a commit with trailers `Tag`,364+`Source-Commit` (the tag's commit) and `Built-By`, signed like a site365+deploy, and pushes the commit, the tag and your CI notes in one atomic366+push. The commit has no parent, so publishing under the tag again replaces367+the files and the old ones aren't kept reachable.368+369+`verify` checks files against `SHA256SUMS`, and its signature for the370+release they say they are from: `--repo` and `--tag`, else `--url`, else371+the path they were saved under if it is a download path (`wget -x`), else372+the remote for the repo. It says which release it verified (`ok373+greet-linux-amd64: matches SHA256SUMS of dami/greet v1.0, signed by dami`).374+The forge's keys come from `--signers <file>`, or `ssh <host> signers` with356375 `--host`, `forge.allowedsigners`, `forge.host` or the remote.357376 358377 `pre-receive` takes a release from the owner and collaborators only, like a
@@ -364,8 +383,8 @@ tag, and only if:
364383 `. _ + -`, none over `git config forge.releasemaxsize` (256m unless set;365384 git's k, m and g work);366385 - `SHA256SUMS` lists every file once with its sha256, and nothing else;367-- `SHA256SUMS.sig` is a `forge-release` signature of it by any key in the368- forge's keys file;386+- `SHA256SUMS.sig` is a `forge-release` signature of it for this repo and387+ the ref's tag, by any key in the forge's keys file;369388 - with `git config forge.releaserequireci true`, the tag's tree has a370389 verified passing attestation, as `forge.requireci` asks of `main`371390 (`forge attest v1.0` before publishing).
@@ -399,12 +418,12 @@ commands answer as for a missing repo:
399418 ```sh400419 ssh git@git.example.com release ls dami/notes401420 ssh git@git.example.com release get dami/notes v1.0 tool > tool # also SHA256SUMS, SHA256SUMS.sig421+forge release verify --repo dami/notes --tag v1.0 tool402422 ```403423 404424 A verified download proves what a verified attestation does, about these405-bytes: the holder of a registered key vouched for this `SHA256SUMS`. It406-doesn't say for which tag; an older release's files verify with their own407-`SHA256SUMS` too.425+bytes and the release they name: the holder of a key the forge knows426+vouched for this `SHA256SUMS` as that repo's tag.408427 409428 ### Search and highlighting410429
M bin/forge +57 -17
@@ -14,7 +14,7 @@
1414 # forge site deploy [--here] [name...] build .forge/sites entries and publish them1515 # forge site push <dir> <name> publish a prebuilt directory1616 # forge release publish <tag> <file>... publish files for a tag, with a signed SHA256SUMS17-# forge release verify [--sums f] [--signers f] [--host git@host] <file>... check downloads17+# forge release verify [--repo r --tag t | --url u] [--sums f] [--signers f] [--host h] <file>... check downloads1818 # forge release ls | rm <tag> this repo's releases; delete one1919 # forge import [--to git@host] [--branches] [--private] <git-url> copy a repo into your forge account2020 #
@@ -534,6 +534,15 @@ release_tag_ok() {
534534 case $1 in '' | latest | *[!A-Za-z0-9._/+-]*) return 1 ;; esac535535 }536536 537+# release_message <owner>/<repo> <tag> <SHA256SUMS> <out>: what538+# SHA256SUMS.sig signs. SHA256SUMS stays plain sha256sum -c input; the539+# header names the release, so its signature holds for that repo and tag540+# only: an older release's files can't pass for a newer one's, nor a copy541+# in another repo for the original.542+release_message() {543+ { printf 'forge-release 1\nrepo %s\ntag %s\n' "$1" "$2"; cat "$3"; } >"$4"544+}545+537546 # The commit has no parent: publishing under the tag again replaces the538547 # files, and the old ones become garbage instead of staying in the repo for539548 # good. The tag and CI notes go in the same atomic push.
@@ -553,6 +562,7 @@ release_publish() {
553562 done554563 count=$#555564 key=$(signing_key)565+ repo=$(remote_repo) || exit 1556566 tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-release.XXXXXX")557567 for f in "$@"; do558568 n=$(basename -- "$f")
@@ -562,8 +572,10 @@ release_publish() {
562572 printf '%s %s\n' "$(sha256 "$f")" "$n" >>"$tmp/sums"563573 done564574 LC_ALL=C sort -k 2 "$tmp/sums" >"$tmp/SHA256SUMS"565- ssh-keygen -Y sign -n "$RNS" -f "$key" "$tmp/SHA256SUMS" 2>"$tmp/sign.err" ||575+ release_message "$repo" "$tag" "$tmp/SHA256SUMS" "$tmp/message"576+ ssh-keygen -Y sign -n "$RNS" -f "$key" "$tmp/message" 2>"$tmp/sign.err" ||566577 { cat "$tmp/sign.err" >&2; rm -rf "$tmp"; die "release: signing SHA256SUMS failed"; }578+ mv "$tmp/message.sig" "$tmp/SHA256SUMS.sig"567579 for f in SHA256SUMS SHA256SUMS.sig; do printf '100644 blob %s\t%s\n' "$(git hash-object -w -- "$tmp/$f")" "$f" >>"$tmp/tree"; done568580 tree=$(git mktree <"$tmp/tree")569581 rm -rf "$tmp"
@@ -576,28 +588,52 @@ release_publish() {
576588 set -- "refs/tags/$tag:refs/tags/$tag" "+$commit:refs/releases/$tag"577589 if git rev-parse -q --verify refs/notes/ci >/dev/null; then set -- "$@" refs/notes/ci:refs/notes/ci; fi578590 git push -q --atomic "$r" "$@" || die "release $tag: $r refused it"579- say "release $tag: published $(git rev-parse --short "$commit"), $count file(s) and SHA256SUMS, signed with $key"591+ say "release $tag: published $(git rev-parse --short "$commit"), $count file(s) and SHA256SUMS, signed with $key for $repo $tag"592+}593+594+# release_of <path or URL>: "<owner>/<repo> <tag>" when it names a release's595+# download directory, or a file in one (/<owner>/<repo>/releases/download/<tag>/).596+release_of() {597+ printf '%s\n' "$1" | sed -n 's|^.*/\([^/]*\)/\([^/]*\)/releases/download/\([^/]*\)\(/[^/]*\)\{0,1\}/*$|\1/\2 \3|p' | tr '~' /580598 }581599 582600 # Check downloaded files against SHA256SUMS, and SHA256SUMS against its583-# signature by a key of the forge: from --signers, else ssh <host> signers584-# (--host, forge.allowedsigners, forge.host or the remote, in that order).601+# signature by a key of the forge, for the release they say they are from:602+# --repo and --tag, else --url, else the download path the files are in,603+# else (the repo only) the remote. The keys come from --signers, else ssh604+# <host> signers (--host, forge.allowedsigners, forge.host or the remote, in605+# that order).585606 release_verify() {586- sums= signers= host=607+ sums= signers= host= vrepo= vtag= vurl=587608 while [ $# -gt 0 ]; do588609 case $1 in589610 --sums) sums=${2:?--sums needs a file}; shift ;;590611 --signers) signers=${2:?--signers needs a file}; shift ;;591612 --host) host=${2:?--host needs git@host}; shift ;;613+ --repo) vrepo=${2:?--repo needs <owner>/<repo>}; shift ;;614+ --tag) vtag=${2:?--tag needs a tag}; shift ;;615+ --url) vurl=${2:?--url needs a download URL}; shift ;;592616 -*) die "release verify: unknown flag $1" ;;593617 *) break ;;594618 esac595619 shift596620 done597- [ $# -ge 1 ] || die "usage: forge release verify [--sums SHA256SUMS] [--signers file] [--host git@host] <file>..."621+ [ $# -ge 1 ] || die "usage: forge release verify [--repo <owner>/<repo> --tag <tag> | --url <download URL>] [--sums SHA256SUMS] [--signers file] [--host git@host] <file>..."598622 [ -n "$sums" ] || sums=$(dirname -- "$1")/SHA256SUMS599623 [ -f "$sums" ] || die "release verify: no $sums; download SHA256SUMS and SHA256SUMS.sig next to the files, or pass --sums"600624 [ -f "$sums.sig" ] || die "release verify: no $sums.sig, so nothing vouches for $sums"625+ from=626+ if [ -n "$vurl" ]; then627+ from=$(release_of "$vurl")628+ [ -n "$from" ] || die "release verify: $vurl is not a release download URL (.../<owner>/<repo>/releases/download/<tag>/)"629+ else630+ from=$(release_of "$(CDPATH= cd -- "$(dirname -- "$sums")" && pwd -P)")631+ fi632+ [ -n "$vrepo" ] || vrepo=${from% *}633+ [ -n "$vtag" ] || vtag=${from#* }634+ [ -n "$vrepo" ] || vrepo=$(remote_repo 2>/dev/null) || vrepo=635+ [ "$vtag" != latest ] || die "release verify: latest/ doesn't say which release it is; pass --tag"636+ [ -n "$vrepo" ] && [ -n "$vtag" ] || die "release verify: which release is this? pass --repo <owner>/<repo> --tag <tag>, or --url <its download URL>"601637 tmp=602638 if [ -z "$signers" ] && [ -z "$host" ]; then603639 signers=$(git config --get forge.allowedsigners 2>/dev/null || true)
@@ -610,11 +646,13 @@ release_verify() {
610646 { rm -f "$tmp"; die "release verify: could not get the forge's keys; pass --signers <file> or --host git@host"; }611647 signers=$tmp612648 fi613- if ! pr=$(verify_block "$sums" "$sums.sig" "$signers" "$RNS"); then614- [ -z "$tmp" ] || rm -f "$tmp"615- die "release verify: $sums.sig is not a good $RNS signature by a key of the forge"649+ msgf=$(mktemp)650+ release_message "$vrepo" "$vtag" "$sums" "$msgf"651+ if ! pr=$(verify_block "$msgf" "$sums.sig" "$signers" "$RNS"); then652+ rm -f "$msgf" ${tmp:+"$tmp"}653+ die "release verify: $sums.sig is not a good $RNS signature of $sums for $vrepo $vtag by a key of the forge"616654 fi617- [ -z "$tmp" ] || rm -f "$tmp"655+ rm -f "$msgf" ${tmp:+"$tmp"}618656 bad=0619657 for f in "$@"; do620658 n=$(basename -- "$f")
@@ -622,7 +660,7 @@ release_verify() {
622660 if [ -z "$want" ]; then say "$n: not in $sums"; bad=1623661 elif [ ! -f "$f" ]; then say "$n: no such file"; bad=1624662 elif [ "$(sha256 "$f")" != "$want" ]; then say "$n: DOES NOT MATCH its sha256 in $sums"; bad=1625- else echo "ok $n: matches $sums, signed by $pr"; fi663+ else echo "ok $n: matches $sums of $vrepo $vtag, signed by $pr"; fi626664 done627665 [ "$bad" = 0 ] || die "release verify: not every file checks out"628666 }
@@ -643,7 +681,7 @@ cmd_release() {
643681 git push -q "$(remote)" ":refs/releases/$1"644682 say "release $1: deleted"645683 ;;646- *) die "usage: forge release publish <tag> <file>... | verify [--sums f] [--signers f] [--host git@host] <file>... | ls | rm <tag>" ;;684+ *) die "usage: forge release publish <tag> <file>... | verify [--repo r --tag t | --url u] [--sums f] [--signers f] [--host git@host] <file>... | ls | rm <tag>" ;;647685 esac648686 }649687
@@ -1784,7 +1822,8 @@ scan_secrets() {
17841822 # trailers name the tag and its commit (as this push leaves it), whose tree17851823 # is regular files only, none over forge.releasemaxsize, each listed in17861824 # SHA256SUMS with its sha256, and SHA256SUMS.sig a forge-release signature1787-# of SHA256SUMS by a key of this forge. With forge.releaserequireci true,1825+# of SHA256SUMS for this repo and tag (release_message) by a key of this1826+# forge. With forge.releaserequireci true,17881827 # the tag's tree needs a verified passing CI attestation too. Releases skip17891828 # the secret check, which reads branches and tags only. Uses $updates,17901829 # $notes_new and the scratch directory $rt.
@@ -1817,7 +1856,7 @@ check_release() {
18171856 [ -s "$rt/files" ] || deny "$rn: no files"18181857 git cat-file blob "$rc:SHA256SUMS" >"$rt/sums" 2>/dev/null || deny "$rn: no SHA256SUMS"18191858 git cat-file blob "$rc:SHA256SUMS.sig" >"$rt/sums.sig" 2>/dev/null ||1820- deny "$rn: no SHA256SUMS.sig; sign SHA256SUMS with ssh-keygen -Y sign -n $RNS (forge release publish does)"1859+ deny "$rn: no SHA256SUMS.sig; forge release publish signs SHA256SUMS for the repo and tag"18211860 # "<sha256> <name>" lines, as sha256sum writes them ("*<name>" in its binary mode).18221861 LC_ALL=C awk 'NF == 0 { next } NF != 2 || length($1) != 64 || $1 !~ /^[0-9a-f]+$/ { bad = 1; exit }18231862 { n = $2; sub(/^\*/, "", n); print n, $1 } END { exit bad }' "$rt/sums" >"$rt/listed" ||
@@ -1832,8 +1871,9 @@ check_release() {
18321871 [ -n "$rx" ] || deny "$rn: SHA256SUMS doesn't list $rname"18331872 [ "$(git cat-file blob "$roid" | sha256 -)" = "$rx" ] || deny "$rn: $rname doesn't match its sha256 in SHA256SUMS"18341873 done <"$rt/files"1835- verify_block "$rt/sums" "$rt/sums.sig" "$FORGE_STATE/allowed_signers" "$RNS" >/dev/null ||1836- deny "$rn: SHA256SUMS.sig is not a good $RNS signature by a key of this forge"1874+ release_message "$OWNER/$NAME" "$2" "$rt/sums" "$rt/message"1875+ rsigner=$(verify_block "$rt/message" "$rt/sums.sig" "$FORGE_STATE/allowed_signers" "$RNS") ||1876+ deny "$rn: SHA256SUMS.sig is not a good $RNS signature of SHA256SUMS for $OWNER/$NAME $2 by a key of this forge"18371877 [ "$(git config --bool forge.releaserequireci || echo false)" = true ] || return 018381878 notes=${notes_new:-refs/notes/ci}18391879 [ "$notes" != "$ZERO" ] || notes=refs/notes/ci
M guide/index.html +1 -1
@@ -215,7 +215,7 @@ log sha256:6cc79019... 4 lines
215215 <p>The pages server serves them the way Cloudflare Pages does, so a site built for there works unchanged: <code>_headers</code> and <code>_redirects</code> apply, <code>/about</code> finds <code>about.html</code> or <code>about/index.html</code>, a missing page gets the nearest <code>404.html</code> (or <code>index.html</code>, for a single-page app), and previews are sent with <code>noindex</code>. Old <code>/sites/...</code> paths redirect to the hostname. Without a pages domain, sites are plain files under <code>/sites/&lt;owner&gt;/&lt;repo&gt;/&lt;name&gt;/</code> on the forge itself.</p>216216 <p>Deleting a branch deletes its previews: the ref, the served link and the hostname, so the sites and review pages stop listing them. Previews of merged branches stay until their branch is deleted. The extracted files stay in the store until <code>forge gc</code>, which removes store trees nothing links to, hostnames whose deploy is gone and leftover previews, skipping store entries changed in the last ten minutes, so it can run while people push.</p>217217 <h3>Releases are the same idea</h3>218-<p><code>forge release publish v1.0 &lt;file&gt;...</code> commits a tag's files to <code>refs/releases/v1.0</code> with a <code>SHA256SUMS</code> signed by your key (<code>ssh-keygen -n forge-release</code>). <code>pre-receive</code> checks every sum, the signature, the tag and a size cap, and with <code>forge.releaserequireci</code> a verified pass for the tag's tree. <code>post-receive</code> extracts the tree into the store and hard-links the files into <code>/dami/greet/releases/download/v1.0/</code>, plus <code>latest/</code>; hard links, so the mirror copies them. Clones and fetches never see <code>refs/releases</code>. A private repo's releases aren't served; its readers get them with <code>ssh git@host release get</code>.</p>218+<p><code>forge release publish v1.0 &lt;file&gt;...</code> commits a tag's files to <code>refs/releases/v1.0</code> with a <code>SHA256SUMS</code> signed by your key for that repo and tag (<code>ssh-keygen -n forge-release</code>), so it can't pass for another release. <code>pre-receive</code> checks every sum, the signature, the tag and a size cap, and with <code>forge.releaserequireci</code> a verified pass for the tag's tree. <code>post-receive</code> extracts the tree into the store and hard-links the files into <code>/dami/greet/releases/download/v1.0/</code>, plus <code>latest/</code>; hard links, so the mirror copies them. Clones and fetches never see <code>refs/releases</code>. A private repo's releases aren't served; its readers get them with <code>ssh git@host release get</code>.</p>219219 </div>220220 </section>221221
M lib/releases.ts +13 -7
@@ -1,8 +1,8 @@
11 // Release files, for the releases page. A release is a commit on22 // refs/releases/<tag> whose tree is its files plus SHA256SUMS and3-// SHA256SUMS.sig, an ssh-keygen signature of SHA256SUMS for the4-// forge-release namespace (forge release publish; pre-receive checks5-// both). The hooks and the build hard-link the files under3+// SHA256SUMS.sig, an ssh-keygen signature (namespace forge-release) of4+// SHA256SUMS behind a header naming the repo and tag (releaseMessage)5+// (forge release publish; pre-receive checks it all). The hooks and the build hard-link the files under66 // /<owner>/<repo>/releases/download/<tag>/; this module reads what the page77 // shows about them: each file's size and sha256, who signed SHA256SUMS, and88 // where the release came from.
@@ -30,7 +30,12 @@ export function parseSums(text: string): Map<string, string> {
3030 return out;3131 }3232 33-function verify(sums: Buffer, sig: Buffer, signers: string): { signed: 'good' | 'bad' | 'unknown'; signer: string | null } {33+// What SHA256SUMS.sig signs; bin/forge's release_message writes the same.34+export function releaseMessage(slug: string, tag: string, sums: Buffer): Buffer {35+ return Buffer.concat([Buffer.from(`forge-release 1\nrepo ${slug}\ntag ${tag}\n`), sums]);36+}37+38+function verify(message: Buffer, sig: Buffer, signers: string): { signed: 'good' | 'bad' | 'unknown'; signer: string | null } {3439 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-release-'));3540 try {3641 const file = path.join(dir, 'sig');
@@ -38,7 +43,7 @@ function verify(sums: Buffer, sig: Buffer, signers: string): { signed: 'good' |
3843 const find = spawnSync('ssh-keygen', ['-Y', 'find-principals', '-s', file, '-f', signers], { encoding: 'utf8' });3944 const signer = find.status === 0 ? find.stdout.split('\n')[0].trim() || null : null;4045 if (!signer) return { signed: 'unknown', signer: null };41- const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', signer, '-n', NAMESPACE, '-s', file], { input: sums });46+ const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', signer, '-n', NAMESPACE, '-s', file], { input: message });4247 return { signed: v.status === 0 ? 'good' : 'bad', signer };4348 } finally {4449 fs.rmSync(dir, { recursive: true, force: true });
@@ -46,6 +51,7 @@ function verify(sums: Buffer, sig: Buffer, signers: string): { signed: 'good' |
4651 }4752 4853 interface LoadOptions {54+ slug: string;4955 urls: RepoUrls;5056 allowedSigners: string;5157 cache: RepoCache;
@@ -53,7 +59,7 @@ interface LoadOptions {
5359 5460 // Every release of the repo, by tag. Signature checks are remembered by the5561 // blob ids of SHA256SUMS and its signature, until the allowed signers change.56-export function loadReleases(gitDir: string, refs: Ref[], { urls, allowedSigners, cache }: LoadOptions): Map<string, ReleaseFiles> {62+export function loadReleases(gitDir: string, refs: Ref[], { slug, urls, allowedSigners, cache }: LoadOptions): Map<string, ReleaseFiles> {5763 const out = new Map<string, ReleaseFiles>();5864 const list = refs.filter((r) => r.name.startsWith('refs/releases/'));5965 if (!list.length) return out;
@@ -77,7 +83,7 @@ export function loadReleases(gitDir: string, refs: Ref[], { urls, allowedSigners
7783 const sumsData = sums ? blobs.get(sums.oid)?.data : undefined;7884 const sigData = sig ? blobs.get(sig.oid)?.data : undefined;7985 const hashes = parseSums(sumsData?.toString('utf8') ?? '');80- const check = sums && sig && sumsData && sigData && signers ? (cache.sigs[`release ${sums.oid} ${sig.oid}`] ??= verify(sumsData, sigData, signers)) : {};86+ const check = sums && sig && sumsData && sigData && signers ? (cache.sigs[`release ${slug} ${tag} ${sums.oid} ${sig.oid}`] ??= verify(releaseMessage(slug, tag, sumsData), sigData, signers)) : {};8187 const [, date = r.date, source = '', builtBy = ''] = meta.get(r.sha) ?? [];8288 out.set(tag, {8389 tag, sha: r.sha, date, source: source.trim() || null, builtBy: builtBy.trim() || null,
M lib/repo.ts +1 -1
M test/lib.sh +15 -3
@@ -177,11 +177,22 @@ site_commit() {
177177 178178 t_sha256() { { sha256sum "$1" 2>/dev/null || shasum -a 256 "$1"; } | cut -d' ' -f1; }179179 180+# release_sign dir key owner/repo tag [namespace]: dir/SHA256SUMS.sig, a181+# signature of dir/SHA256SUMS for that release, as forge release publish182+# signs it (a header naming the repo and tag, then SHA256SUMS).183+release_sign() {184+ { printf 'forge-release 1\nrepo %s\ntag %s\n' "$3" "$4"; cat "$1/SHA256SUMS"; } >"$T/message"185+ rm -f "$T/message.sig"186+ ssh-keygen -q -Y sign -n "${5:-forge-release}" -f "$2" "$T/message" 2>/dev/null187+ mv "$T/message.sig" "$1/SHA256SUMS.sig"188+}189+180190 # release_commit dir tag files-dir [key|-]: a commit for refs/releases/<tag>181191 # made by hand, the way forge release publish makes one, from every file in182192 # files-dir. SHA256SUMS and SHA256SUMS.sig (signed with key, dami's by183-# default) are written unless files-dir has them already; with - there is184-# no signature. R_TAG and R_SRC set other Tag and Source-Commit trailers.193+# default, for the clone's repo or R_REPO) are written unless files-dir has194+# them already; with - there is no signature. R_TAG and R_SRC set other Tag195+# and Source-Commit trailers.185196 release_commit() {186197 (187198 cd "$1" || exit 1
@@ -191,7 +202,8 @@ release_commit() {
191202 for f in "$3"/*; do printf '%s %s\n' "$(t_sha256 "$f")" "${f##*/}"; done >"$T/sums.tmp"192203 mv "$T/sums.tmp" "$3/SHA256SUMS"193204 fi194- if [ "$k" != - ] && [ ! -f "$3/SHA256SUMS.sig" ]; then ssh-keygen -q -Y sign -n forge-release -f "$k" "$3/SHA256SUMS" 2>/dev/null; fi205+ slug=${R_REPO:-$(git remote get-url origin | sed "s|^$FORGE_ROOT/||; s|\.git\$||")}206+ if [ "$k" != - ] && [ ! -f "$3/SHA256SUMS.sig" ]; then release_sign "$3" "$k" "$slug" "$2"; fi195207 t=$(for f in "$3"/*; do printf '100644 blob %s\t%s\n' "$(git hash-object -w "$f")" "${f##*/}"; done | git mktree)196208 printf 'release %s\n\nTag: %s\nSource-Commit: %s\nBuilt-By: test\n' "$2" "${R_TAG:-$2}" "${R_SRC:-$src}" | git commit-tree "$t"197209 )
M test/scenarios/release-admin.sh +2 -0
A test/scenarios/release-identity.sh +41 -0
@@ -0,0 +1,41 @@
1+# A release's signature names its repo and tag, so its files can't be2+# passed off as another release's: v1's offered as v2 (a rollback), or a3+# release copied into another repo, fail on the push and in forge release4+# verify alike.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+repo dami/other9+clone dami dami/greet "$T/w/d"10+at "$T/w/d" git remote add other "$FORGE_ROOT/dami/other.git"11+at "$T/w/d" commit "first"12+at "$T/w/d" git tag v113+tick 360014+at "$T/w/d" commit "second"15+at "$T/w/d" git tag v216+ok push_as dami "$T/w/d" origin main v217+ok push_as dami "$T/w/d" other main v1 v218+mkdir "$T/f"19+printf 'greet 1\n' >"$T/f/greet"20+FORGE_USER=dami ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"21+g=$FORGE_ROOT/dami/greet.git22+v1=$(git --git-dir="$g" rev-parse refs/releases/v1)23+signers=$FORGE_ROOT/.forge/allowed_signers24+25+# v1's files, SHA256SUMS and signature (publish left them in the clone),26+# committed as v2's release.27+c=$(printf 'release v2\n\nTag: v2\nSource-Commit: %s\n' "$(git -C "$T/w/d" rev-parse v2)" | git -C "$T/w/d" commit-tree "$v1^{tree}")28+fails_with 'release v2: SHA256SUMS.sig is not a good forge-release signature of SHA256SUMS for dami/greet v2' push_as dami "$T/w/d" origin "$c:refs/releases/v2"29+missing "$FORGE_WWW/dami/greet/releases/download/v2"30+# The same files downloaded into v2's place, or said to be v2's.31+v2dl=$T/dl/dami/greet/releases/download/v232+mkdir -p "$v2dl"33+cp "$FORGE_WWW/dami/greet/releases/download/v1/"* "$v2dl/"34+fails_with 'is not a good forge-release signature of .* for dami/greet v2' "$FORGE" release verify --signers "$signers" "$v2dl/greet"35+fails_with 'for dami/greet v2' "$FORGE" release verify --signers "$signers" --repo dami/greet --tag v2 "$v2dl/greet"36+ok "$FORGE" release verify --signers "$signers" --tag v1 --repo dami/greet "$v2dl/greet"37+38+# The v1 release itself, copied to dami/other, where v1 is the same commit.39+fails_with 'release v1: SHA256SUMS.sig is not a good forge-release signature of SHA256SUMS for dami/other v1' push_as dami "$T/w/d" other "$v1:refs/releases/v1"40+missing "$FORGE_WWW/dami/other/releases"41+fails_with 'for dami/other v1' "$FORGE" release verify --signers "$signers" --repo dami/other --tag v1 "$v2dl/greet"
M test/scenarios/release-private.sh +2 -1
M test/scenarios/release-publish.sh +4 -1
M test/scenarios/release-signature.sh +17 -10
@@ -1,5 +1,6 @@
1-# SHA256SUMS.sig must be a forge-release signature of SHA256SUMS by a key2-# of this forge: none, a stranger's, one for CI results, or one of other1+# SHA256SUMS.sig must be a forge-release signature of SHA256SUMS behind a2+# header naming this repo and tag, by a key of this forge: none, a3+# stranger's, one for CI results, one of SHA256SUMS alone, or one of other34 # text is refused. allowed_signers lets every key sign releases.45 . "$(dirname "$0")/../lib.sh"56 user dami
@@ -11,30 +12,36 @@ at "$T/w/d" commit "first"
1112 at "$T/w/d" git tag v11213 ok push_as dami "$T/w/d" origin main v11314 ssh-keygen -q -t ed25519 -N '' -C eve -f "$T/keys/eve"14-# files [sign-with-key namespace]: a fresh release directory, signed or not.15+# files [key [namespace]]: a fresh release directory, signed for dami/greet v1 or not.1516 files() {1617 rm -rf "$T/f"1718 mkdir "$T/f"1819 printf 'greet\n' >"$T/f/greet"1920 printf '%s greet\n' "$(t_sha256 "$T/f/greet")" >"$T/f/SHA256SUMS"20- if [ $# -gt 0 ]; then ssh-keygen -q -Y sign -n "$2" -f "$1" "$T/f/SHA256SUMS" 2>/dev/null; fi21+ if [ $# -gt 0 ]; then release_sign "$T/f" "$1" dami/greet v1 "${2:-forge-release}"; fi2122 }2223 push() { c=$(release_commit "$T/w/d" v1 "$T/f" -) && push_as dami "$T/w/d" origin "$c:refs/releases/v1"; }24+bad='release v1: SHA256SUMS.sig is not a good forge-release signature of SHA256SUMS for dami/greet v1 by a key of this forge'2325 2426 files2527 fails_with 'release v1: no SHA256SUMS.sig' push26-files "$T/keys/eve" forge-release27-fails_with 'release v1: SHA256SUMS.sig is not a good forge-release signature by a key of this forge' push28+files "$T/keys/eve"29+fails_with "$bad" push2830 files "$T/keys/dami" forge-ci29-fails_with 'SHA256SUMS.sig is not a good forge-release signature' push30-files "$T/keys/dami" forge-release31+fails_with "$bad" push32+# SHA256SUMS alone, without the header.33+files34+ssh-keygen -q -Y sign -n forge-release -f "$T/keys/dami" "$T/f/SHA256SUMS" 2>/dev/null35+fails_with "$bad" push36+files3137 printf 'different text\n' >"$T/sig-other"3238 ssh-keygen -q -Y sign -n forge-release -f "$T/keys/dami" "$T/sig-other" 2>/dev/null3339 cp "$T/sig-other.sig" "$T/f/SHA256SUMS.sig"34-fails_with 'SHA256SUMS.sig is not a good forge-release signature' push40+fails_with "$bad" push41+3542 missing "$FORGE_WWW/dami/greet/releases"3643 3744 # Any registered key will do, not only the pusher's.38-files "$T/keys/ana" forge-release45+files "$T/keys/ana"3946 ok push4047 exists "$FORGE_WWW/dami/greet/releases/download/v1/greet"
M test/scenarios/release-tag.sh +1 -0
M test/scenarios/release-verify.sh +38 -28
@@ -1,8 +1,9 @@
11 # forge release verify checks downloads against SHA256SUMS, and SHA256SUMS2-# against its signature by a key of the forge, whose keys it gets from3-# forge.allowedsigners, --signers or ssh <host> signers. A changed file, a4-# rewritten SHA256SUMS, a signature by a stranger or none, or a file it5-# doesn't list, fail.2+# against its signature for the release they come from (the download3+# path, --url, or --repo and --tag) by a key of the forge, whose keys it4+# gets from forge.allowedsigners, --signers or ssh <host> signers. It says5+# which release it verified. A changed file, a rewritten SHA256SUMS, a6+# signature by a stranger, for CI or none, or a file it doesn't list, fail.67 . "$(dirname "$0")/../lib.sh"78 user dami89 user ana
@@ -11,44 +12,53 @@ clone dami dami/greet "$T/w/d"
1112 at "$T/w/d" commit "first"1213 ok push_as dami "$T/w/d" origin main1314 at "$T/w/d" git tag v114-mkdir "$T/f" "$T/dl"15+mkdir "$T/f"1516 printf 'greet\000binary\n' >"$T/f/greet"1617 printf 'other\n' >"$T/f/other"1718 FORGE_USER=dami ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet"18-cp "$FORGE_WWW/dami/greet/releases/download/v1/"* "$T/dl/"19+# As wget -x saves them: the path says which release.20+dl=$T/dl/forge.test/dami/greet/releases/download/v121+mkdir -p "$dl"22+cp "$FORGE_WWW/dami/greet/releases/download/v1/"* "$dl/"1923 signers=$FORGE_ROOT/.forge/allowed_signers2024 2125 # In a clone, forge.allowedsigners says who signs.22-ok at "$T/w/d" "$FORGE" release verify "$T/dl/greet"23-has "$T_LOG" '^ok greet: matches .*/dl/SHA256SUMS, signed by dami$'26+ok at "$T/w/d" "$FORGE" release verify "$dl/greet"27+has "$T_LOG" "^ok greet: matches .*/v1/SHA256SUMS of dami/greet v1, signed by dami$"2428 # Anywhere: --signers, or the forge's keys over ssh.25-ok at "$T" "$FORGE" release verify --signers "$signers" "$T/dl/greet"26-ok at "$T" env GIT_SSH_COMMAND="$T/ssh" FORGE_USER=ana "$FORGE" release verify --host "$FORGE_SSH" "$T/dl/greet"27-has "$T_LOG" 'signed by dami$'29+ok at "$T" "$FORGE" release verify --signers "$signers" "$dl/greet"30+ok at "$T" env GIT_SSH_COMMAND="$T/ssh" FORGE_USER=ana "$FORGE" release verify --host "$FORGE_SSH" "$dl/greet"31+has "$T_LOG" 'of dami/greet v1, signed by dami$'32+# Files saved anywhere: the URL they came from, or the repo and tag.2833 mkdir "$T/elsewhere"29-cp "$T/dl/greet" "$T/elsewhere/"30-ok at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"34+cp "$dl/greet" "$dl/SHA256SUMS" "$dl/SHA256SUMS.sig" "$T/elsewhere/"35+fails_with 'which release is this' at "$T" "$FORGE" release verify --signers "$signers" "$T/elsewhere/greet"36+ok at "$T" "$FORGE" release verify --signers "$signers" --url https://forge.test/dami/greet/releases/download/v1/greet "$T/elsewhere/greet"37+ok at "$T" "$FORGE" release verify --signers "$signers" --repo dami/greet --tag v1 "$T/elsewhere/greet"38+# In the clone the remote names the repo; the tag still has to be said.39+ok at "$T/w/d" "$FORGE" release verify --tag v1 "$T/elsewhere/greet"40+fails_with 'is not a good forge-release signature of .* for dami/greet v2' at "$T/w/d" "$FORGE" release verify --tag v2 "$T/elsewhere/greet"41+fails_with "latest/ doesn't say which release" at "$T" "$FORGE" release verify --signers "$signers" --url https://forge.test/dami/greet/releases/download/latest/ "$T/elsewhere/greet"42+s="--signers $signers --sums $dl/SHA256SUMS"3143 3244 # A changed file.3345 printf 'x' >>"$T/elsewhere/greet"34-fails_with 'greet: DOES NOT MATCH its sha256' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"46+fails_with 'greet: DOES NOT MATCH its sha256' at "$T" "$FORGE" release verify $s "$T/elsewhere/greet"3547 # A file SHA256SUMS doesn't list.36-cp "$T/f/other" "$T/dl/other"37-fails_with 'other: not in' at "$T" "$FORGE" release verify --signers "$signers" "$T/dl/greet" "$T/dl/other"48+cp "$T/f/other" "$dl/other"49+fails_with 'other: not in' at "$T" "$FORGE" release verify --signers "$signers" "$dl/greet" "$dl/other"3850 # SHA256SUMS rewritten to match a changed file: its signature no longer does.39-cp "$T/dl/SHA256SUMS" "$T/sums.good"40-printf '%s greet\n' "$(t_sha256 "$T/elsewhere/greet")" >"$T/dl/SHA256SUMS"41-fails_with 'SHA256SUMS.sig is not a good forge-release signature' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"51+cp "$dl/SHA256SUMS" "$T/sums.good"52+printf '%s greet\n' "$(t_sha256 "$T/elsewhere/greet")" >"$dl/SHA256SUMS"53+fails_with 'SHA256SUMS.sig is not a good forge-release signature' at "$T" "$FORGE" release verify $s "$T/elsewhere/greet"4254 # Signed again, by a key the forge doesn't know.4355 ssh-keygen -q -t ed25519 -N '' -C eve -f "$T/keys/eve"44-rm "$T/dl/SHA256SUMS.sig"45-ssh-keygen -q -Y sign -n forge-release -f "$T/keys/eve" "$T/dl/SHA256SUMS" 2>/dev/null46-fails_with 'is not a good forge-release signature' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"56+release_sign "$dl" "$T/keys/eve" dami/greet v157+fails_with 'is not a good forge-release signature' at "$T" "$FORGE" release verify $s "$T/elsewhere/greet"4758 # By dami, but for CI results, not releases.48-rm "$T/dl/SHA256SUMS.sig"49-ssh-keygen -q -Y sign -n forge-ci -f "$T/keys/dami" "$T/dl/SHA256SUMS" 2>/dev/null50-fails_with 'is not a good forge-release signature' at "$T" "$FORGE" release verify --signers "$signers" --sums "$T/dl/SHA256SUMS" "$T/elsewhere/greet"59+release_sign "$dl" "$T/keys/dami" dami/greet v1 forge-ci60+fails_with 'is not a good forge-release signature' at "$T" "$FORGE" release verify $s "$T/elsewhere/greet"5161 # No signature at all.52-cp "$T/sums.good" "$T/dl/SHA256SUMS"53-rm "$T/dl/SHA256SUMS.sig"54-fails_with 'SHA256SUMS.sig, so nothing vouches' at "$T" "$FORGE" release verify --signers "$signers" "$T/dl/greet"62+cp "$T/sums.good" "$dl/SHA256SUMS"63+rm "$dl/SHA256SUMS.sig"64+fails_with 'SHA256SUMS.sig, so nothing vouches' at "$T" "$FORGE" release verify --signers "$signers" "$dl/greet"