OXYGENHUB

Agents sign keyless: author.signer = keyless (keyless step 5c) c895bc52e4e0

With author.signer = keyless, oxy signs with a fresh key the hub
certifies whenever it must sign: it proves the key with the agent's grant
token, keeps the key in memory for its certificate's minutes (renewing
two minutes before the end), stores the certificate and the hub's
published identity, and writes no key to disk. The certificate carries
the owner's grant, so the agent's commits count as the owner's.
keyless.hub names the hub (default: the default remote's).

oxy auth grant keeps the grant token in the credential store (a grant
entry beside the hub's token, or the credential helper under its own
user name). home_key gives the key an oxy home names, with its secret,
so a grant can be signed outside any repository (oh agent grant).

The guide and spec §19 describe it; the grant's scope is what
certificates under it may give.

Refs oxygenhub#241
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-17 18:52 UTC
Signature
✔ signed by Andy McCall
Hash
c895bc52e4e037ad477508b097a23c089e679f5d005abc0e852403268c722d3b
Depends on
05059d434aca 05e71ed6cdf1 2b5ee371060e 445ce913ba56 4dee277c327a 519c7c061bcb 530a0fc650a2 6a81dbab471d 7b9809e67c3d 957fee30826e 982864068a59 98a1620a3840 a2387d547373 ee06a6c557b7 f53cbe7a204e fce44ae9ad29 ff0af1ee299d ff1a64f81c1c ff1f62fee654
Depended on by
2452b4407ab7 f0ef9778e587 e5f8eb2a2006 c96354484bd9
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
38 atoms, 1 tree operation, 11 files

Browse the tree at this change

added crates/oxy-core/src/repo/keyless.rs +191 −0
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 +
}
modified Cargo.lock +1 −0
…
1502 unchanged lines
1503 1503
 "rayon",
1504 1504
 "rustls",
1505 1505
 "rustls-pemfile",
1506 +
 "serde_json",
1506 1507
 "similar",
1507 1508
 "tempfile",
1508 1509
 "thiserror 1.0.69",
…
1443 unchanged lines
modified crates/oxy-core/Cargo.toml +1 −0
…
5 unchanged lines
6 6
description = "Core data model, change graph, and storage for Oxygen"
7 7
8 8
[dependencies]
9 +
serde_json = "1"
9 10
blake3 = { version = "1", features = ["rayon"] }
10 11
hex = "0.4"
11 12
rand = "0.8"
…
20 unchanged lines
modified crates/oxy-core/src/credentials.rs +91 −2
…
82 unchanged lines
83 83
    pub token: Option<String>,
84 84
    /// Extra roots to trust for this hub (a self-signed hub).
85 85
    pub ca: Option<PathBuf>,
86 +
    /// An agent's grant token for keyless signing (oxygenhub#241), when it
87 +
    /// is in the file (not with a helper).
88 +
    pub grant: Option<String>,
86 89
}
87 90
88 91
/// Where a saved token went.
…
71 unchanged lines
160 163
                entry.token = run_helper(h, "get", hub, None)?;
161 164
            }
162 165
        }
163 -
        if entry.token.is_none() && entry.account.is_none() && entry.ca.is_none() {
166 +
        if entry.token.is_none()
167 +
            && entry.account.is_none()
168 +
            && entry.ca.is_none()
169 +
            && entry.grant.is_none()
170 +
        {
164 171
            return Ok(None);
165 172
        }
166 173
        Ok(Some(entry))
…
33 unchanged lines
200 207
        Ok(stored)
201 208
    }
202 209
210 +
    /// Save an agent's grant token for `hub` (keyless signing, spec §14.7):
211 +
    /// in the file, or the helper under its own user name.
212 +
    pub fn save_grant(&self, hub: &str, token: &str) -> Result<Stored, String> {
213 +
        let mut all = self.read()?;
214 +
        let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry {
215 +
            hub: hub.to_string(),
216 +
            ..HubEntry::default()
217 +
        });
218 +
        let stored = match self.helper() {
219 +
            Some(h) => {
220 +
                run_helper_as(&h, "store", hub, GRANT_USER, Some(token))?;
221 +
                entry.grant = None;
222 +
                Stored::Helper(h)
223 +
            }
224 +
            None => {
225 +
                entry.grant = Some(token.to_string());
226 +
                Stored::File(self.path())
227 +
            }
228 +
        };
229 +
        self.write(&all)?;
230 +
        Ok(stored)
231 +
    }
232 +
233 +
    /// The agent grant token kept for `hub`, if any.
234 +
    pub fn grant(&self, hub: &str) -> Result<Option<String>, String> {
235 +
        if let Some(g) = self.read()?.get(hub).and_then(|e| e.grant.clone()) {
236 +
            return Ok(Some(g));
237 +
        }
238 +
        match self.helper() {
239 +
            Some(h) => run_helper_as(&h, "get", hub, GRANT_USER, None),
240 +
            None => Ok(None),
241 +
        }
242 +
    }
243 +
203 244
    /// Remove `hub` from the file and the helper; returns what it held,
204 245
    /// with the token, so the caller can revoke it.
205 246
    pub fn remove(&self, hub: &str) -> Result<Option<HubEntry>, String> {
…
53 unchanged lines
259 300
                "account" => e.account = Some(v),
260 301
                "token" => e.token = Some(v),
261 302
                "ca" => e.ca = Some(PathBuf::from(v)),
303 +
                "grant" => e.grant = Some(v),
262 304
                // Unknown keys are kept out of lookups, not fatal.
263 305
                _ => {}
264 306
            }
…
17 unchanged lines
282 324
            }
283 325
            if let Some(c) = &e.ca {
284 326
                text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy())));
327 +
            }
328 +
            if let Some(g) = &e.grant {
329 +
                text.push_str(&format!("grant = {}\n", quote(g)));
285 330
            }
286 331
        }
287 332
        let path = self.path();
…
116 unchanged lines
404 449
    hub: &str,
405 450
    token: Option<&str>,
406 451
) -> Result<Option<String>, String> {
452 +
    run_helper_as(helper, action, hub, HELPER_USER, token)
453 +
}
454 +
455 +
/// The helper's user name for an agent's grant token, beside the hub's
456 +
/// own token.
457 +
const GRANT_USER: &str = "oxy-agent-grant";
458 +
459 +
fn run_helper_as(
460 +
    helper: &str,
461 +
    action: &str,
462 +
    hub: &str,
463 +
    user: &str,
464 +
    token: Option<&str>,
465 +
) -> Result<Option<String>, String> {
407 466
    let (protocol, host) = hub
408 467
        .split_once("://")
409 468
        .ok_or_else(|| format!("{hub} is not a hub URL"))?;
…
10 unchanged lines
420 479
        c.arg(format!("credential-{helper}")).arg(action);
421 480
        c
422 481
    };
423 -
    let mut input = format!("protocol={protocol}\nhost={host}\nusername={HELPER_USER}\n");
482 +
    let mut input = format!("protocol={protocol}\nhost={host}\nusername={user}\n");
424 483
    if let Some(t) = token {
425 484
        input.push_str(&format!("password={t}\n"));
426 485
    }
…
29 unchanged lines
456 515
#[cfg(test)]
457 516
mod tests {
458 517
    use super::*;
518 +
519 +
    /// oxygenhub#241: an agent's grant token is kept beside the hub's own
520 +
    /// token, owner-only, and read back without disturbing it.
521 +
    #[test]
522 +
    fn a_grant_token_is_kept_beside_the_hub_token() {
523 +
        let dir = tempfile::tempdir().unwrap();
524 +
        let store = Credentials::at(dir.path());
525 +
        let hub = "https://oxygenhub.io";
526 +
        assert_eq!(store.grant(hub).unwrap(), None);
527 +
        store.save(hub, "oxyhub_t", Some("andy"), None).unwrap();
528 +
        store.save_grant(hub, "oxyagent_g").unwrap();
529 +
        assert_eq!(store.grant(hub).unwrap().as_deref(), Some("oxyagent_g"));
530 +
        let e = store.get(hub).unwrap().unwrap();
531 +
        assert_eq!(e.token.as_deref(), Some("oxyhub_t"));
532 +
        assert_eq!(e.account.as_deref(), Some("andy"));
533 +
        // A grant alone is an entry too.
534 +
        store
535 +
            .save_grant("https://elsewhere.example", "oxyagent_h")
536 +
            .unwrap();
537 +
        assert!(store.get("https://elsewhere.example").unwrap().is_some());
538 +
        #[cfg(unix)]
539 +
        {
540 +
            use std::os::unix::fs::PermissionsExt;
541 +
            let mode = std::fs::metadata(store.path())
542 +
                .unwrap()
543 +
                .permissions()
544 +
                .mode();
545 +
            assert_eq!(mode & 0o077, 0, "owner only");
546 +
        }
547 +
    }
459 548
460 549
    #[test]
461 550
    fn hubs_are_scheme_host_and_port() {
…
115 unchanged lines
modified crates/oxy-core/src/repo/config.rs +15 −2
…
17 unchanged lines
18 18
    /// agent sets it to its own key, so its changes carry the human as
19 19
    /// author and the agent's signature (spec §14.2, agent workflows §2).
20 20
    pub signer_key: Option<KeyId>,
21 +
    /// `author.signer = "keyless"`: sign with fresh keys the hub certifies
22 +
    /// under the agent's grant, keeping no key on disk (spec §14.7,
23 +
    /// oxygenhub#241).
24 +
    pub signer_keyless: bool,
25 +
    /// `keyless.hub`: the hub that certifies them (default: the default
26 +
    /// remote's hub).
27 +
    pub keyless_hub: Option<String>,
21 28
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
22 29
    /// push-time seals (`false`, the spec default).
23 30
    pub sign_changes: bool,
…
54 unchanged lines
78 85
            signer_key: std::env::var("OXY_SIGNER")
79 86
                .ok()
80 87
                .and_then(|v| KeyId::from_hex(&v)),
88 +
            signer_keyless: std::env::var("OXY_SIGNER").is_ok_and(|v| v == "keyless"),
89 +
            keyless_hub: None,
81 90
            sign_changes: false,
82 91
            bookmark: "main".into(),
83 92
            remotes: BTreeMap::new(),
…
55 unchanged lines
139 148
                }
140 149
            }
141 150
            "author.signer" => {
142 -
                self.signer_key = if value.is_empty() {
151 +
                self.signer_keyless = value == "keyless";
152 +
                self.signer_key = if value.is_empty() || self.signer_keyless {
143 153
                    None
144 154
                } else {
145 155
                    Some(KeyId::from_hex(value).ok_or_else(|| {
146 -
                        RepoError::Msg("author.signer must be a 64-character hex KeyId".into())
156 +
                        RepoError::Msg(
157 +
                            "author.signer must be a 64-character hex KeyId, or \"keyless\"".into(),
158 +
                        )
147 159
                    })?)
148 160
                }
149 161
            }
…
5 unchanged lines
155 167
                }
156 168
            }
157 169
            "workspace.bookmark" => self.bookmark = value.into(),
170 +
            "keyless.hub" => self.keyless_hub = (!value.is_empty()).then(|| value.to_string()),
158 171
            // Read by the credential store from the user file.
159 172
            "auth.helper" => {}
160 173
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
…
173 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +9 −1
…
327 unchanged lines
328 328
    /// The key this repository signs with: `author.signer` when set (an
329 329
    /// agent's own key, acting for the author), else `author.key`.
330 330
    pub fn signing_key(&self) -> Option<KeyId> {
331 +
        if self.config.signer_keyless {
332 +
            // The key certified last, once this command has signed.
333 +
            return self.keyless_key.borrow().as_ref().map(|(k, _, _)| *k);
334 +
        }
331 335
        self.config.signer_key.or(self.config.author_key)
332 336
    }
333 337
…
2 unchanged lines
336 340
    /// so a repository that has never seen it (a fresh clone) can store it
337 341
    /// when it first signs.
338 342
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
343 +
        if self.config.signer_keyless {
344 +
            return self.keyless_secret().map(Some);
345 +
        }
339 346
        let Some(key) = self.signing_key() else {
340 347
            return Ok(None);
341 348
        };
…
355 unchanged lines
697 704
    pub(crate) fn seals_for_author(&self, author: &KeyId, signer: &KeyId) -> bool {
698 705
        author == signer
699 706
            || author.is_zero()
700 -
            || (self.config.signer_key.is_some() && self.config.author_key == Some(*author))
707 +
            || ((self.config.signer_key.is_some() || self.config.signer_keyless)
708 +
                && self.config.author_key == Some(*author))
701 709
    }
702 710
703 711
    /// The changes of the current bookmark this identity can authenticate
…
38 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +10 −0
…
115 unchanged lines
116 116
mod hosted;
117 117
mod identity;
118 118
mod inspect;
119 +
mod keyless;
119 120
mod ops;
120 121
mod policy;
121 122
mod prompt;
…
21 unchanged lines
143 144
pub use hosted::FileEdit;
144 145
pub use identity::{Rotation, SignatureStatus};
145 146
pub use inspect::{BisectStep, BlameLine, Description};
147 +
pub use keyless::home_key;
146 148
pub use ops::{RestoreReport, RevertReport, Undone, ViewChange};
147 149
pub use policy::PolicyUpdate;
148 150
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
…
103 unchanged lines
252 254
    /// set, accepting a reference update signed by a short-lived key also
253 255
    /// records the host's acceptance and its time.
254 256
    pub(crate) acceptor: Option<std::sync::Arc<(Identity, crate::identity::SecretKey)>>,
257 +
    /// With `author.signer = keyless`, the fresh key the hub last
258 +
    /// certified and when its certificate ends (spec §14.7): kept in memory
259 +
    /// only, for one command.
260 +
    pub(crate) keyless_key: std::cell::RefCell<Option<(KeyId, crate::identity::SecretKey, u64)>>,
255 261
    /// Oxygen-pinned subrepos skipped by the running Git export.
256 262
    pub(crate) git_export_skipped: std::cell::Cell<usize>,
257 263
    /// The manifest as last read, with the file identity it was read from,
…
118 unchanged lines
376 382
        let store = open_objects(&oxy, &config)?;
377 383
        let repo = Repo {
378 384
            acceptor: None,
385 +
            keyless_key: Default::default(),
379 386
            last_change: Default::default(),
380 387
            at_op: None,
381 388
            walked_dirs: Default::default(),
…
59 unchanged lines
441 448
        let store = open_objects(&oxy, &config)?;
442 449
        let repo = Repo {
443 450
            acceptor: None,
451 +
            keyless_key: Default::default(),
444 452
            last_change: Default::default(),
445 453
            at_op: None,
446 454
            walked_dirs: Default::default(),
…
73 unchanged lines
520 528
                let store = open_objects(&shared, &config)?;
521 529
                let mut repo = Repo {
522 530
                    acceptor: None,
531 +
                    keyless_key: Default::default(),
523 532
                    last_change: Default::default(),
524 533
                    at_op: None,
525 534
                    walked_dirs: Default::default(),
…
28 unchanged lines
554 563
                let store = open_objects(&oxy, &config)?;
555 564
                let mut repo = Repo {
556 565
                    acceptor: None,
566 +
                    keyless_key: Default::default(),
557 567
                    last_change: Default::default(),
558 568
                    at_op: None,
559 569
                    walked_dirs: Default::default(),
…
2120 unchanged lines
modified docs/guide.md +21 −1
…
465 unchanged lines
466 466
lists them, `oxy verify` names the agent, and its own `oxy push` is
467 467
refused under any policy, so you land its work.
468 468
469 +
**Keyless agents** (on an OxygenHub) hold no key at all. You give the
470 +
agent a grant instead, signed by your key, and the hub certifies a fresh
471 +
key for it each time it signs:
472 +
473 +
```
474 +
oh agent grant claude --for 30d       # your shell: prints the agent's token, once
475 +
oxy auth grant < token                # the agent's shell (its own OXY_HOME)
476 +
oxy config --user author.key    <YOUR KeyId>
477 +
oxy config --user author.signer keyless
478 +
```
479 +
480 +
Each certificate lasts ten minutes and carries your grant, so the
481 +
agent's commits count as yours, and a push seals them with the fresh
482 +
key. A repository with a trust policy also names the agent:
483 +
`oxy policy grant-subject agent:claude@YOU --issuer hub.identity --scope
484 +
changes` (add `refs` to let it push), with the hub's identity from
485 +
`/.well-known/oxyhub-key`. `oh agent list` shows your grants and `oh
486 +
agent revoke ID` stops one at once; there is no key to rotate.
487 +
469 488
```
470 489
oxy identity revoke                   # retire your own key
471 490
oxy identity revoke 3f2a… --reason "laptop stolen"   # any key, if you hold a root
…
141 unchanged lines
613 632
| Key | Meaning |
614 633
|---|---|
615 634
| `author.name`, `author.email`, `author.key` | who you are; `key` is set by `identity create` |
616 -
| `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate (also `$OXY_SIGNER`) |
635 +
| `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate; or `keyless`, for fresh keys the hub certifies under the agent's grant (also `$OXY_SIGNER`) |
636 +
| `keyless.hub` | the hub that certifies keyless keys (default: the default remote's hub) |
617 637
| `core.sign` | `change` to sign every commit; otherwise seals at push |
618 638
| `core.op-retention` | days for which old operations keep their states alive (90) |
619 639
| `core.gc-grace` | days an unreachable object survives (14) |
…
59 unchanged lines
modified docs/spec.md +2 −2
…
744 unchanged lines
745 745
  grantor     [1]: KeyId          // the author, whose key signs the grant
746 746
  subject     [2]: text           // "agent:NAME@ACCOUNT"
747 747
  issuer      [3]: KeyId          // whose certificates for the subject count
748 -
  scope       [4]: [text]         // changes, seal
748 +
  scope       [4]: [text]         // what certificates under it may give: changes, seal, refs
749 749
  not_before  [5]: u64            // wall milliseconds
750 750
  expires     [6]: u64
751 751
}
…
115 unchanged lines
867 867
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. A directory's `.gitignore` and `.ignore` files are read too, with lower precedence than its `.oxyignore` (a `!` rule there re-includes what they exclude), so a project coming from Git leaves out what Git did. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. |
868 868
| `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. |
869 869
| `.oxy/config` | Repository-local settings, TOML. |
870 -
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; also `$OXY_SIGNER`), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
870 +
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; or `keyless`, for fresh keys the hub certifies under an agent's grant, §14.7, from the grant token `oxy auth grant` keeps; also `$OXY_SIGNER`), `keyless.hub` (the hub that certifies them; default the default remote's), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
871 871
| `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. |
872 872
873 873
Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. A remote's token and CA without `remote.<name>.token` or `.ca` come from `OXY_TOKEN` and `OXY_CA`, then from `credentials.toml` for the remote's hub, then from a remote on the same hub in an enclosing repository's `.oxy/config`. A client MUST NOT send a token over plain `http` except to a loopback address, and MUST NOT forward it across a redirect. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs.
…
230 unchanged lines
modified src/cli/auth.rs +39 −0
…
211 unchanged lines
212 212
    Ok(0)
213 213
}
214 214
215 +
/// `oxy auth grant`: keep an agent's grant token (oxygenhub#241) for
216 +
/// keyless signing, read from standard input.
217 +
pub fn grant(hub: Option<&str>) -> Result<i32> {
218 +
    use std::io::IsTerminal;
219 +
    let hub = resolve_hub(hub)?;
220 +
    if !token_allowed(&hub) {
221 +
        bail!("{hub} is plain http: a token would cross the network unencrypted; use its https address");
222 +
    }
223 +
    if std::io::stdin().is_terminal() {
224 +
        eprint!("Agent grant token for {hub}: ");
225 +
    }
226 +
    let mut line = String::new();
227 +
    std::io::stdin()
228 +
        .lock()
229 +
        .read_line(&mut line)
230 +
        .context("reading the token from standard input")?;
231 +
    let token = line.trim();
232 +
    if !token.starts_with("oxyagent_") {
233 +
        bail!("that is not an agent grant token (they start oxyagent_; `oh agent grant NAME` makes one)");
234 +
    }
235 +
    let stored = Credentials::user()
236 +
        .save_grant(&hub, token)
237 +
        .map_err(|e| anyhow::anyhow!("{e}"))?;
238 +
    println!(
239 +
        "{} for {}",
240 +
        ui::done("Kept the agent grant"),
241 +
        ui::path(&hub)
242 +
    );
243 +
    println!(
244 +
        "  {} {}",
245 +
        ui::note(),
246 +
        ui::dim(format!(
247 +
            "token in {}; sign with it: oxy config author.signer keyless",
248 +
            where_stored(&stored)
249 +
        ))
250 +
    );
251 +
    Ok(0)
252 +
}
253 +
215 254
/// The start of a token, enough to tell tokens apart and no more.
216 255
fn masked(token: &str) -> String {
217 256
    let shown: String = token.chars().take(10).collect();
…
128 unchanged lines
modified src/cli/mod.rs +10 −0
…
682 unchanged lines
683 683
        /// The hub (default: this checkout's origin, or the only one)
684 684
        hub: Option<String>,
685 685
    },
686 +
    /// Keep an agent grant token (from `oh agent grant`) for keyless
687 +
    /// signing: with `author.signer = keyless`, commits and pushes are
688 +
    /// signed by fresh keys the hub certifies, in the grantor's name
689 +
    /// (the token is read from standard input, so it stays out of the
690 +
    /// shell's history)
691 +
    Grant {
692 +
        /// The hub (default: this checkout's origin)
693 +
        hub: Option<String>,
694 +
    },
686 695
}
687 696
688 697
#[derive(Subcommand)]
…
590 unchanged lines
1279 1288
            } => auth::login(hub.as_deref(), with_token, ca),
1280 1289
            AuthCmd::Status => auth::status(),
1281 1290
            AuthCmd::Logout { hub } => auth::logout(hub.as_deref()),
1291 +
            AuthCmd::Grant { hub } => auth::grant(hub.as_deref()),
1282 1292
        },
1283 1293
        Cmd::Submodule { cmd } => match cmd {
1284 1294
            SubmoduleCmd::Add {
…
216 unchanged lines