OXYGENHUB

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

64 of 224 lines run, 7 of 50 functions · run #404 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×
        if let (Some(owner), Some(g)) = (&self.config.author_for, &cert.cert.grant) {
1560×
            self.follow_author(owner, &cert.cert.subject, g.grant.grantor, &reply, &home)?;
1570×
        }
1580×
        self.store.put_identity(&issuer)?;
1590×
        self.store.put_identity(&id)?;
1600×
        self.store.put_certificate(&cert)?;
1610×
        *self.keyless_key.borrow_mut() = Some((key, sk.clone(), cert.cert.expires));
1620×
        Ok((key, sk))
1631×
    }
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.
1720×
    fn follow_author(
1730×
        &self,
1740×
        owner: &str,
1750×
        subject: &str,
1760×
        grantor: KeyId,
1770×
        reply: &serde_json::Value,
1780×
        home: &Path,
1790×
    ) -> Result<()> {
1800×
        let granted_by = subject.rsplit_once('@').map(|(_, o)| o).unwrap_or(subject);
1810×
        if !granted_by.eq_ignore_ascii_case(owner) {
1820×
            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
            ));
1850×
        }
1860×
        let chain: Vec<Identity> = reply["author_chain"]
1870×
            .as_array()
1880×
            .map(|a| {
1890×
                a.iter()
1900×
                    .filter_map(|v| v.as_str().and_then(crate::dsse::base64_decode))
1910×
                    .filter_map(|b| Identity::decode(&b).ok())
1920×
                    .collect()
1930×
            })
1940×
            .unwrap_or_default();
1950×
        let linked = chain.windows(2).all(|w| w[0].previous == Some(w[1].hash()))
1960×
            && chain.last().is_none_or(|l| l.previous == Some(grantor));
1970×
        if !linked {
1980×
            return fail(
1990×
                "the hub's author chain does not lead back to the grant's key".to_string(),
200
            );
2010×
        }
2020×
        let keys = home.join("keys");
2030×
        std::fs::create_dir_all(&keys)?;
2040×
        for id in &chain {
2050×
            self.store.put_identity(id)?;
2060×
            let path = keys.join(format!("{}.identity", id.hash().to_hex()));
2070×
            if !path.exists() {
2080×
                crate::durable::write_atomic(&path, &id.encode())?;
2090×
            }
210
        }
2110×
        let newest = chain.first().map(Identity::hash).unwrap_or(grantor);
2120×
        crate::durable::write_atomic(
2130×
            &keys.join(super::config::AUTHOR_FOR_FILE),
2140×
            format!("{}\n", newest.to_hex()).as_bytes(),
2150×
        )?;
2160×
        Ok(())
2170×
    }
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.
2242×
pub fn home_key(home: &Path) -> Result<Option<(Identity, SecretKey)>> {
2252×
    let config = read_config_file(&home.join("config.toml"))?;
2262×
    let Some(hex) = config.get("author.key").filter(|v| !v.is_empty()) else {
2271×
        return Ok(None);
228
    };
2291×
    let key = KeyId::from_hex(hex)
2301×
        .ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?;
2311×
    let keys = home.join("keys");
2321×
    let secret = match fs::read(keys.join(format!("{hex}.secret"))) {
2331×
        Ok(b) => b,
2340×
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2350×
        Err(e) => return Err(e.into()),
236
    };
2371×
    let (k, sk) = SecretKey::decode(&secret)?;
2381×
    let id = Identity::decode(&fs::read(keys.join(format!("{hex}.identity")))?)?;
2391×
    if k != key || id.hash() != key {
2400×
        return fail(format!(
241
            "{} holds another key than author.key",
2420×
            keys.display()
243
        ));
2441×
    }
2451×
    Ok(Some((id, sk)))
2462×
}
247
248
#[cfg(test)]
249
mod tests {
250
    use super::*;
251
252
    #[test]
2531×
    fn a_home_s_key_signs_outside_any_repository() {
254
        static HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
2551×
        HOME.get_or_init(|| {
2561×
            let d = tempfile::tempdir().unwrap();
2571×
            std::env::set_var("OXY_HOME", d.path());
2581×
            d
2591×
        });
2601×
        let dir = tempfile::tempdir().unwrap();
2611×
        let home = dir.path().join("home");
2621×
        assert!(home_key(&home).unwrap().is_none(), "no config, no key");
2631×
        let mut r = Repo::init(&dir.path().join("work"))
2641×
            .unwrap()
2651×
            .with_home(home.clone());
2661×
        let (key, id) = r
2671×
            .create_identity("Andy", "[email protected]", IdentityScope::User)
2681×
            .unwrap();
2691×
        let (got, sk) = home_key(&home).unwrap().expect("the home's author key");
2701×
        assert_eq!(got, id);
2711×
        let sig = sk.sign(Domain::Grant, &key, &ObjectRef([1; 32]));
2721×
        assert!(verify_signature(&id, &sig, Domain::Grant, &ObjectRef([1; 32])).is_ok());
273
        // An agent whose signer is keyless signs nothing until it asks.
2741×
        r.set_user_config("author.signer", "keyless").unwrap();
2751×
        assert!(r.config.signer_keyless && r.config.signer_key.is_none());
2761×
        assert_eq!(r.signing_key(), None);
2771×
        let err = r.secret_key().unwrap_err().to_string();
2781×
        assert!(err.contains("keyless.hub nor a default remote"), "{err}");
2791×
    }
280
}