git-ents.gitmain
⌘K
foforge
setup.rs331 lines · 12.6 KB · rusthistorycomment on this file
1//! `git ents setup`: resolve or generate a signing key, record it as this
2//! repository's `user.signingkey` with `gpg.format=ssh`, set
3//! `receive.denyCurrentBranch=updateInstead` (`roots.worktree-update`),
4//! and ([`configure_global_signing_defaults`]) default every commit, tag,
5//! and (when asked) push, in any repository, to sign itself.
6//!
7//! `receive.denyCurrentBranch=updateInstead` is the integration-test
8//! harness edge case `roots.worktree-update` names: it lets an external
9//! push land on this repository's checked-out branch and still update the
10//! working tree, which is not how a normal git remote behaves and is never
11//! needed for `refs/meta/*` traffic (which never touches a worktree at
12//! all).
13//!
14//! `--hosted` ([`run_hosted`]) configures the single-node hosted root
15//! instead (`roots.single-node-hosted`): a signing key for the hosted
16//! worker, and this binary's own `hook pre-receive`/`hook post-receive`
17//! installed into a bare repository's `hooks/`. Without this, a hosted
18//! bare repository accepts every push completely ungated — stock git's
19//! `receive-pack` enforces nothing on its own; the gate exists only where
20//! a hook calls it.
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use rand_core::{OsRng, RngCore as _};
26use ssh_key::{Algorithm, LineEnding, PrivateKey};
27
28use crate::error::{Error, Result};
29use crate::root::LocalRoot;
30use crate::sign::Signer;
31
32/// Run `git ents setup` against `root`: resolve `key`, generating a new
33/// `~/.ssh/id_ed25519` if neither `key` nor `user.signingkey` resolves to
34/// an existing file, then write `user.signingkey`, `gpg.format=ssh`, and
35/// `receive.denyCurrentBranch=updateInstead` to the repository's own
36/// (local) config.
37///
38/// # Errors
39///
40/// [`Error::BadSigningKey`] if a given or configured key cannot be loaded;
41/// [`Error::Io`] if generating or writing a new key fails; propagates a
42/// config-write failure.
43pub fn run(root: &LocalRoot, key: Option<PathBuf>) -> Result<PathBuf> {
44 let resolved = resolve_or_generate_key(&root.path, key)?;
45 let path_str = resolved.to_string_lossy().into_owned();
46 for (key, value) in [
47 ("user.signingkey", path_str.as_str()),
48 ("gpg.format", "ssh"),
49 ("receive.denyCurrentBranch", "updateInstead"),
50 ] {
51 set_local_config(&root.path, key, value)?;
52 }
53 Ok(resolved)
54}
55
56/// Run `git ents setup --hosted` against the bare repository at `path`:
57/// resolve or generate a signing key for the hosted worker (recorded as
58/// `path`'s own `user.signingkey`/`gpg.format=ssh`, same as [`run`]),
59/// install this binary's `hook pre-receive`/`hook post-receive` as
60/// `path`'s own git hooks (`roots.single-node-hosted`), and require every
61/// push to carry a verifiable signed-push certificate (below).
62///
63/// `receive.denyCurrentBranch=updateInstead` is deliberately not set here:
64/// it is the local-root, checked-out-worktree edge case
65/// (`roots.worktree-update`), meaningless for a bare repository with no
66/// worktree to update.
67///
68/// # Errors
69///
70/// [`Error::BadSigningKey`] if a given or configured key cannot be loaded;
71/// [`Error::Io`] if generating a key, writing config, resolving this
72/// binary's own path, or writing a hook file fails.
73// @relation(roots.single-node-hosted, scope=function)
74pub fn run_hosted(path: &Path, key: Option<PathBuf>) -> Result<PathBuf> {
75 let resolved = resolve_or_generate_key(path, key)?;
76 let path_str = resolved.to_string_lossy().into_owned();
77 for (key, value) in [
78 ("user.signingkey", path_str.as_str()),
79 ("gpg.format", "ssh"),
80 ] {
81 set_local_config(path, key, value)?;
82 }
83 write_pubkey(&resolved)?;
84 install_hooks(path)?;
85 configure_signed_push(path)?;
86 Ok(resolved)
87}
88
89/// Make `receive-pack` advertise and require the `push-cert` capability:
90/// without `receive.certNonceSeed` set, stock git never advertises it at
91/// all, so `git push --signed` fails with "the receiving end does not
92/// support --signed push" regardless of what `hook pre_receive` goes on
93/// to verify. The seed itself only needs to stay stable across the two
94/// requests one push makes (the capability advertisement and the push
95/// itself) — not across pushes — but is generated once and reused on
96/// every later boot anyway, so an in-flight push spanning a restart still
97/// verifies. `certNonceSlop` tolerates the two requests landing in
98/// different seconds.
99fn configure_signed_push(path: &Path) -> Result<()> {
100 let seed = match get_local_config(path, "receive.certNonceSeed")? {
101 Some(existing) => existing,
102 None => generate_nonce_seed(),
103 };
104 for (key, value) in [
105 ("receive.certNonceSeed", seed.as_str()),
106 ("receive.certNonceSlop", "60"),
107 ] {
108 set_local_config(path, key, value)?;
109 }
110 Ok(())
111}
112
113/// 32 random bytes, hex-encoded — plenty of entropy for a nonce-signing
114/// secret that never leaves this repository's local config.
115fn generate_nonce_seed() -> String {
116 let mut bytes = [0u8; 32];
117 OsRng.fill_bytes(&mut bytes);
118 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
119}
120
121/// Write the key's public half to `<key>.pub` — the front proxy publishes
122/// it at a well-known path so `git ents bootstrap` can discover the server
123/// identity to vouch for (`roots.web-signing`) without the operator
124/// copying it out of the logs. Runs every boot, so a volume whose key
125/// predates this file gains it on the next deploy.
126fn write_pubkey(key: &Path) -> Result<()> {
127 let pubkey = Signer::load(key)?.public_openssh();
128 let pub_path = PathBuf::from(format!("{}.pub", key.display()));
129 std::fs::write(&pub_path, format!("{pubkey}\n")).map_err(|source| Error::Io {
130 path: pub_path,
131 source,
132 })
133}
134
135/// Resolve `key` (or `path`'s `user.signingkey`, or a default
136/// `~/.ssh/id_ed25519`), generating a fresh key if nothing resolves to an
137/// existing file, and confirm the result actually loads.
138fn resolve_or_generate_key(path: &Path, key: Option<PathBuf>) -> Result<PathBuf> {
139 let repo = gix::open(path)?;
140 let resolved = match crate::sign::resolve_key_path(&repo, key.as_deref()) {
141 Ok(candidate) if candidate.exists() => candidate,
142 Ok(candidate) => generate_key(&candidate)?,
143 Err(Error::NoSigningKey) => {
144 let default = default_key_path()?;
145 generate_key(&default)?
146 }
147 Err(other) => return Err(other),
148 };
149 // Confirm the resolved key actually loads before recording it.
150 Signer::load(&resolved)?;
151 Ok(resolved)
152}
153
154/// Install this binary's own `hook pre-receive`/`hook post-receive` as
155/// `repo_path`'s git hooks, overwriting any existing scripts of the same
156/// name — the mechanism `roots.single-node-hosted` requires: without
157/// these hooks, git's own `receive-pack` performs no gate check at all,
158/// and a hosted bare repository would accept every push ungated.
159///
160/// # Errors
161///
162/// [`Error::Io`] if this binary's own path cannot be resolved, the
163/// `hooks/` directory cannot be created, or a hook file cannot be written
164/// or (on unix) made executable.
165fn install_hooks(repo_path: &Path) -> Result<()> {
166 let this_binary = std::env::current_exe().map_err(|source| Error::Io {
167 path: repo_path.to_owned(),
168 source,
169 })?;
170 let hooks_dir = repo_path.join("hooks");
171 std::fs::create_dir_all(&hooks_dir).map_err(|source| Error::Io {
172 path: hooks_dir.clone(),
173 source,
174 })?;
175 for hook in ["pre-receive", "post-receive"] {
176 let script = format!("#!/bin/sh\nexec {:?} hook {hook}\n", this_binary.display());
177 let hook_path = hooks_dir.join(hook);
178 std::fs::write(&hook_path, script).map_err(|source| Error::Io {
179 path: hook_path.clone(),
180 source,
181 })?;
182 set_executable(&hook_path)?;
183 }
184 Ok(())
185}
186
187#[cfg(unix)]
188fn set_executable(path: &Path) -> Result<()> {
189 use std::os::unix::fs::PermissionsExt as _;
190 let mut perms = std::fs::metadata(path)
191 .map_err(|source| Error::Io {
192 path: path.to_owned(),
193 source,
194 })?
195 .permissions();
196 perms.set_mode(0o755);
197 std::fs::set_permissions(path, perms).map_err(|source| Error::Io {
198 path: path.to_owned(),
199 source,
200 })
201}
202
203#[cfg(not(unix))]
204fn set_executable(_path: &Path) -> Result<()> {
205 Ok(())
206}
207
208/// Sign every commit, tag, and (when the remote asks) push by default —
209/// written to the operator's *global* (`~/.gitconfig`) config, not any
210/// one repository's, so `git ents setup` needs running only once per
211/// machine for every later `git commit`/`git push`, anywhere, to sign
212/// itself without `-S`/`--signed`. `push.gpgsign=if-asked` in particular
213/// is what makes a plain `git push` safe against both a hosted root
214/// (which now advertises `push-cert`, so this signs) and a remote that
215/// does not (GitHub among them, which never has — this silently pushes
216/// unsigned there instead of failing outright).
217///
218/// Deliberately not called from [`run`] itself: `run`'s callers configure
219/// one *repository* (this crate's own integration tests among them), and
220/// must never mutate the real machine's global git config as a side
221/// effect of that; only the actual `git ents setup` CLI invocation calls
222/// this.
223///
224/// # Errors
225///
226/// Propagates a `git config --global` failure.
227pub fn configure_global_signing_defaults() -> Result<()> {
228 for (key, value) in [
229 ("commit.gpgsign", "true"),
230 ("tag.gpgsign", "true"),
231 ("push.gpgsign", "if-asked"),
232 ] {
233 set_global_config(key, value)?;
234 }
235 Ok(())
236}
237
238/// Set `key` to `value` in the operator's global (`~/.gitconfig`) config
239/// via `git config --global` — unlike [`set_local_config`], not scoped to
240/// any one repository.
241fn set_global_config(key: &str, value: &str) -> Result<()> {
242 let output = Command::new("git")
243 .args(["config", "--global", key, value])
244 .output()
245 .map_err(|source| Error::Io {
246 path: PathBuf::from("~/.gitconfig"),
247 source,
248 })?;
249 if !output.status.success() {
250 return Err(Error::Io {
251 path: PathBuf::from("~/.gitconfig"),
252 source: std::io::Error::other(format!(
253 "git config --global {key} {value} failed: {}",
254 String::from_utf8_lossy(&output.stderr)
255 )),
256 });
257 }
258 Ok(())
259}
260
261/// Set `key` to `value` in `repo_path`'s own local config via `git config`.
262fn set_local_config(repo_path: &Path, key: &str, value: &str) -> Result<()> {
263 let output = Command::new("git")
264 .arg("-C")
265 .arg(repo_path)
266 .args(["config", "--local", key, value])
267 .output()
268 .map_err(|source| Error::Io {
269 path: repo_path.to_owned(),
270 source,
271 })?;
272 if !output.status.success() {
273 return Err(Error::BadSigningKey {
274 path: repo_path.to_owned(),
275 detail: format!(
276 "git config --local {key} {value} failed: {}",
277 String::from_utf8_lossy(&output.stderr)
278 ),
279 });
280 }
281 Ok(())
282}
283
284/// Read `key` from `repo_path`'s own local config, or `None` if it is
285/// unset — used to make [`configure_signed_push`] idempotent across boots
286/// rather than mint a fresh nonce seed (and so a fresh push-cert
287/// namespace) every deploy.
288fn get_local_config(repo_path: &Path, key: &str) -> Result<Option<String>> {
289 let output = Command::new("git")
290 .arg("-C")
291 .arg(repo_path)
292 .args(["config", "--local", "--get", key])
293 .output()
294 .map_err(|source| Error::Io {
295 path: repo_path.to_owned(),
296 source,
297 })?;
298 if !output.status.success() {
299 return Ok(None);
300 }
301 let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
302 Ok((!value.is_empty()).then_some(value))
303}
304
305/// Generate a fresh, unencrypted ed25519 key at `path` (creating parent
306/// directories as needed) and return `path` unchanged.
307fn generate_key(path: &Path) -> Result<PathBuf> {
308 if let Some(parent) = path.parent() {
309 std::fs::create_dir_all(parent).map_err(|source| Error::Io {
310 path: parent.to_owned(),
311 source,
312 })?;
313 }
314 let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|source| {
315 Error::BadSigningKey {
316 path: path.to_owned(),
317 detail: source.to_string(),
318 }
319 })?;
320 key.write_openssh_file(path, LineEnding::LF)
321 .map_err(|source| Error::BadSigningKey {
322 path: path.to_owned(),
323 detail: source.to_string(),
324 })?;
325 Ok(path.to_owned())
326}
327
328fn default_key_path() -> Result<PathBuf> {
329 let home = std::env::var_os("HOME").ok_or(Error::NoSigningKey)?;
330 Ok(PathBuf::from(home).join(".ssh").join("id_ed25519"))
331}