OXYGENHUB

Coverage / crates/oxy-core/src/repo/keyless.rs 36.8%

64 of 174 lines run, 7 of 38 functions · run #382 on main · run missed

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.
211×
    fn keyless_hub(&self) -> Result<String> {
221×
        let url = match &self.config.keyless_hub {
230×
            Some(h) => h.clone(),
241×
            None => self
251×
                .config
261×
                .remotes
271×
                .get(&self.default_remote())
281×
                .cloned()
291×
                .ok_or_else(|| {
301×
                    RepoError::Msg(
311×
                        "author.signer is keyless, but neither keyless.hub nor a default remote names the hub that certifies keys"
321×
                            .into(),
331×
                    )
341×
                })?,
35
        };
360×
        hub_of(&url).ok_or_else(|| RepoError::Msg(format!("{url} is not a hub URL")))
371×
    }
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.
411×
    pub(crate) fn keyless_secret(&self) -> Result<(KeyId, SecretKey)> {
421×
        let now = Hlc::now().wall_ms;
431×
        if let Some((k, sk, until)) = self.keyless_key.borrow().as_ref() {
440×
            if now + MARGIN_MS < *until {
450×
                return Ok((*k, sk.clone()));
460×
            }
471×
        }
481×
        let hub = self.keyless_hub()?;
490×
        let home = self
500×
            .user_config
510×
            .parent()
520×
            .map(Path::to_path_buf)
530×
            .unwrap_or_else(default_home);
540×
        let creds = Credentials::at(home.clone());
550×
        let token = creds.grant(&hub).map_err(RepoError::Msg)?.ok_or_else(|| {
560×
            RepoError::Msg(format!(
570×
                "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"
580×
            ))
590×
        })?;
600×
        let options = RemoteOptions {
610×
            token: Some(token.clone()),
620×
            ca: creds.get(&hub).map_err(RepoError::Msg)?.and_then(|e| e.ca),
630×
            ..RemoteOptions::default()
64
        };
650×
        let sk = SecretKey::generate();
660×
        let id = sk.identity(&self.config.author_name, &self.config.author_email, now);
670×
        let key = id.hash();
680×
        let proof = sk
690×
            .sign_challenge(&key, &certify_challenge(&token))
700×
            .map_err(|e| RepoError::Msg(e.to_string()))?;
710×
        let body = serde_json::json!({
720×
            "identity": crate::dsse::base64(&id.encode()),
730×
            "proof": crate::dsse::base64(&crate::cbor::encode(&proof.to_value())),
740×
            "scope": "changes,refs,seal",
75
        });
760×
        let (code, answer) = http_json(
770×
            "POST",
780×
            &format!("{hub}/api/v1/keyless/certify"),
790×
            Some(&body.to_string()),
800×
            &options,
810×
        )?;
820×
        let reply: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
830×
        if !(200..300).contains(&code) {
840×
            let why = reply["error"].as_str().unwrap_or(answer.trim());
850×
            return fail(format!(
86
                "{hub} would not certify a key (HTTP {code}): {why}"
87
            ));
880×
        }
890×
        let cert = reply["certificate"]
900×
            .as_str()
910×
            .and_then(crate::dsse::base64_decode)
920×
            .ok_or_else(|| RepoError::Msg(format!("{hub} answered with no certificate")))?;
930×
        let cert = SignedCertificate::decode(&cert)
940×
            .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?;
950×
        if cert.cert.key_id() != key {
960×
            return fail(format!(
97
                "{hub} certified another key than the one it was asked for"
98
            ));
990×
        }
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.
1020×
        let issuer = match self.store.get_identity(&cert.cert.issuer) {
1030×
            Ok(i) => i,
104
            Err(_) => {
1050×
                let bytes = http_get(
1060×
                    &format!("{hub}/.well-known/oxyhub-key"),
1070×
                    &RemoteOptions {
1080×
                        token: None,
1090×
                        ..options.clone()
1100×
                    },
1110×
                )?;
1120×
                let i = Identity::decode(&bytes)?;
1130×
                if i.hash() != cert.cert.issuer {
1140×
                    return fail(format!(
115
                        "{hub}'s published key is not the one that signed its certificate"
116
                    ));
1170×
                }
1180×
                i
119
            }
120
        };
1210×
        cert.verify(&issuer)
1220×
            .map_err(|e| RepoError::Msg(format!("{hub}'s certificate: {e}")))?;
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.
1270×
        if let Some(g) = &cert.cert.grant {
1280×
            let grantor = g.grant.grantor;
1290×
            if self.store.get_identity(&grantor).is_err() {
1300×
                let sent = reply["grantor_identity"]
1310×
                    .as_str()
1320×
                    .and_then(crate::dsse::base64_decode)
1330×
                    .and_then(|b| Identity::decode(&b).ok());
1340×
                let kept = || {
1350×
                    std::fs::read(
1360×
                        home.join("keys")
1370×
                            .join(format!("{}.identity", grantor.to_hex())),
138
                    )
1390×
                    .ok()
1400×
                    .and_then(|b| Identity::decode(&b).ok())
1410×
                };
1420×
                match sent.filter(|i| i.hash() == grantor).or_else(|| kept().filter(|i| i.hash() == grantor)) {
1430×
                    Some(i) => {
1440×
                        self.store.put_identity(&i)?;
145
                    }
146
                    None => {
1470×
                        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/",
1490×
                            grantor.short(12)
150
                        ))
151
                    }
152
                }
1530×
            }
1540×
        }
1550×
        self.store.put_identity(&issuer)?;
1560×
        self.store.put_identity(&id)?;
1570×
        self.store.put_certificate(&cert)?;
1580×
        *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires));
1590×
        Ok((key, sk))
1601×
    }
161
}
162
163
/// The key a home names as its author (`author.key` in its
164
/// `config.toml`), with its secret from the home's `keys/`: what signs
165
/// outside any repository, such as a grant to an agent (`oh agent grant`).
166
/// `None` when the home names no key or does not hold its secret.
1672×
pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> {
1682×
    let config = read_config_file(&home.join("config.toml"))?;
1692×
    let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else {
1701×
        return Ok(None);
171
    };
1721×
    let key = KeyId::from_hex(hex)
1731×
        .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?;
1741×
    let keys = home.join("keys");
1751×
    let secret = match fs::read(keys.join(format!("{hex}.secret"))) {
1761×
        Ok(b) => b,
1770×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1780×
        Err(e) => return Err(e.into()),
179
    };
1801×
    let (k, sk) = SecretKey::decode(&secret)?;
1811×
    let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?;
1821×
    if k != key || id.hash() != key {
1830×
        return fail(format!(
184
            "{} holds another key than author.key",
1850×
            keys.display()
186
        ));
1871×
    }
1881×
    Ok(Some((id, sk)))
1892×
}
190
191
#[cfg(test)]
192
mod tests {
193
    use super::*;
194
195
    #[test]
1961×
    fn a_home_s_key_signs_outside_any_repository() {
197
        static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
1981×
        HOME.get_or_init(|| {
1991×
            let d = tempfile::tempdir().unwrap();
2001×
            std::env::set_var("OXY_HOME", d.path());
2011×
            d
2021×
        });
2031×
        let dir = tempfile::tempdir().unwrap();
2041×
        let home = dir.path().join("home");
2051×
        assert!(home_key(&home).unwrap().is_none(), "no config, no key");
2061×
        let mut r = Repo::init(&dir.path().join("work"))
2071×
            .unwrap()
2081×
            .with_home(home.clone());
2091×
        let (key, id) = r
2101×
            .create_identity("Andy", "[email protected]", IdentityScope::User)
2111×
            .unwrap();
2121×
        let (got, sk) = home_key(&home).unwrap().expect("the home's author key");
2131×
        assert_eq!(got, id);
2141×
        let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32]));
2151×
        assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok());
216
        // An agent whose signer is keyless signs nothing until it asks.
2171×
        r.set_user_config("author.signer", "keyless").unwrap();
2181×
        assert!(r.config.signer_keyless && r.config.signer_key.is_none());
2191×
        assert_eq!(r.signing_key(), None);
2201×
        let err = r.secret_key().unwrap_err().to_string();
2211×
        assert!(err.contains("keyless.hub nor a default remote"), "{err}");
2221×
    }
223
}