Skip to main content

max / makenotwork

mnw-cli: sign post-receive hooks with the per-repo HMAC mnw-cli wrote the raw BUILD_TRIGGER_TOKEN into hooks/post-receive for every bare repo it auto-created over SSH, and sent it as the bearer token. The server stopped accepting the raw token: both builds/trigger and issues/process-push verify HMAC(token, owner:repo), so those hooks could not authenticate, and the curl output is discarded, so the failure was silent. Derive the same HMAC mnw-cli's hooks need from the bare-repo path, matching server's build_runner. The global token no longer lands on disk. Both crates pin the derivation to one shared test vector.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-26 13:00 UTC
Signed with PGP, not checked
Commit: d60b54ae8abee6a93d9dbccf9dd3858101ca87a8
Parent: 0934524
4 files changed, +88 insertions, -4 deletions
@@ -1945,6 +1945,8 @@
1945 1945 "anyhow",
1946 1946 "bytes",
1947 1947 "crossterm",
1948 + "hex",
1949 + "hmac",
1948 1950 "rand 0.10.2",
1949 1951 "ratatui",
1950 1952 "reqwest",
@@ -1952,6 +1954,7 @@
1952 1954 "russh-sftp",
1953 1955 "serde",
1954 1956 "serde_json",
1957 + "sha2 0.11.0",
1955 1958 "synckit-client",
1956 1959 "tokio",
1957 1960 "tracing",
@@ -17,6 +17,9 @@
17 17 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
18 18 anyhow = "1"
19 19 bytes = "1"
20 + hmac = "0.13.0"
21 + sha2 = "0.11.0"
22 + hex = "0.4.3"
20 23 synckit-client = { path = "../../synckit/synckit-client", default-features = false }
21 24 uuid = "1"
22 25
@@ -1060,6 +1060,18 @@
1060 1060 assert!(hook.contains("/api/internal/builds/trigger"));
1061 1061 }
1062 1062
1063 + /// Fixed vector, duplicated in mnw-cli's `repo_hmac` test. mnw-cli installs
1064 + /// hooks for repos it auto-creates over SSH, and this endpoint verifies
1065 + /// them; if either side's derivation moves, both tests have to move
1066 + /// together or those pushes stop triggering builds.
1067 + #[test]
1068 + fn repo_hmac_matches_mnw_cli_vector() {
1069 + assert_eq!(
1070 + repo_hmac("test-token", "max", "repo"),
1071 + "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
1072 + );
1073 + }
1074 +
1063 1075 #[test]
1064 1076 fn repo_hmac_differs_per_repo() {
1065 1077 let h1 = repo_hmac("token", "alice", "repo-a");
@@ -145,7 +145,10 @@
145 145 Ok(stdin)
146 146 }
147 147
148 - /// Post-receive hook template. __TOKEN__ is replaced with the actual token.
148 + /// Post-receive hook template. `__HMAC__` is replaced with a per-repo HMAC
149 + /// signature so the global `BUILD_TRIGGER_TOKEN` never lands on disk. Both
150 + /// endpoints verify `HMAC(token, owner:repo)`, not the raw token, so this has
151 + /// to match `server`'s `build_runner::POST_RECEIVE_HOOK_TEMPLATE`.
149 152 const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
150 153 while read oldrev newrev refname; do
151 154 case "$refname" in
@@ -155,7 +158,7 @@
155 158 REPO_NAME="$(basename "$REPO_PATH" .git)"
156 159 OWNER="$(basename "$(dirname "$REPO_PATH")")"
157 160 curl -sf -X POST \
158 - -H "Authorization: Bearer __TOKEN__" \
161 + -H "Authorization: Bearer __HMAC__" \
159 162 -H "Content-Type: application/json" \
160 163 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
161 164 "http://localhost:3000/api/internal/builds/trigger" \
@@ -167,7 +170,7 @@
167 170 REPO_NAME="$(basename "$REPO_PATH" .git)"
168 171 OWNER="$(basename "$(dirname "$REPO_PATH")")"
169 172 curl -sf -X POST \
170 - -H "Authorization: Bearer __TOKEN__" \
173 + -H "Authorization: Bearer __HMAC__" \
171 174 -H "Content-Type: application/json" \
172 175 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
173 176 "http://localhost:3000/api/internal/issues/process-push" \
@@ -177,13 +180,37 @@
177 180 done
178 181 "#;
179 182
183 + /// Compute the per-repo HMAC the internal endpoints expect. Mirrors
184 + /// `build_runner::repo_hmac` on the server side; the two must agree.
185 + fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
186 + use hmac::{Hmac, KeyInit, Mac};
187 + use sha2::Sha256;
188 + let mut mac =
189 + <Hmac<Sha256>>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
190 + mac.update(format!("{owner}:{repo}").as_bytes());
191 + hex::encode(mac.finalize().into_bytes())
192 + }
193 +
194 + /// Split a bare-repo path into (owner, repo), matching what the hook derives
195 + /// at run time from its own location: repo = basename minus `.git`, owner =
196 + /// the containing directory's name.
197 + fn owner_and_repo(repo_path: &str) -> Option<(String, String)> {
198 + let path = std::path::Path::new(repo_path);
199 + let repo = path.file_name()?.to_str()?;
200 + let repo = repo.strip_suffix(".git").unwrap_or(repo);
201 + let owner = path.parent()?.file_name()?.to_str()?;
202 + Some((owner.to_string(), repo.to_string()))
203 + }
204 +
180 205 /// Install the post-receive hook in a bare repository.
181 206 pub(crate) async fn install_post_receive_hook(
182 207 _git_user: &str,
183 208 repo_path: &str,
184 209 token: &str,
185 210 ) -> anyhow::Result<()> {
186 - let hook_content = POST_RECEIVE_HOOK.replace("__TOKEN__", token);
211 + let (owner, repo) = owner_and_repo(repo_path)
212 + .ok_or_else(|| anyhow::anyhow!("cannot derive owner/repo from {repo_path}"))?;
213 + let hook_content = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
187 214 let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive");
188 215
189 216 // Write directly — mnw-cli is in the git group, setgid dir gives correct ownership
@@ -230,6 +257,45 @@
230 257 assert!(parse_git_command("scp -t /tmp/file").is_none());
231 258 }
232 259
260 + /// Locks the derivation to the same vector the server's `repo_hmac`
261 + /// produces (HMAC-SHA256 over `owner:repo`, hex). If this drifts, every
262 + /// hook mnw-cli installs starts failing auth silently.
263 + #[test]
264 + fn repo_hmac_matches_server_vector() {
265 + assert_eq!(
266 + repo_hmac("test-token", "max", "repo"),
267 + "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
268 + );
269 + }
270 +
271 + #[test]
272 + fn owner_and_repo_from_bare_path() {
273 + let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
274 + assert_eq!(owner, "max");
275 + assert_eq!(repo, "repo");
276 +
277 + // No .git suffix is still valid.
278 + let (owner, repo) = owner_and_repo("/srv/git/max/repo").unwrap();
279 + assert_eq!(owner, "max");
280 + assert_eq!(repo, "repo");
281 +
282 + // A bare name has no owner directory to read.
283 + assert!(owner_and_repo("repo.git").is_none());
284 + }
285 +
286 + /// The hook that lands on disk must carry the per-repo HMAC and never the
287 + /// global token itself.
288 + #[test]
289 + fn hook_body_carries_hmac_not_token() {
290 + let token = "super-secret-token";
291 + let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
292 + let body = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
293 +
294 + assert!(!body.contains(token));
295 + assert!(!body.contains("__HMAC__"));
296 + assert!(body.contains(&repo_hmac(token, "max", "repo")));
297 + }
298 +
233 299 #[test]
234 300 fn parse_repo_path_basic() {
235 301 let (owner, repo) = parse_repo_path("max/repo.git").unwrap();