OXYGENHUB
◆ v0.5.1 oxygen / crates / oxy-core / src / repo / keyless.rs
8.0 KiB · Rust · last changed Agents sign keyless: author.signer = keyless (keyless step 5c) 18 days ago source blame history raw
1
//! Signing with short-lived keys (spec §14.7, oxygenhub#241): with
2
//! `author.signer = keyless`, oxy asks the hub to certify a fresh key
3
//! whenever it must sign, with the agent's grant token as the credential,
4
//! and keeps the key in memory for its certificate's minutes. Nothing that
5
//! signs is ever written to disk: the home holds only the grant token,
6
//! which its owner revokes on the hub. The certificate carries the owner's
7
//! grant, so what the key signs counts in the owner's name.
8
9
use super::*;
10
use crate::cert::{certify_challenge, SignedCertificate};
11
use crate::credentials::{hub_of, Credentials};
12
use crate::protocol::{http_get, http_json, RemoteOptions};
13
14
/// A certified key is used until this long before its certificate ends,
15
/// so a push started near the end does not outlive it.
16
const MARGIN_MS: u64 = 2 * 60 * 1000;
17
18
impl Repo {
19
    /// The hub that certifies keys: `keyless.hub`, else the default
20
    /// remote's.
21
    fn keyless_hub(&self) -> Result<String> {
22
        let url = match &self.config.keyless_hub {
23
            Some(h) => h.clone(),
24
            None => self
25
                .config
26
                .remotes
27
                .get(&self.default_remote())
28
                .cloned()
29
                .ok_or_else(|| {
30
                    RepoError::Msg(
31
                        "author.signer is keyless, but neither keyless.hub nor a default remote names the hub that certifies keys"
32
                            .into(),
33
                    )
34
                })?,
35
        };
36
        hub_of(&url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub URL")))
37
    }
38
39
    /// The key to sign with now: the one certified last while it has more
40
    /// than a couple of minutes left, else a fresh one the hub certifies.
41
    pub(crate) fn keyless_secret(&self) -> Result<(KeyId, SecretKey)> {
42
        let now = Hlc::now().wall_ms;
43
        if let Some((k, sk, until)) = self.keyless_key.borrow().as_ref() {
44
            if now + MARGIN_MS < *until {
45
                return Ok((*k, sk.clone()));
46
            }
47
        }
48
        let hub = self.keyless_hub()?;
49
        let home = self
50
            .user_config
51
            .parent()
52
            .map(Path::to_path_buf)
53
            .unwrap_or_else(default_home);
54
        let creds = Credentials::at(home);
55
        let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| {
56
            RepoError::Msg(format!(
57
                "author.signer is keyless, but there is no agent grant for {hub}: its owner makes one with `oh agent grant NAME`, and `oxy auth grant TOKEN` keeps it"
58
            ))
59
        })?;
60
        let options = RemoteOptions {
61
            token: Some(token.clone()),
62
            ca: creds.get(&hub).map_err(RepoError::Msg)?.and_then(|e| e.ca),
63
            ..RemoteOptions::default()
64
        };
65
        let sk = SecretKey::generate();
66
        let id = sk.identity(&self.config.author_name, &self.config.author_email, now);
67
        let key = id.hash();
68
        let proof = sk
69
            .sign_challenge(&key, &certify_challenge(&token))
70
            .map_err(|e| RepoError::Msg(e.to_string()))?;
71
        let body = serde_json::json!({
72
            "identity": crate::dsse::base64(&id.encode()),
73
            "proof": crate::dsse::base64(&crate::cbor::encode(&proof.to_value())),
74
            "scope": "changes,refs,seal",
75
        });
76
        let (code, answer) = http_json(
77
            "POST",
78
            &format!("{hub}/api/v1/keyless/certify"),
79
            Some(&body.to_string()),
80
            &options,
81
        )?;
82
        let reply: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
83
        if !(200..300).contains(&code) {
84
            let why = reply["error"].as_str().unwrap_or(answer.trim());
85
            return fail(format!(
86
                "{hub} would not certify a key (HTTP {code}): {why}"
87
            ));
88
        }
89
        let cert = reply["certificate"]
90
            .as_str()
91
            .and_then(crate::dsse::base64_decode)
92
            .ok_or_else(|| RepoError::Msg(format!("{hub} answered with no certificate")))?;
93
        let cert = SignedCertificate::decode(&cert)
94
            .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?;
95
        if cert.cert.key_id() != key {
96
            return fail(format!(
97
                "{hub} certified another key than the one it was asked for"
98
            ));
99
        }
100
        // The issuer's identity, so this repository (and whoever it pushes
101
        // to) can check the certificate: the hub's own, as it publishes it.
102
        let issuer = match self.store.get_identity(&cert.cert.issuer) {
103
            Ok(i) => i,
104
            Err(_) => {
105
                let bytes = http_get(
106
                    &format!("{hub}/.well-known/oxyhub-key"),
107
                    &RemoteOptions {
108
                        token: None,
109
                        ..options.clone()
110
                    },
111
                )?;
112
                let i = Identity::decode(&bytes)?;
113
                if i.hash() != cert.cert.issuer {
114
                    return fail(format!(
115
                        "{hub}'s published key is not the one that signed its certificate"
116
                    ));
117
                }
118
                i
119
            }
120
        };
121
        cert.verify(&issuer)
122
            .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?;
123
        self.store.put_identity(&issuer)?;
124
        self.store.put_identity(&id)?;
125
        self.store.put_certificate(&cert)?;
126
        *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires));
127
        Ok((key, sk))
128
    }
129
}
130
131
/// The key a home names as its author (`author.key` in its
132
/// `config.toml`), with its secret from the home's `keys/`: what signs
133
/// outside any repository, such as a grant to an agent (`oh agent grant`).
134
/// `None` when the home names no key or does not hold its secret.
135
pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> {
136
    let config = read_config_file(&home.join("config.toml"))?;
137
    let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else {
138
        return Ok(None);
139
    };
140
    let key = KeyId::from_hex(hex)
141
        .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?;
142
    let keys = home.join("keys");
143
    let secret = match fs::read(keys.join(format!("{hex}.secret"))) {
144
        Ok(b) => b,
145
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
146
        Err(e) => return Err(e.into()),
147
    };
148
    let (k, sk) = SecretKey::decode(&secret)?;
149
    let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?;
150
    if k != key || id.hash() != key {
151
        return fail(format!(
152
            "{} holds another key than author.key",
153
            keys.display()
154
        ));
155
    }
156
    Ok(Some((id, sk)))
157
}
158
159
#[cfg(test)]
160
mod tests {
161
    use super::*;
162
163
    #[test]
164
    fn a_home_s_key_signs_outside_any_repository() {
165
        static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
166
        HOME.get_or_init(|| {
167
            let d = tempfile::tempdir().unwrap();
168
            std::env::set_var("OXY_HOME", d.path());
169
            d
170
        });
171
        let dir = tempfile::tempdir().unwrap();
172
        let home = dir.path().join("home");
173
        assert!(home_key(&home).unwrap().is_none(), "no config, no key");
174
        let mut r = Repo::init(&dir.path().join("work"))
175
            .unwrap()
176
            .with_home(home.clone());
177
        let (key, id) = r
178
            .create_identity("Andy", "[email protected]", IdentityScope::User)
179
            .unwrap();
180
        let (got, sk) = home_key(&home).unwrap().expect("the home's author key");
181
        assert_eq!(got, id);
182
        let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32]));
183
        assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok());
184
        // An agent whose signer is keyless signs nothing until it asks.
185
        r.set_user_config("author.signer", "keyless").unwrap();
186
        assert!(r.config.signer_keyless && r.config.signer_key.is_none());
187
        assert_eq!(r.signing_key(), None);
188
        let err = r.secret_key().unwrap_err().to_string();
189
        assert!(err.contains("keyless.hub nor a default remote"), "{err}");
190
    }
191
}