OXYGENHUB

WIP #129: a tag's signer travels with the push (part 1 of 3) d8524a9f0117

Push carries each sent tag's signer identity and certificate. Not for
main yet: receive must also authorize certified tag keys and fetch must
judge them at a trusted time (#129 parts 2 and 3); the test shows part 1
turns 'unknown key' into the policy refusal. Refs #129
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 576201c49697
When
· 2026-10-20 00:02 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
d8524a9f01170fa2f4e3300332530ad57ba8f6ed9e05568dbc9eb3babc8aae1f
Depends on
132b884b9a8d 28fc23d7706a 982864068a59 d36ba2f79f95 f0a517d28237
Depended on by
b63fc7a77fc6 1ec6fdbc6bcb
In bookmarks
identical-sides keyless-tags main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
2 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-core/src/repo/sync.rs +23 −0
…
2296 unchanged lines
2297 2297
                StateObject::new(t.heads.clone()).encode(),
2298 2298
            ));
2299 2299
        }
2300 +
        // A tag's signer travels with it, as the bookmark's does: a tag made
2301 +
        // by `oxy tag create` under a short-lived key was signed by a key the
2302 +
        // receiver has never seen, so its identity and certificate go too.
2303 +
        let tag_signers: Vec<KeyId> = tags_to_send
2304 +
            .iter()
2305 +
            .filter_map(|t| t.attestation.as_ref())
2306 +
            .flat_map(|a| a.sigs.iter().map(|s| s.key))
2307 +
            .collect();
2308 +
        for k in &tag_signers {
2309 +
            if let Ok(id) = self.identity(k) {
2310 +
                let body = id.encode();
2311 +
                if !bundle
2312 +
                    .objects
2313 +
                    .iter()
2314 +
                    .any(|(t, b)| *t == ObjectType::Identity && *b == body)
2315 +
                {
2316 +
                    bundle.objects.push((ObjectType::Identity, body));
2317 +
                }
2318 +
            }
2319 +
        }
2320 +
        if !tag_signers.is_empty() {
2321 +
            self.attach_certificates_for(&mut bundle, &tag_signers)?;
2322 +
        }
2300 2323
        if let Some(l) = &policy_to_send {
2301 2324
            let remote_serial = resp_policy_serial;
2302 2325
            bundle.policies = self
…
241 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +79 −0
…
1126 unchanged lines
1127 1127
    );
1128 1128
}
1129 1129
1130 +
/// A tag made under one short-lived key and pushed under another: its
1131 +
/// signer travels with the push (identity and certificate), so a receiver
1132 +
/// that has never seen that key accepts it. A tag-only push by a keyless
1133 +
/// agent was refused "unknown key" (found releasing OxygenFlow 0.2.0).
1134 +
#[test]
1135 +
fn a_tag_signed_by_an_earlier_short_lived_key_is_pushed() {
1136 +
    use crate::cert::{Certificate, SignedCertificate};
1137 +
    use crate::identity::Scope;
1138 +
    hermetic();
1139 +
    let dir = tempfile::tempdir().unwrap();
1140 +
    let root = dir.path();
1141 +
    let _origin = Repo::init(&root.join("origin")).unwrap();
1142 +
    let url = root.join("origin").to_string_lossy().to_string();
1143 +
    let mut a = Repo::clone(&url, &root.join("a"))
1144 +
        .unwrap()
1145 +
        .with_home(root.join("home-a"));
1146 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1147 +
        .unwrap();
1148 +
    write(&root.join("a"), "a.txt", b"a\n");
1149 +
    a.commit("a", &[]).unwrap();
1150 +
    a.policy_init().unwrap();
1151 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1152 +
    let hub_sk = SecretKey::generate();
1153 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1154 +
    a.policy_grant_subject(
1155 +
        "agent:claude@ada",
1156 +
        hub.clone(),
1157 +
        Scope::CHANGES | Scope::REFS,
1158 +
        Some("*"),
1159 +
    )
1160 +
    .unwrap();
1161 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1162 +
1163 +
    let now = Hlc::now().wall_ms;
1164 +
    let mut agent = Repo::clone(&url, &root.join("agent"))
1165 +
        .unwrap()
1166 +
        .with_home(root.join("home-agent"));
1167 +
    agent.store().put_identity(&hub).unwrap();
1168 +
    let certify = |agent: &Repo, id| {
1169 +
        let cert = SignedCertificate::sign(
1170 +
            Certificate {
1171 +
                key: id,
1172 +
                issuer: hub.hash(),
1173 +
                subject: "agent:claude@ada".into(),
1174 +
                claims: vec![],
1175 +
                scope: Scope::CHANGES | Scope::REFS,
1176 +
                not_before: now - 60_000,
1177 +
                expires: now + 600_000,
1178 +
                grant: None,
1179 +
            },
1180 +
            &hub,
1181 +
            &hub_sk,
1182 +
        );
1183 +
        agent.store().put_certificate(&cert).unwrap();
1184 +
    };
1185 +
    // The tag, under the first short-lived key.
1186 +
    let (first, first_id) = agent
1187 +
        .create_identity("claude", "[email protected]", IdentityScope::User)
1188 +
        .unwrap();
1189 +
    certify(&agent, first_id);
1190 +
    let heads = agent.bookmark_heads().unwrap();
1191 +
    agent.tag_create("v1", &heads, "first").unwrap();
1192 +
    // The push, under a second one (a later process's ten-minute key).
1193 +
    let (second, second_id) = agent
1194 +
        .create_identity("claude", "[email protected]", IdentityScope::User)
1195 +
        .unwrap();
1196 +
    assert_ne!(first, second);
1197 +
    certify(&agent, second_id);
1198 +
    agent
1199 +
        .set_user_config("author.signer", &second.to_hex())
1200 +
        .unwrap();
1201 +
    let host = Repo::open(&root.join("origin")).unwrap();
1202 +
    assert!(host.store().get_identity(&first).is_err(), "the host has never seen the tag's key");
1203 +
    let r = agent.push("origin", false).unwrap();
1204 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1205 +
    let host = Repo::open(&root.join("origin")).unwrap();
1206 +
    assert!(host.tag_refs().unwrap().iter().any(|t| t.name == "v1"), "the tag arrived");
1207 +
}
1208 +
1130 1209
/// oxygenhub#266: a short-lived key its issuer certified moves a bookmark
1131 1210
/// and signs no change (a keyless agent naming a bookmark). A third
1132 1211
/// party's clone gets the key's identity, its certificate and the
…
8026 unchanged lines