git-ents.gitmain
⌘K
foforge
sign.rs182 lines · 6.8 KB · rusthistorycomment on this file
1//! Real SSH commit signing: the production counterpart to
2//! `ents_testutil::Keypair` — the same SSHSIG-over-`git`-namespace shape
3//! `ents_gate::signature` verifies, but loaded from the user's own key
4//! instead of a deterministic test seed.
5//!
6//! This is new work, not a port: `pre-redo`'s CLI shelled out to `git
7//! commit -S`/`git push --signed` and let stock git invoke
8//! `ssh-keygen -Y sign` itself. The redone architecture's mutation
9//! frontends build and sign the commit object themselves, in-process,
10//! before handing it to [`ents_receive::receive`] (`receive.unit`), so
11//! `git-ents` needs its own signer rather than a subprocess shelling to
12//! `git`.
13
14use std::path::{Path, PathBuf};
15
16use ssh_key::{HashAlg, LineEnding, PrivateKey};
17
18use crate::error::{Error, Result};
19
20/// The SSHSIG namespace git signs commits under — mirrors
21/// `ents_testutil::keys::GIT_SIGN_NAMESPACE` and
22/// `ents_gate::signature`'s verification side.
23const GIT_SIGN_NAMESPACE: &str = "git";
24
25/// A loaded SSH signing identity: the private key material plus its
26/// OpenSSH public-key line, exactly the string an
27/// [`ents_model::Member::key`] carries.
28///
29/// # Examples
30///
31/// ```
32/// # use ssh_key::private::{Ed25519Keypair, KeypairData};
33/// # let dir = tempfile::tempdir().expect("tempdir");
34/// # let path = dir.path().join("id_ed25519");
35/// # let pair = Ed25519Keypair::from_seed(&[7; 32]);
36/// # let key = ssh_key::PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed");
37/// # key.write_openssh_file(&path, ssh_key::LineEnding::LF).expect("write");
38/// use git_ents::sign::Signer;
39///
40/// let signer = Signer::load(&path).expect("loads");
41/// assert!(signer.public_openssh().starts_with("ssh-ed25519 "));
42///
43/// let pem = signer.sign(b"payload");
44/// assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
45/// ```
46#[derive(Debug)]
47pub struct Signer {
48 private: PrivateKey,
49}
50
51impl Signer {
52 /// Load a signing identity from an OpenSSH private key file at `path`.
53 ///
54 /// # Errors
55 ///
56 /// [`Error::BadSigningKey`] if `path` cannot be read, is not a
57 /// well-formed OpenSSH private key, or is passphrase-protected — an
58 /// encrypted key is a deliberate deferral (see this module's own
59 /// doc): this phase supports only an unencrypted key file.
60 pub fn load(path: &Path) -> Result<Self> {
61 let private =
62 PrivateKey::read_openssh_file(path).map_err(|source| Error::BadSigningKey {
63 path: path.to_owned(),
64 detail: source.to_string(),
65 })?;
66 if private.is_encrypted() {
67 return Err(Error::BadSigningKey {
68 path: path.to_owned(),
69 detail: "passphrase-protected keys are not supported yet; use an unencrypted key \
70 or ssh-agent (deferred)"
71 .to_owned(),
72 });
73 }
74 Ok(Self { private })
75 }
76
77 /// The public half in OpenSSH single-line format — what a
78 /// [`ents_model::Member`]'s `key` field stores.
79 #[must_use]
80 pub fn public_openssh(&self) -> String {
81 #[expect(
82 clippy::expect_used,
83 reason = "rendering an already-loaded key's own public half cannot fail; mirrors \
84 `ents_testutil::Keypair::public_openssh`'s identical, unguarded call"
85 )]
86 self.private
87 .public_key()
88 .to_openssh()
89 .expect("a loaded key's public half always renders")
90 }
91
92 /// Sign `payload` in git's SSHSIG namespace, returning the armored PEM
93 /// block git stores in a commit's `gpgsig` header — identical shape to
94 /// `ents_testutil::Keypair::sign`.
95 ///
96 /// # Panics
97 ///
98 /// Never for a well-formed loaded key; signing an arbitrary byte
99 /// payload cannot fail for the algorithms this module accepts.
100 #[must_use]
101 pub fn sign(&self, payload: &[u8]) -> String {
102 self.sign_in_namespace(GIT_SIGN_NAMESPACE, payload)
103 }
104
105 /// Sign `payload` under an explicit SSHSIG `namespace` — what
106 /// `git ents login` uses with `ents_web::auth::LOGIN_NAMESPACE`
107 /// (`roots.web-signin`): a sign-in signature deliberately lives in a
108 /// namespace distinct from [`Self::sign`]'s `git`, so neither can
109 /// ever double as the other.
110 ///
111 /// # Panics
112 ///
113 /// Never for a well-formed loaded key; see [`Self::sign`].
114 #[must_use]
115 pub fn sign_in_namespace(&self, namespace: &str, payload: &[u8]) -> String {
116 #[expect(
117 clippy::expect_used,
118 reason = "signing and PEM-rendering an ed25519 signature over any byte payload is \
119 infallible; mirrors `ents_testutil::Keypair::sign`'s identical, unguarded call"
120 )]
121 self.private
122 .sign(namespace, HashAlg::Sha512, payload)
123 .expect("signing is infallible for a loaded, unencrypted key")
124 .to_pem(LineEnding::LF)
125 .expect("an SSHSIG always renders as PEM")
126 }
127}
128
129/// Resolve the signing key path a command should use: `--key` if given,
130/// else the repository's (or global) `user.signingkey`, else the default
131/// `~/.ssh/id_ed25519`.
132///
133/// A `user.signingkey` naming the public half (stock git's own SSH-signing
134/// convention, e.g. `~/.ssh/id_ed25519.pub`) resolves to its sibling
135/// private key, since [`Signer::load`] only reads private key files.
136///
137/// # Errors
138///
139/// [`Error::NoSigningKey`] when none of the three sources resolves to a
140/// path.
141pub fn resolve_key_path(repo: &gix::Repository, explicit: Option<&Path>) -> Result<PathBuf> {
142 if let Some(path) = explicit {
143 return Ok(path.to_owned());
144 }
145 if let Some(configured) = repo
146 .config_snapshot()
147 .string("user.signingkey")
148 .map(|v| v.to_string())
149 {
150 let path = expand_tilde(&configured);
151 return Ok(match path.to_str().and_then(|s| s.strip_suffix(".pub")) {
152 Some(private) => PathBuf::from(private),
153 None => path,
154 });
155 }
156 if let Some(home) = home_dir() {
157 let default = home.join(".ssh").join("id_ed25519");
158 if default.exists() {
159 return Ok(default);
160 }
161 }
162 Err(Error::NoSigningKey)
163}
164
165/// Expand a leading `~` (or `~/...`) to the user's home directory, the way
166/// git itself expands `user.signingkey`'s path-typed value. Leaves the
167/// input untouched if it doesn't start with `~` or `$HOME` isn't set.
168fn expand_tilde(configured: &str) -> PathBuf {
169 match configured.strip_prefix('~') {
170 Some(rest) => home_dir().map_or_else(
171 || PathBuf::from(configured),
172 |home| home.join(rest.trim_start_matches('/')),
173 ),
174 None => PathBuf::from(configured),
175 }
176}
177
178/// The current user's home directory, however the platform exposes it —
179/// `$HOME` on every platform `git-ents` targets.
180fn home_dir() -> Option<PathBuf> {
181 std::env::var_os("HOME").map(PathBuf::from)
182}