OXYGENHUB
◆ identical-sides oxygen / crates / oxy-core / src / repo / keyless.rs
11.9 KiB · Rust · last changed author.for: a keyless home follows its owner's newest identity (refs #82) 10 days ago source blame history raw
c895bc52 Andy McCall ↶ 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);
f0ef9778 Andy McCall ↶ 54
        let creds = Credentials::at(home.clone());
c895bc52 Andy McCall ↶ 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}")))?;
f0ef9778 Andy McCall ↶ 123
        // The grant's author, so the grant can be checked here and wherever
124
        // this pushes (oxygen#134): from the hub's answer, else this home's
125
        // copy of it. Without it, a repository that never saw the owner's
126
        // current identity reads the change as signed by an unknown author.
127
        if let Some(g) = &cert.cert.grant {
128
            let grantor = g.grant.grantor;
129
            if self.store.get_identity(&grantor).is_err() {
130
                let sent = reply["grantor_identity"]
131
                    .as_str()
132
                    .and_then(crate::dsse::base64_decode)
133
                    .and_then(|b| Identity::decode(&b).ok());
134
                let kept = || {
135
                    std::fs::read(
136
                        home.join("keys")
137
                            .join(format!("{}.identity", grantor.to_hex())),
138
                    )
139
                    .ok()
140
                    .and_then(|b| Identity::decode(&b).ok())
141
                };
142
                match sent.filter(|i| i.hash() == grantor).or_else(|| kept().filter(|i| i.hash() == grantor)) {
143
                    Some(i) => {
144
                        self.store.put_identity(&i)?;
145
                    }
146
                    None => {
147
                        return fail(format!(
148
                            "the grant is signed by {}, whose identity neither {hub} nor this home has: export it there (oxy identity export) into this home's keys/",
149
                            grantor.short(12)
150
                        ))
151
                    }
152
                }
153
            }
154
        }
2452b440 Andy McCall ↶ 155
        if let (Some(owner), Some(g)) = (&self.config.author_for, &cert.cert.grant) {
156
            self.follow_author(owner, &cert.cert.subject, g.grant.grantor, &reply, &home)?;
157
        }
c895bc52 Andy McCall ↶ 158
        self.store.put_identity(&issuer)?;
159
        self.store.put_identity(&id)?;
160
        self.store.put_certificate(&cert)?;
161
        *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires));
162
        Ok((key, sk))
2452b440 Andy McCall ↶ 163
    }
164
}
165
166
impl Repo {
167
    /// `author.for` (oxygen#82): keep the identity the hub names as the
168
    /// grant's owner's newest (`author_chain`, newest first, each the
169
    /// successor of the next, the last the grant's key's), store the chain
170
    /// here so the grant checks against it, and note its key in the home,
171
    /// so this and the home's next commands author as it.
172
    fn follow_author(
173
        &self,
174
        owner: &str,
175
        subject: &str,
176
        grantor: KeyId,
177
        reply: &serde_json::Value,
178
        home: &Path,
179
    ) -> Result<()> {
180
        let granted_by = subject.rsplit_once('@').map(|(_, o)| o).unwrap_or(subject);
181
        if !granted_by.eq_ignore_ascii_case(owner) {
182
            return fail(format!(
183
                "author.for is {owner}, but this home's grant is from {granted_by}: set author.for to the account that granted it"
184
            ));
185
        }
186
        let chain: Vec<Identity> = reply["author_chain"]
187
            .as_array()
188
            .map(|a| {
189
                a.iter()
190
                    .filter_map(|v| v.as_str().and_then(crate::dsse::base64_decode))
191
                    .filter_map(|b| Identity::decode(&b).ok())
192
                    .collect()
193
            })
194
            .unwrap_or_default();
195
        let linked = chain.windows(2).all(|w| w[0].previous == Some(w[1].hash()))
196
            && chain.last().is_none_or(|l| l.previous == Some(grantor));
197
        if !linked {
198
            return fail(
199
                "the hub's author chain does not lead back to the grant's key".to_string(),
200
            );
201
        }
202
        let keys = home.join("keys");
203
        std::fs::create_dir_all(&keys)?;
204
        for id in &chain {
205
            self.store.put_identity(id)?;
206
            let path = keys.join(format!("{}.identity", id.hash().to_hex()));
207
            if !path.exists() {
208
                crate::durable::write_atomic(&path, &id.encode())?;
209
            }
210
        }
211
        let newest = chain.first().map(Identity::hash).unwrap_or(grantor);
212
        crate::durable::write_atomic(
213
            &keys.join(super::config::AUTHOR_FOR_FILE),
214
            format!("{}\n", newest.to_hex()).as_bytes(),
215
        )?;
216
        Ok(())
c895bc52 Andy McCall ↶ 217
    }
218
}
219
220
/// The key a home names as its author (`author.key` in its
221
/// `config.toml`), with its secret from the home's `keys/`: what signs
222
/// outside any repository, such as a grant to an agent (`oh agent grant`).
223
/// `None` when the home names no key or does not hold its secret.
224
pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> {
225
    let config = read_config_file(&home.join("config.toml"))?;
226
    let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else {
227
        return Ok(None);
228
    };
229
    let key = KeyId::from_hex(hex)
230
        .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?;
231
    let keys = home.join("keys");
232
    let secret = match fs::read(keys.join(format!("{hex}.secret"))) {
233
        Ok(b) => b,
234
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
235
        Err(e) => return Err(e.into()),
236
    };
237
    let (k, sk) = SecretKey::decode(&secret)?;
238
    let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?;
239
    if k != key || id.hash() != key {
240
        return fail(format!(
241
            "{} holds another key than author.key",
242
            keys.display()
243
        ));
244
    }
245
    Ok(Some((id, sk)))
246
}
247
248
#[cfg(test)]
249
mod tests {
250
    use super::*;
251
252
    #[test]
253
    fn a_home_s_key_signs_outside_any_repository() {
254
        static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
255
        HOME.get_or_init(|| {
256
            let d = tempfile::tempdir().unwrap();
257
            std::env::set_var("OXY_HOME", d.path());
258
            d
259
        });
260
        let dir = tempfile::tempdir().unwrap();
261
        let home = dir.path().join("home");
262
        assert!(home_key(&home).unwrap().is_none(), "no config, no key");
263
        let mut r = Repo::init(&dir.path().join("work"))
264
            .unwrap()
265
            .with_home(home.clone());
266
        let (key, id) = r
267
            .create_identity("Andy", "[email protected]", IdentityScope::User)
268
            .unwrap();
269
        let (got, sk) = home_key(&home).unwrap().expect("the home's author key");
270
        assert_eq!(got, id);
271
        let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32]));
272
        assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok());
273
        // An agent whose signer is keyless signs nothing until it asks.
274
        r.set_user_config("author.signer", "keyless").unwrap();
275
        assert!(r.config.signer_keyless && r.config.signer_key.is_none());
276
        assert_eq!(r.signing_key(), None);
277
        let err = r.secret_key().unwrap_err().to_string();
278
        assert!(err.contains("keyless.hub nor a default remote"), "{err}");
279
    }
280
}