OXYGENHUB

Keyless agents create release tags: receive and fetch authorize certified tag keys 1ec6fdbc6bcb

Parts 2 and 3 of #129. A receiver authorizes a tag signed by a short-lived
key by the policy's subject grant at its own clock, as it does a bookmark
move, and records its acceptance (the hub signs it with its key). A fetch
checks such a tag at that acceptance, or at its integrated time when the
host's log gives one, never at the time the tag claims: without either the
tag is left out with a warning and the rest is fetched. A client keeps the
issuer's acceptance, serves it and pushes it with the tag, and a receiver
takes a tag at a carried acceptance when its own clock is past the
certificate; gc keeps it. Tags on the wire gain keys 4 to 6 (acceptance,
log place, cosigned checkpoints), only when present, which older readers
ignore; the fetch checks a tag's log entry as a bookmark's. Spec §10.1,
§14.5, §14.7, §14.8, §16.3 and docs/protocol.md say so.

Closes #129
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 984468c55197
When
· 2026-10-20 04:54 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
1ec6fdbc6bcba5867766727220e1b13e9460a72ad5291f54d77df766d2ac21f8
Depends on
028545dfa7a4 096f447ccda0 0ed150d62994 132b884b9a8d 3526d339519f 4b9d06778581 5b9c6ad45f6e 6f06d9f912b4 750251b21a96 75167672ebcd 767ac6cd0545 83e0677b07fe 8fd78eb52ad2 982864068a59 9f9e72b93e4e a2387d547373 b2ef93bb2366 ba160f60219e d8524a9f0117 e4595038502a e5d9b0513f3e ee06a6c557b7 f0a517d28237 f53cbe7a204e ff0af1ee299d
Depended on by
570e35310f31 b63fc7a77fc6 ee130dd9eef2
In bookmarks
identical-sides 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
70 atoms, 0 tree operations, 10 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Keyless agents can create release tags (#129).** `oxy tag create` and `oxy push` under a keyless home was refused "unknown key": the push did not carry the tag signer's identity and certificate, and tags were authorized only by keys the policy names, never by a certificate under a subject grant. The push now carries them, and a receiver authorizes a short-lived key's tag by a subject grant at its own clock, as it does a bookmark move, and records when it accepted it (OxygenHub signs that with its key). A fetch checks such a tag at that acceptance, or at its integrated time when the hub's log gives one, so a release tag made with a ten-minute key is still fetched months later. A fetch never checks it at the time the tag claims: without an acceptance the tag is left out with a warning (`tags/NAME not updated`), and the rest is fetched. A client keeps the acceptance and serves and pushes it with the tag, so a tag passed on to another remote, or cloned from a clone, keeps its time. The wire change is additive: tags in `refs` and `push` gain keys 4 to 6 (acceptance, log place, cosigned checkpoints), which older readers ignore.
6 +
5 7
## 0.5.0 (2026-10-20)
6 8
7 9
- **A repository kept in a bucket finds signatures and seals without a LIST each (#116).** Every lookup of an object's signatures, seals, receipts, approvals or certificate listed a prefix in the bucket, and most of these lookups found nothing. Writers now also add each such entry to a journal (`pack-entries`, updated by compare-and-swap), and `oxy gc` folds them all into one list. Once a store has been folded, a reader reads the lists and the journal when it opens it, and again after 30 seconds (as long as the pack listing is kept), and looks everything up from them. Another writer's entry is seen within 30 seconds, as its pack would be, even by a handle a server keeps open. The first `oxy gc` also copies the one-list keys of much older stores (`sigs/`, `seals/`, `certs/`, …) into the current layout and marks them folded, so readers stop requesting them; until now nearly every lookup made one of these requests and found nothing. On the test store, 40 lookups went from 85 requests (40 LISTs) to 7 (one journal read and no LIST). Older readers are unaffected, because every entry still has its own key. Entries written by oxy 0.4.6 or older after the fold are missed until the next `oxy gc`, so upgrade every client that writes to the bucket first, then run `oxy gc` once. A server's `/health` counts journal reads as `read_journal`. A repository's own directory works as before.
…
248 unchanged lines
modified crates/oxy-core/src/protocol.rs +174 −58
…
91 unchanged lines
92 92
    pub state: ObjectRef,
93 93
    pub heads: Vec<ChangeHash>,
94 94
    pub attestation: Option<SignedAttest>,
95 +
    /// The acceptance of `attestation` by its signer's certificate issuer
96 +
    /// when a short-lived key signed it (spec §14.7; key 4, only when there
97 +
    /// is one): the time a fetch checks the certificate at.
98 +
    pub accepted: Option<SignedAttest>,
99 +
    /// The attestation's place in the host's transparency log (key 5,
100 +
    /// only when it has one), as a bookmark's.
101 +
    pub logged: Option<Logged>,
102 +
    /// The cosigned checkpoints that first included `logged`'s entry (key
103 +
    /// 6, only when there are any).
104 +
    pub integration: Vec<Integration>,
105 +
}
106 +
107 +
impl TagRef {
108 +
    /// A tag with no acceptance or log place (yet).
109 +
    pub fn new(
110 +
        name: String,
111 +
        state: ObjectRef,
112 +
        heads: Vec<ChangeHash>,
113 +
        attestation: Option<SignedAttest>,
114 +
    ) -> TagRef {
115 +
        TagRef {
116 +
            name,
117 +
            state,
118 +
            heads,
119 +
            attestation,
120 +
            accepted: None,
121 +
            logged: None,
122 +
            integration: vec![],
123 +
        }
124 +
    }
95 125
}
96 126
97 127
/// One reference as a remote reports it.
…
823 unchanged lines
921 951
        Value::Array(
922 952
            tags.iter()
923 953
                .map(|t| {
924 -
                    Value::map([
954 +
                    let mut v = Value::map([
925 955
                        (0, Value::Text(t.name.clone())),
926 956
                        (1, Value::Bytes(t.state.0.to_vec())),
927 957
                        (2, hashes_value(&t.heads)),
928 958
                        (3, opt_attest_value(&t.attestation)),
929 -
                    ])
959 +
                    ]);
960 +
                    // Only when there is one; older peers ignore them
961 +
                    // (oxygen#129).
962 +
                    if let Value::Map(m) = &mut v {
963 +
                        if t.accepted.is_some() {
964 +
                            m.insert(4, opt_attest_value(&t.accepted));
965 +
                        }
966 +
                        if let Some(l) = &t.logged {
967 +
                            m.insert(5, logged_value(l));
968 +
                        }
969 +
                        if !t.integration.is_empty() {
970 +
                            m.insert(6, integration_value(&t.integration));
971 +
                        }
972 +
                    }
973 +
                    v
930 974
                })
931 975
                .collect(),
932 976
        )
…
7 unchanged lines
940 984
                state: o::oref(field(tm, 1, "state")?, "state")?,
941 985
                heads: hashes_from(field(tm, 2, "heads")?)?,
942 986
                attestation: opt_attest_from(field(tm, 3, "attestation")?)?,
987 +
                accepted: match tm.get(&4) {
988 +
                    Some(a) => opt_attest_from(a)?,
989 +
                    None => None,
990 +
                },
991 +
                logged: tm.get(&5).map(logged_from).transpose()?,
992 +
                integration: match tm.get(&6) {
993 +
                    Some(l) => integration_from(l)?,
994 +
                    None => Vec::new(),
995 +
                },
943 996
            });
997 +
        }
998 +
        Ok(out)
999 +
    }
1000 +
1001 +
    /// The tags of a refs answer as oxy 0.5.0 read them (and the runners
1002 +
    /// built with it), keys 0 to 3 only: what an older reader makes of an
1003 +
    /// answer with keys 4 to 6.
1004 +
    #[cfg(test)]
1005 +
    pub(super) fn tags_as_0_5_read_them(bytes: &[u8]) -> Result<Vec<TagRef>> {
1006 +
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
1007 +
        let m = map(&v, "refs")?;
1008 +
        let mut out = Vec::new();
1009 +
        for t in o::array(field(m, 3, "tags")?, "tags")? {
1010 +
            let tm = map(t, "tag")?;
1011 +
            out.push(TagRef::new(
1012 +
                o::text(field(tm, 0, "name")?, "name")?.to_owned(),
1013 +
                o::oref(field(tm, 1, "state")?, "state")?,
1014 +
                hashes_from(field(tm, 2, "heads")?)?,
1015 +
                opt_attest_from(field(tm, 3, "attestation")?)?,
1016 +
            ));
944 1017
        }
945 1018
        Ok(out)
1019 +
    }
1020 +
1021 +
    /// A log place, `[entry number, entry bytes, [proof hash]]` (spec §14.8).
1022 +
    fn logged_value(l: &Logged) -> Value {
1023 +
        Value::Array(vec![
1024 +
            Value::Uint(l.index),
1025 +
            Value::Bytes(l.entry.clone()),
1026 +
            proof_value(&l.proof),
1027 +
        ])
946 1028
    }
1029 +
    fn logged_from(v: &Value) -> Result<Logged> {
1030 +
        let a = o::array(v, "logged")?;
1031 +
        if a.len() != 3 {
1032 +
            return Err(schema("logged: expected [index, entry, proof]"));
1033 +
        }
1034 +
        Ok(Logged {
1035 +
            index: o::uint(&a[0], "index")?,
1036 +
            entry: o::bytes(&a[1], "entry")?.to_vec(),
1037 +
            proof: proof_from(&a[2])?,
1038 +
        })
1039 +
    }
947 1040
1041 +
    /// Cosigned checkpoints holding an entry, `[[note, [proof hash]]]`.
1042 +
    fn integration_value(integration: &[Integration]) -> Value {
1043 +
        Value::Array(
1044 +
            integration
1045 +
                .iter()
1046 +
                .map(|i| Value::Array(vec![Value::Text(i.note.clone()), proof_value(&i.proof)]))
1047 +
                .collect(),
1048 +
        )
1049 +
    }
1050 +
    fn integration_from(v: &Value) -> Result<Vec<Integration>> {
1051 +
        let mut out = Vec::new();
1052 +
        for i in o::array(v, "integration")? {
1053 +
            let a = o::array(i, "integration")?;
1054 +
            if a.len() != 2 {
1055 +
                return Err(schema("integration: expected [note, proof]"));
1056 +
            }
1057 +
            out.push(Integration {
1058 +
                note: o::text(&a[0], "note")?.to_owned(),
1059 +
                proof: proof_from(&a[1])?,
1060 +
            });
1061 +
        }
1062 +
        Ok(out)
1063 +
    }
1064 +
948 1065
    pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> {
949 1066
        let mut v = Value::map([
950 1067
            (
…
15 unchanged lines
966 1083
                                    m.insert(5, opt_attest_value(&r.accepted));
967 1084
                                }
968 1085
                                if let Some(l) = &r.logged {
969 -
                                    m.insert(
970 -
                                        6,
971 -
                                        Value::Array(vec![
972 -
                                            Value::Uint(l.index),
973 -
                                            Value::Bytes(l.entry.clone()),
974 -
                                            proof_value(&l.proof),
975 -
                                        ]),
976 -
                                    );
1086 +
                                    m.insert(6, logged_value(l));
977 1087
                                }
978 1088
                                if !r.integration.is_empty() {
979 -
                                    m.insert(
980 -
                                        7,
981 -
                                        Value::Array(
982 -
                                            r.integration
983 -
                                                .iter()
984 -
                                                .map(|i| {
985 -
                                                    Value::Array(vec![
986 -
                                                        Value::Text(i.note.clone()),
987 -
                                                        proof_value(&i.proof),
988 -
                                                    ])
989 -
                                                })
990 -
                                                .collect(),
991 -
                                        ),
992 -
                                    );
1089 +
                                    m.insert(7, integration_value(&r.integration));
993 1090
                                }
994 1091
                            }
995 1092
                            v
…
61 unchanged lines
1057 1154
                },
1058 1155
                accepted: match rm.get(&5) {
1059 1156
                    Some(a) => opt_attest_from(a)?,
1060 -
                    None => None,
1061 -
                },
1062 -
                logged: match rm.get(&6) {
1063 -
                    Some(l) => {
1064 -
                        let a = o::array(l, "logged")?;
1065 -
                        if a.len() != 3 {
1066 -
                            return Err(schema("logged: expected [index, entry, proof]"));
1067 -
                        }
1068 -
                        Some(Logged {
1069 -
                            index: o::uint(&a[0], "index")?,
1070 -
                            entry: o::bytes(&a[1], "entry")?.to_vec(),
1071 -
                            proof: proof_from(&a[2])?,
1072 -
                        })
1073 -
                    }
1074 1157
                    None => None,
1075 1158
                },
1159 +
                logged: rm.get(&6).map(logged_from).transpose()?,
1076 1160
                integration: match rm.get(&7) {
1077 -
                    Some(l) => {
1078 -
                        let mut out = Vec::new();
1079 -
                        for i in o::array(l, "integration")? {
1080 -
                            let a = o::array(i, "integration")?;
1081 -
                            if a.len() != 2 {
1082 -
                                return Err(schema("integration: expected [note, proof]"));
1083 -
                            }
1084 -
                            out.push(Integration {
1085 -
                                note: o::text(&a[0], "note")?.to_owned(),
1086 -
                                proof: proof_from(&a[1])?,
1087 -
                            });
1088 -
                        }
1089 -
                        out
1090 -
                    }
1161 +
                    Some(l) => integration_from(l)?,
1091 1162
                    None => Vec::new(),
1092 1163
                },
1093 1164
            });
…
328 unchanged lines
1422 1493
                integration: vec![],
1423 1494
            },
1424 1495
        ];
1425 -
        let tag = TagRef {
1426 -
            name: "v1".into(),
1427 -
            state: ObjectRef([2; 32]),
1428 -
            heads: vec![c.hash()],
1429 -
            attestation: None,
1430 -
        };
1496 +
        let tag = TagRef::new("v1".into(), ObjectRef([2; 32]), vec![c.hash()], None);
1431 1497
        let mut resp = RefsResponse {
1432 1498
            refs,
1433 1499
            policy: Some(SignedPolicy {
…
43 unchanged lines
1477 1543
        assert_eq!(back.policy.as_ref().unwrap().policy, policy);
1478 1544
        assert_eq!(back.history, resp.history);
1479 1545
        assert_eq!(back.tags, vec![tag.clone()]);
1546 +
1547 +
        // A tag's acceptance and log place (oxygen#129): keys 4 to 6, only
1548 +
        // when there are any, so a tag without them encodes as before.
1549 +
        let crate::cbor::Value::Map(m) = crate::cbor::decode(&new).unwrap() else {
1550 +
            panic!()
1551 +
        };
1552 +
        let crate::cbor::Value::Array(tags) = &m[&3] else {
1553 +
            panic!()
1554 +
        };
1555 +
        let crate::cbor::Value::Map(plain) = &tags[0] else {
1556 +
            panic!()
1557 +
        };
1558 +
        assert_eq!(plain.keys().copied().collect::<Vec<_>>(), vec![0, 1, 2, 3]);
1559 +
        let mut full = tag.clone();
1560 +
        full.name = "v2".into();
1561 +
        full.attestation = Some(SignedAttest {
1562 +
            attest: att.clone(),
1563 +
            sigs: vec![sig.clone()],
1564 +
        });
1565 +
        full.accepted = full.attestation.clone();
1566 +
        full.logged = Some(Logged {
1567 +
            index: 7,
1568 +
            entry: vec![4],
1569 +
            proof: vec![[3; 32]],
1570 +
        });
1571 +
        full.integration = vec![Integration {
1572 +
            note: "hub.example/log\n8\nroot\n\n- sig\n".into(),
1573 +
            proof: vec![],
1574 +
        }];
1575 +
        resp.tags = vec![full.clone(), tag.clone()];
1576 +
        let bytes = wire::encode_refs([3; 16], &resp);
1577 +
        assert_eq!(wire::decode_refs(&bytes).unwrap().1.tags, resp.tags);
1578 +
        // In a push too.
1579 +
        let pushed = wire::encode_push(&Bundle::default(), &[], None, &resp.tags);
1580 +
        assert_eq!(wire::decode_push(&pushed).unwrap().3, resp.tags);
1581 +
        // A reader that knows only keys 0 to 3 (oxy 0.5.0 and older, and
1582 +
        // the runners built with it) reads the same tags from the new answer.
1583 +
        let stripped: Vec<TagRef> = resp
1584 +
            .tags
1585 +
            .iter()
1586 +
            .map(|t| {
1587 +
                TagRef::new(
1588 +
                    t.name.clone(),
1589 +
                    t.state,
1590 +
                    t.heads.clone(),
1591 +
                    t.attestation.clone(),
1592 +
                )
1593 +
            })
1594 +
            .collect();
1595 +
        assert_eq!(wire::tags_as_0_5_read_them(&bytes).unwrap(), stripped);
1480 1596
1481 1597
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(
1482 1598
            &[ObjectRef([1; 32])],
…
68 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +4 −0
…
143 unchanged lines
144 144
        states.extend(view_now.tags.values().copied());
145 145
        for name in view_now.tags.keys() {
146 146
            if let Some((r, _)) = self.tag_attest(name)? {
147 +
                // With its issuer's acceptance (spec §14.7).
148 +
                if let Some(acc) = self.acceptance_of(&r)? {
149 +
                    attests.push(acc.attest.hash());
150 +
                }
147 151
                attests.push(r);
148 152
            }
149 153
        }
…
98 unchanged lines
modified crates/oxy-core/src/repo/strata.rs +7 −2
…
346 unchanged lines
347 347
        for t in &resp.tags {
348 348
            mirror.store.put_state(&StateObject::new(t.heads.clone()))?;
349 349
            mirror
350 -
                .adopt_tag(&mut view, t, policy.as_ref())
351 -
                .map_err(|e| RepoError::Msg(format!("{}: {e}", self.what())))?;
350 +
                .adopt_tag(
351 +
                    &mut view,
352 +
                    t,
353 +
                    policy.as_ref(),
354 +
                    &mut super::tags::TagCheck::Mirrored,
355 +
                )
356 +
                .map_err(|e| RepoError::Msg(format!("{}: {}", self.what(), e.reason)))?;
352 357
        }
353 358
        if view != op.view {
354 359
            mirror.record_op(&[op_ref], "strata sync", view)?;
…
1007 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +49 −28
1 1
//! Serving and talking to remotes (spec §10.2, §16).
2 2
3 +
use super::tags::TagCheck;
3 4
use super::*;
4 5
5 6
/// What a push did, for hosts (hub spec §6.12).
…
938 unchanged lines
944 945
        for t in &tags {
945 946
            let name = format!("tags/{}", t.name);
946 947
            let new = !view.tags.contains_key(&t.name);
947 -
            match self.adopt_tag(&mut view, t, policy.as_ref()) {
948 +
            match self.adopt_tag(&mut view, t, policy.as_ref(), &mut TagCheck::Received) {
948 949
                Ok(()) => {
949 950
                    // A tag new here is an update too, so a hub can start
950 951
                    // its pipeline's tag jobs (oxygenflow#24); one it
…
16 unchanged lines
967 968
                Err(e) => results.push(RefResult {
968 969
                    name,
969 970
                    ok: false,
970 -
                    reason: e,
971 +
                    reason: e.reason,
971 972
                }),
972 973
            }
973 974
        }
…
83 unchanged lines
1057 1058
        sa: &SignedAttest,
1058 1059
        accepted: Option<&SignedAttest>,
1059 1060
    ) -> u64 {
1060 -
        let own = sa.attest.hlc.wall_ms;
1061 -
        let Some(acc) = accepted else {
1062 -
            return own;
1063 -
        };
1064 -
        let Ok(Some(c)) = self.store.certificate_of(signer) else {
1065 -
            return own;
1066 -
        };
1067 -
        let Ok(cert) = self.store.get_certificate(&c) else {
1068 -
            return own;
1069 -
        };
1061 +
        self.issuer_acceptance(signer, sa, accepted)
1062 +
            .unwrap_or(sa.attest.hlc.wall_ms)
1063 +
    }
1064 +
1065 +
    /// The time of `accepted` when it accepts exactly `sa` and is signed by
1066 +
    /// the issuer of `signer`'s kept certificate (spec §14.7); else none.
1067 +
    pub(crate) fn issuer_acceptance(
1068 +
        &self,
1069 +
        signer: &KeyId,
1070 +
        sa: &SignedAttest,
1071 +
        accepted: Option<&SignedAttest>,
1072 +
    ) -> Option<u64> {
1073 +
        let acc = accepted?;
1074 +
        let c = self.store.certificate_of(signer).ok().flatten()?;
1075 +
        let cert = self.store.get_certificate(&c).ok()?;
1070 1076
        let AttestBody::Accepted { attest } = &acc.attest.body else {
1071 -
            return own;
1072 -
        };
1073 -
        let Some(sig) = acc.sigs.iter().find(|s| s.key == cert.cert.issuer) else {
1074 -
            return own;
1077 +
            return None;
1075 1078
        };
1076 -
        let Ok(issuer) = self.store.get_identity(&cert.cert.issuer) else {
1077 -
            return own;
1078 -
        };
1079 -
        if *attest == sa.attest.hash()
1080 -
            && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok()
1081 -
        {
1082 -
            acc.attest.hlc.wall_ms
1083 -
        } else {
1084 -
            own
1079 +
        let sig = acc.sigs.iter().find(|s| s.key == cert.cert.issuer)?;
1080 +
        let issuer = self.store.get_identity(&cert.cert.issuer).ok()?;
1081 +
        (*attest == sa.attest.hash()
1082 +
            && verify_signature(&issuer, sig, Domain::Attest, &acc.attest.hash()).is_ok())
1083 +
        .then_some(acc.attest.hlc.wall_ms)
1084 +
    }
1085 +
1086 +
    /// Keep an acceptance this repository was given for attestation `r`
1087 +
    /// (one [`Repo::issuer_acceptance`] took), so it is served again with
1088 +
    /// what it accepts.
1089 +
    pub(crate) fn keep_acceptance(&self, r: &ObjectRef, acc: &SignedAttest) -> Result<()> {
1090 +
        let a = self.store.put_attest(&acc.attest)?;
1091 +
        for sig in &acc.sigs {
1092 +
            self.store.put_signature(&a, sig)?;
1085 1093
        }
1094 +
        self.store.index_add_pub("accepted", r, &a)?;
1095 +
        Ok(())
1086 1096
    }
1087 1097
1088 1098
    /// A fetched bookmark's attestation by a signer the policy does not
…
689 unchanged lines
1778 1788
                self.import_bundle(&bundle, false)?;
1779 1789
            }
1780 1790
            self.store.put_state(&StateObject::new(t.heads.clone()))?;
1781 -
            self.adopt_tag(&mut view, t, policy.as_ref())
1782 -
                .map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?;
1783 -
            new_tags += 1;
1791 +
            let mut check = TagCheck::Fetched {
1792 +
                remote: remote_name,
1793 +
                log: &log,
1794 +
                notes: &mut log_notes,
1795 +
            };
1796 +
            match self.adopt_tag(&mut view, t, policy.as_ref(), &mut check) {
1797 +
                Ok(()) => new_tags += 1,
1798 +
                // Nothing says when a short-lived key signed it: the tag is
1799 +
                // left out, said so, and the rest is fetched (oxygen#129).
1800 +
                Err(e) if e.untimed => {
1801 +
                    skipped.push((format!("tags/{}", t.name), e.reason));
1802 +
                }
1803 +
                Err(e) => return fail(format!("{remote_name} {}: {}", t.name, e.reason)),
1804 +
            }
1784 1805
        }
1785 1806
        if view != op.view {
1786 1807
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
…
839 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +163 −14
…
4 unchanged lines
5 5
6 6
use super::*;
7 7
8 +
/// How a tag's signer is judged when it is adopted (spec §10.1, §14.7).
9 +
pub(crate) enum TagCheck<'a> {
10 +
    /// Received in a push: a short-lived key's certificate is checked at
11 +
    /// the receiver's own clock, or at its issuer's acceptance when the
12 +
    /// push carries one (a tag passed on from another host).
13 +
    Received,
14 +
    /// Fetched from `remote`: at the tag attestation's integrated time when
15 +
    /// the host's log gives one, else at its certificate issuer's
16 +
    /// acceptance; never at the time the tag claims, which its signer chose.
17 +
    Fetched {
18 +
        remote: &'a str,
19 +
        log: &'a super::transparency::LogCheck,
20 +
        notes: &'a mut Vec<String>,
21 +
    },
22 +
    /// Replayed into a Strata mirror from its store's record: at the
23 +
    /// acceptance when there is one, else at the tag's own time. The fetch
24 +
    /// from the mirror then judges it as every fetch does.
25 +
    Mirrored,
26 +
}
27 +
28 +
/// Why a tag was not adopted.
29 +
#[derive(Debug)]
30 +
pub(crate) struct TagRefusal {
31 +
    pub reason: String,
32 +
    /// Signed by a short-lived key, with nothing that says when to check
33 +
    /// its certificate: a fetch leaves the tag out and goes on.
34 +
    pub untimed: bool,
35 +
}
36 +
37 +
impl From<String> for TagRefusal {
38 +
    fn from(reason: String) -> TagRefusal {
39 +
        TagRefusal {
40 +
            reason,
41 +
            untimed: false,
42 +
        }
43 +
    }
44 +
}
45 +
46 +
impl From<&str> for TagRefusal {
47 +
    fn from(reason: &str) -> TagRefusal {
48 +
        reason.to_string().into()
49 +
    }
50 +
}
51 +
8 52
#[derive(Debug, Clone, PartialEq, Eq)]
9 53
pub struct TagInfo {
10 54
    pub name: String,
…
133 unchanged lines
144 188
                .tag_attest(&t.name)?
145 189
                .map(|(_, a)| a)
146 190
                .filter(|a| !a.sigs.is_empty());
147 -
            out.push(crate::protocol::TagRef {
148 -
                name: t.name.clone(),
149 -
                state: t.state,
150 -
                heads: t.heads,
151 -
                attestation,
152 -
            });
191 +
            // A short-lived key's tag goes with its issuer's acceptance
192 +
            // (spec §14.7), for whoever fetches it later.
193 +
            let accepted = match &attestation {
194 +
                Some(a) => self.tag_acceptance(a)?,
195 +
                None => None,
196 +
            };
197 +
            let mut tag = crate::protocol::TagRef::new(t.name, t.state, t.heads, attestation);
198 +
            tag.accepted = accepted;
199 +
            out.push(tag);
153 200
        }
154 201
        Ok(out)
202 +
    }
203 +
204 +
    /// The acceptance kept for tag attestation `sa`: its signer's
205 +
    /// certificate issuer's when one of those kept is, else the first.
206 +
    fn tag_acceptance(&self, sa: &SignedAttest) -> Result<Option<SignedAttest>> {
207 +
        let r = sa.attest.hash();
208 +
        let mut first = None;
209 +
        for a in self.store.index_get_pub("accepted", &r)? {
210 +
            let acc = SignedAttest {
211 +
                attest: self.store.get_attest(&a)?,
212 +
                sigs: self.store.signatures(&a)?,
213 +
            };
214 +
            let signer = sa.sigs.first().map(|s| s.key);
215 +
            if signer.is_some_and(|k| self.issuer_acceptance(&k, sa, Some(&acc)).is_some()) {
216 +
                return Ok(Some(acc));
217 +
            }
218 +
            first.get_or_insert(acc);
219 +
        }
220 +
        Ok(first)
155 221
    }
156 222
157 223
    /// Adopt a tag received from a remote or a push. Verifies the
158 224
    /// attestation when present (and requires one, by an authorized key,
159 225
    /// under a policy). An existing tag with a different state is refused.
226 +
    /// A short-lived key's tag is authorized by a subject grant, its
227 +
    /// certificate checked at the time `check` says (spec §14.7), and its
228 +
    /// issuer's acceptance is kept with it, or recorded when this host
229 +
    /// receives it.
160 230
    pub(crate) fn adopt_tag(
161 231
        &self,
162 232
        view: &mut View,
163 233
        t: &crate::protocol::TagRef,
164 234
        policy: Option<&crate::policy::Policy>,
165 -
    ) -> std::result::Result<(), String> {
235 +
        check: &mut TagCheck,
236 +
    ) -> std::result::Result<(), TagRefusal> {
166 237
        if let Some(existing) = view.tags.get(&t.name) {
167 238
            return if *existing == t.state {
168 239
                Ok(())
…
1 unchanged lines
170 241
                Err(format!(
171 242
                    "tag {:?} already exists with a different state; tags are immutable",
172 243
                    t.name
173 -
                ))
244 +
                )
245 +
                .into())
174 246
            };
175 247
        }
176 248
        let state = self
…
6 unchanged lines
183 255
                    "tag {:?} names change {}, which is missing",
184 256
                    t.name,
185 257
                    h.short(12)
186 -
                ));
258 +
                )
259 +
                .into());
187 260
            }
188 261
        }
189 262
        if let Some(sa) = &t.attestation {
…
19 unchanged lines
209 282
                .get_identity(&sig.key)
210 283
                .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
211 284
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
285 +
            let certified = self.store.certificate_of(&sig.key).ok().flatten().is_some();
286 +
            // The issuer's acceptance this tag came with, when it is one.
287 +
            let accepted = self.issuer_acceptance(&sig.key, sa, t.accepted.as_ref());
212 288
            if let Some(p) = policy {
213 289
                let reference = format!("tags/{}", t.name);
214 290
                let at = sa.attest.hlc.wall_ms;
215 -
                if !p.authorizes(&sig.key, &reference)
216 -
                    && !self.delegate_authorized(p, &sig.key, &reference, at)
217 -
                    && !self.predecessor_authorized(p, &sig.key, &reference, at)
291 +
                let listed = p.authorizes(&sig.key, &reference)
292 +
                    || self.delegate_authorized(p, &sig.key, &reference, at)
293 +
                    || self.predecessor_authorized(p, &sig.key, &reference, at);
294 +
                if !listed
295 +
                    && !(certified
296 +
                        && self.tag_certified_in_time(p, t, sa, &sig.key, accepted, check)?)
218 297
                {
219 298
                    return Err(format!(
220 299
                        "key {} may not create tags/{} under this repository's policy",
221 300
                        sig.key.short(12),
222 301
                        t.name
223 -
                    ));
302 +
                    )
303 +
                    .into());
224 304
                }
225 305
            }
226 306
            let stored = self
…
5 unchanged lines
232 312
                    .put_signature(&stored, s)
233 313
                    .map_err(|e| e.to_string())?;
234 314
            }
315 +
            if certified {
316 +
                match (&t.accepted, accepted) {
317 +
                    (Some(acc), Some(_)) => self.keep_acceptance(&stored, acc),
318 +
                    // Received here: this host says when, as it does for a
319 +
                    // bookmark's attestation.
320 +
                    _ if matches!(check, TagCheck::Received) => self.record_acceptance(&stored),
321 +
                    _ => Ok(()),
322 +
                }
323 +
                .map_err(|e| e.to_string())?;
324 +
            }
235 325
            self.set_tag_attest(&t.name, &stored)
236 326
                .map_err(|e| e.to_string())?;
237 327
        } else if policy.is_some() {
238 328
            return Err(format!(
239 329
                "tag {:?} needs a signed attestation under this repository's policy",
240 330
                t.name
241 -
            ));
331 +
            )
332 +
            .into());
242 333
        }
243 334
        view.tags.insert(t.name.clone(), t.state);
244 335
        Ok(())
336 +
    }
337 +
338 +
    /// Whether short-lived key `signer`'s certificate gives it tag `t`
339 +
    /// under policy `p` at the time `check` says (spec §14.7, §14.8):
340 +
    /// `accepted` is the issuer's acceptance time the tag came with.
341 +
    fn tag_certified_in_time(
342 +
        &self,
343 +
        p: &crate::policy::Policy,
344 +
        t: &crate::protocol::TagRef,
345 +
        sa: &SignedAttest,
346 +
        signer: &KeyId,
347 +
        accepted: Option<u64>,
348 +
        check: &mut TagCheck,
349 +
    ) -> std::result::Result<bool, TagRefusal> {
350 +
        use super::transparency::{judge, Timed};
351 +
        let reference = format!("tags/{}", t.name);
352 +
        let certified = |ms: u64| self.certified_authorized(p, signer, &reference, Scope::REFS, ms);
353 +
        Ok(match check {
354 +
            TagCheck::Received => certified(Hlc::now().wall_ms) || accepted.is_some_and(certified),
355 +
            TagCheck::Mirrored => certified(accepted.unwrap_or(sa.attest.hlc.wall_ms)),
356 +
            TagCheck::Fetched { remote, log, notes } => {
357 +
                let time = log.tag_time_of(&t.name);
358 +
                let integrated = time.as_ref().ok().map(|i| i.ms());
359 +
                if integrated.is_none() && accepted.is_none() {
360 +
                    return Err(TagRefusal {
361 +
                        reason: format!(
362 +
                            "signed by short-lived key {}, and {remote} gave no time to check its certificate at (neither its issuer's acceptance nor a witnessed log time: {}); a tag is never checked at the time it claims",
363 +
                            signer.short(12),
364 +
                            time.err().unwrap_or_default()
365 +
                        ),
366 +
                        untimed: true,
367 +
                    });
368 +
                }
369 +
                let what = format!("{remote} tag {}", t.name);
370 +
                match judge(integrated, certified, || accepted.is_some_and(certified)) {
371 +
                    Timed::Refused => false,
372 +
                    Timed::Integrated => true,
373 +
                    Timed::Disagrees => {
374 +
                        notes.push(format!(
375 +
                            "{what}: {}'s certificate had lapsed when witness {} first saw its attestation, though not at the issuer's acceptance; accepted while transparency logs are new",
376 +
                            signer.short(12),
377 +
                            time.as_ref().map(|i| i.witness.as_str()).unwrap_or("?")
378 +
                        ));
379 +
                        true
380 +
                    }
381 +
                    Timed::Fallback => {
382 +
                        if log.kept {
383 +
                            notes.push(format!(
384 +
                                "{what}: {} checked at the issuer's acceptance, not at a witnessed time: {}",
385 +
                                signer.short(12),
386 +
                                time.err().unwrap_or_default()
387 +
                            ));
388 +
                        }
389 +
                        true
390 +
                    }
391 +
                }
392 +
            }
393 +
        })
245 394
    }
246 395
}
modified crates/oxy-core/src/repo/tests.rs +180 −8
…
1205 unchanged lines
1206 1206
        .set_user_config("author.signer", &second.to_hex())
1207 1207
        .unwrap();
1208 1208
    let host = Repo::open(&root.join("origin")).unwrap();
1209 -
    assert!(host.store().get_identity(&first).is_err(), "the host has never seen the tag's key");
1209 +
    assert!(
1210 +
        host.store().get_identity(&first).is_err(),
1211 +
        "the host has never seen the tag's key"
1212 +
    );
1210 1213
    let r = agent.push("origin", false).unwrap();
1214 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1215 +
    let host = Repo::open(&root.join("origin")).unwrap();
1216 +
    assert!(
1217 +
        host.tag_refs().unwrap().iter().any(|t| t.name == "v1"),
1218 +
        "the tag arrived"
1219 +
    );
1220 +
}
1221 +
1222 +
/// oxygen#129: a short-lived key's tag is checked on a fetch at a time its
1223 +
/// signer did not choose, its issuer's acceptance, never at the time it
1224 +
/// claims nor at the fetch's (when the ten-minute certificate has long
1225 +
/// expired). Without an acceptance it is left out and said so; with one
1226 +
/// inside the certificate's window it is fetched however much later, and
1227 +
/// a host it is pushed on to keeps and serves the acceptance.
1228 +
#[test]
1229 +
fn a_short_lived_key_s_tag_is_fetched_at_its_issuer_s_acceptance() {
1230 +
    use crate::cert::{Certificate, SignedCertificate};
1231 +
    use crate::identity::Scope;
1232 +
    hermetic();
1233 +
    let dir = tempfile::tempdir().unwrap();
1234 +
    let root = dir.path();
1235 +
    let _origin = Repo::init(&root.join("origin")).unwrap();
1236 +
    let url = root.join("origin").to_string_lossy().to_string();
1237 +
    let mut a = Repo::clone(&url, &root.join("a"))
1238 +
        .unwrap()
1239 +
        .with_home(root.join("home-a"));
1240 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1241 +
        .unwrap();
1242 +
    write(&root.join("a"), "a.txt", b"a\n");
1243 +
    a.commit("a", &[]).unwrap();
1244 +
    a.policy_init().unwrap();
1245 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1246 +
    let hub_sk = SecretKey::generate();
1247 +
    let hub = hub_sk.identity("hub", "[email protected]", 1);
1248 +
    a.policy_grant_subject(
1249 +
        "agent:claude@ada",
1250 +
        hub.clone(),
1251 +
        Scope::CHANGES | Scope::REFS,
1252 +
        Some("*"),
1253 +
    )
1254 +
    .unwrap();
1255 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1256 +
1257 +
    let now = Hlc::now().wall_ms;
1258 +
    // An agent whose key the hub certified from `from` to `to`.
1259 +
    let agent = |name: &str, from: u64, to: u64| {
1260 +
        let mut r = Repo::clone(&url, &root.join(name))
1261 +
            .unwrap()
1262 +
            .with_home(root.join(format!("home-{name}")));
1263 +
        r.store().put_identity(&hub).unwrap();
1264 +
        let (_, id) = r
1265 +
            .create_identity(name, "[email protected]", IdentityScope::User)
1266 +
            .unwrap();
1267 +
        let cert = SignedCertificate::sign(
1268 +
            Certificate {
1269 +
                key: id,
1270 +
                issuer: hub.hash(),
1271 +
                subject: "agent:claude@ada".into(),
1272 +
                claims: vec![],
1273 +
                scope: Scope::CHANGES | Scope::REFS,
1274 +
                not_before: from,
1275 +
                expires: to,
1276 +
                grant: None,
1277 +
            },
1278 +
            &hub,
1279 +
            &hub_sk,
1280 +
        );
1281 +
        r.store().put_certificate(&cert).unwrap();
1282 +
        r
1283 +
    };
1284 +
1285 +
    // A tag by a key certified now, pushed to a host that records no
1286 +
    // acceptance (a path on disk): it lands, but a fetch has no time to
1287 +
    // check its certificate at, so it is left out and the fetch goes on.
1288 +
    let claude = agent("claude", now - 60_000, now + 600_000);
1289 +
    let heads = claude.bookmark_heads().unwrap();
1290 +
    claude.tag_create("v1", &heads, "one").unwrap();
1291 +
    let r = claude.push("origin", false).unwrap();
1211 1292
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1293 +
    let b = Repo::clone(&url, &root.join("b"))
1294 +
        .unwrap()
1295 +
        .with_home(root.join("home-b"));
1296 +
    let rep = b.fetch("origin").unwrap();
1297 +
    assert!(
1298 +
        rep.skipped
1299 +
            .iter()
1300 +
            .any(|(n, why)| n == "tags/v1" && why.contains("no time to check its certificate at")),
1301 +
        "{rep:?}"
1302 +
    );
1303 +
    assert_eq!(b.tag_heads("v1").unwrap(), None);
1304 +
1305 +
    // The host's acceptance, signed by the certificate's issuer, served
1306 +
    // with the tag: fetched.
1307 +
    let host = Repo::open(&root.join("origin"))
1308 +
        .unwrap()
1309 +
        .with_acceptor(hub.clone(), hub_sk.clone());
1310 +
    let (v1, _) = host.tag_attest("v1").unwrap().unwrap();
1311 +
    host.record_acceptance(&v1).unwrap();
1312 +
    let served = host.tag_refs().unwrap();
1313 +
    assert!(served
1314 +
        .iter()
1315 +
        .any(|t| t.name == "v1" && t.accepted.is_some()));
1316 +
    let rep = b.fetch("origin").unwrap();
1317 +
    assert!(rep.skipped.is_empty(), "{rep:?}");
1318 +
    assert_eq!(b.tag_heads("v1").unwrap(), Some(heads.clone()));
1319 +
    // gc keeps the acceptance with the tag.
1320 +
    host.gc(true).unwrap();
1321 +
    assert!(host.acceptance_of(&v1).unwrap().is_some());
1322 +
1323 +
    // Long after: a key whose certificate expired an hour ago may not
1324 +
    // create a tag now.
1325 +
    let late = agent("late", now - 7_200_000, now - 3_600_000);
1326 +
    late.tag_create("v2", &heads, "two").unwrap();
1327 +
    let r = late.push("origin", false).unwrap();
1328 +
    assert!(
1329 +
        r.iter()
1330 +
            .any(|x| x.name == "tags/v2" && !x.ok && x.reason.contains("may not create")),
1331 +
        "{r:?}"
1332 +
    );
1333 +
    // With its issuer's acceptance from inside the window (as a hub gave
1334 +
    // it then, passed on to another host), it is taken at that time: by
1335 +
    // the host it is pushed to, which keeps it, and by a fresh clone.
1336 +
    let (v2, _) = late.tag_attest("v2").unwrap().unwrap();
1337 +
    let acc = Attest {
1338 +
        repo: late.id().unwrap(),
1339 +
        hlc: Hlc {
1340 +
            wall_ms: now - 5_400_000,
1341 +
            logical: 0,
1342 +
        },
1343 +
        body: AttestBody::Accepted { attest: v2 },
1344 +
    };
1345 +
    let sig = hub_sk.sign(Domain::Attest, &hub.hash(), &acc.hash());
1346 +
    // One by any other key gives no time.
1347 +
    let stranger_sk = SecretKey::generate();
1348 +
    let stranger = stranger_sk.identity("stranger", "[email protected]", 1);
1349 +
    let forged = stranger_sk.sign(Domain::Attest, &stranger.hash(), &acc.hash());
1350 +
    late.keep_acceptance(
1351 +
        &v2,
1352 +
        &SignedAttest {
1353 +
            attest: acc.clone(),
1354 +
            sigs: vec![forged],
1355 +
        },
1356 +
    )
1357 +
    .unwrap();
1358 +
    let r = late.push("origin", false).unwrap();
1359 +
    assert!(
1360 +
        r.iter().any(|x| x.name == "tags/v2" && !x.ok),
1361 +
        "not the issuer's: {r:?}"
1362 +
    );
1363 +
    let theirs = SignedAttest {
1364 +
        attest: acc,
1365 +
        sigs: vec![sig],
1366 +
    };
1367 +
    // Kept beside the stranger's: the issuer's is the one sent.
1368 +
    late.keep_acceptance(&v2, &theirs).unwrap();
1369 +
    let r = late.push("origin", false).unwrap();
1370 +
    // (Its re-attestation of main is refused: an expired key moves no
1371 +
    // bookmark.)
1372 +
    assert!(r.iter().any(|x| x.name == "tags/v2" && x.ok), "{r:?}");
1212 1373
    let host = Repo::open(&root.join("origin")).unwrap();
1213 -
    assert!(host.tag_refs().unwrap().iter().any(|t| t.name == "v1"), "the tag arrived");
1374 +
    let (hv2, _) = host.tag_attest("v2").unwrap().unwrap();
1375 +
    assert_eq!(hv2, v2);
1376 +
    assert!(host
1377 +
        .store()
1378 +
        .index_get_pub("accepted", &v2)
1379 +
        .unwrap()
1380 +
        .contains(&theirs.attest.hash()));
1381 +
    let c = Repo::clone(&url, &root.join("c"))
1382 +
        .unwrap()
1383 +
        .with_home(root.join("home-c"));
1384 +
    assert_eq!(c.tag_heads("v2").unwrap(), Some(heads.clone()));
1385 +
    assert_eq!(c.tag_heads("v1").unwrap(), Some(heads));
1214 1386
}
1215 1387
1216 1388
/// oxygenhub#266: a short-lived key its issuer certified moves a bookmark
…
1709 unchanged lines
2926 3098
    a.policy_init().unwrap();
2927 3099
    a.push("origin", false).unwrap();
2928 3100
    origin.refresh().unwrap();
2929 -
    let unattested = crate::protocol::TagRef {
2930 -
        name: "v3".into(),
2931 -
        state: origin
3101 +
    let unattested = crate::protocol::TagRef::new(
3102 +
        "v3".into(),
3103 +
        origin
2932 3104
            .store()
2933 3105
            .put_state(&StateObject::new(vec![h2]))
2934 3106
            .unwrap(),
2935 -
        heads: vec![h2],
2936 -
        attestation: None,
2937 -
    };
3107 +
        vec![h2],
3108 +
        None,
3109 +
    );
2938 3110
    let r = origin
2939 3111
        .receive_push(Bundle::default(), vec![], None, vec![unattested])
2940 3112
        .unwrap();
…
6753 unchanged lines
modified crates/oxy-core/src/repo/transparency.rs +48 −15
…
333 unchanged lines
334 334
        out.notes.extend(accepted.notes.iter().cloned());
335 335
        let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?;
336 336
        let mut missing = 0;
337 -
        for r in &resp.refs {
338 -
            let Some(a) = &r.attestation else { continue };
339 -
            let Some(l) = &r.logged else {
337 +
        // Each attestation's place in the log, and its integrated time.
338 +
        let mut place = |name: &str,
339 +
                         kind: &str,
340 +
                         a: &SignedAttest,
341 +
                         logged: Option<&crate::protocol::Logged>,
342 +
                         integration: &[crate::protocol::Integration]|
343 +
         -> std::result::Result<Integrated, String> {
344 +
            let Some(l) = logged else {
340 345
                missing += 1;
341 -
                out.times
342 -
                    .insert(r.name.clone(), Err("it is not in the log yet".into()));
343 -
                continue;
346 +
                return Err("it is not in the log yet".into());
344 347
            };
345 348
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
346 -
                e.kind == "ref" && e.object == a.attest.hash() && e.repo == a.attest.repo
349 +
                e.kind == kind && e.object == a.attest.hash() && e.repo == a.attest.repo
347 350
            });
348 351
            let included = fits
349 352
                && tlog::check_inclusion(
…
6 unchanged lines
356 359
                .is_ok();
357 360
            if !included {
358 361
                out.notes.push(format!(
359 -
                    "{hub}'s log entry {} for {} does not prove its attestation is in the log",
360 -
                    l.index, r.name
362 +
                    "{hub}'s log entry {} for {name} does not prove its attestation is in the log",
363 +
                    l.index
361 364
                ));
362 365
            }
363 -
            let time = if !fits {
366 +
            if !fits {
364 367
                Err("its log entry is not its attestation's".to_string())
365 368
            } else if witnesses.is_empty() {
366 369
                Err(format!("{hub} names no witness"))
367 370
            } else {
368 -
                let cosigned: Vec<(&str, &[Hash])> = r
369 -
                    .integration
371 +
                let cosigned: Vec<(&str, &[Hash])> = integration
370 372
                    .iter()
371 373
                    .map(|i| (i.note.as_str(), i.proof.as_slice()))
372 374
                    .collect();
…
5 unchanged lines
378 380
                    &accepted.identity,
379 381
                    &witnesses,
380 382
                )
381 -
            };
382 -
            out.times.insert(r.name.clone(), time);
383 +
            }
384 +
        };
385 +
        let mut times = HashMap::new();
386 +
        for r in &resp.refs {
387 +
            let Some(a) = &r.attestation else { continue };
388 +
            let time = place(&r.name, "ref", a, r.logged.as_ref(), &r.integration);
389 +
            times.insert(r.name.clone(), time);
390 +
        }
391 +
        // Tags too (oxygen#129): a short-lived key's tag is checked at its
392 +
        // integrated time on every later fetch.
393 +
        let mut tag_times = HashMap::new();
394 +
        for t in &resp.tags {
395 +
            let Some(a) = &t.attestation else { continue };
396 +
            let name = format!("tags/{}", t.name);
397 +
            let time = place(&name, "tag", a, t.logged.as_ref(), &t.integration);
398 +
            tag_times.insert(t.name.clone(), time);
383 399
        }
400 +
        out.times = times;
401 +
        out.tag_times = tag_times;
384 402
        if missing > 0 {
385 403
            out.notes.push(format!(
386 404
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
…
24 unchanged lines
411 429
    pub notes: Vec<String>,
412 430
    /// Each attested bookmark's integrated time, or why it has none.
413 431
    pub times: HashMap<String, std::result::Result<Integrated, String>>,
432 +
    /// Each attested tag's, by tag name.
433 +
    pub tag_times: HashMap<String, std::result::Result<Integrated, String>>,
414 434
}
415 435
416 436
impl LogCheck {
417 437
    /// Bookmark `name`'s integrated time, or why there is none.
418 438
    pub fn time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
439 +
        self.lookup(&self.times, name)
440 +
    }
441 +
442 +
    /// Tag `name`'s integrated time, or why there is none.
443 +
    pub fn tag_time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
444 +
        self.lookup(&self.tag_times, name)
445 +
    }
446 +
447 +
    fn lookup<'a>(
448 +
        &self,
449 +
        times: &'a HashMap<String, std::result::Result<Integrated, String>>,
450 +
        name: &str,
451 +
    ) -> std::result::Result<&'a Integrated, String> {
419 452
        if let Some(why) = &self.unchecked {
420 453
            return Err(format!("its host's log was not checked ({why})"));
421 454
        }
422 455
        if !self.kept {
423 456
            return Err("its host keeps no transparency log".into());
424 457
        }
425 -
        match self.times.get(name) {
458 +
        match times.get(name) {
426 459
            Some(Ok(i)) => Ok(i),
427 460
            Some(Err(why)) => Err(why.clone()),
428 461
            None => Err("it has no log entry".into()),
…
162 unchanged lines
modified docs/protocol.md +1 −1
…
88 unchanged lines
89 89
| Capabilities | `{0: [protocol versions], 1: repo id bytes(16), 2: [feature names]}`; version `1`; features currently `["seal"]` |
90 90
| Refs response | `{0: [ref], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}` |
91 91
| ref | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}`; the attestation is present only when it describes the current state; `last serial` is the highest attestation serial ever recorded for the name; key 5 (optional, only when there is one) is the host's `accepted` attestation of that attestation (spec §14.7); key 6 (optional) is `[entry number, entry bytes, [proof hash]]`, the attestation's inclusion proof in the host's transparency log against the response's key 6, the latest checkpoint as a signed note (spec §14.8) |
92 -
| tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
92 +
| tag | `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: signed acceptance, 5: log place, 6: [cosigned checkpoint]}`; keys 4 to 6 (optional, each only when there is one; oxygen#129) are for the tag's attestation what a ref's keys 5 to 7 are for its own: the issuer's `accepted` attestation when a short-lived key signed the tag (spec §14.7), which a fetch checks the certificate at, and its place in the host's transparency log with the cosigned checkpoints that first held it (spec §14.8); a push carries key 4 when the client has one; older readers ignore them |
93 93
| Negotiate request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order (no change before its dependencies) |
94 94
| Fetch request | `{0: [hashes], 1: max blob size or null}`; response a Bundle whose blobs above the size (and their chunks) are omitted |
95 95
| Objects request | `{0: [refs]}`; response a Bundle carrying only objects; a blob is accompanied by its chunks |
…
108 unchanged lines
modified docs/spec.md +6 −6
…
433 unchanged lines
434 434
435 435
Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update. A fetched bookmark whose attestation cannot be verified because its signer's identity is unknown to the client or its signature does not verify is skipped, not fatal, when it is not the current bookmark: its `remotes/` reference stays as it was, the client warns naming it and why, and the other bookmarks are fetched (oxygenhub#266). For the current bookmark (the one a `pull` takes, or a `clone` checks out) the fetch, pull or clone still fails, and an attestation that is missing under a policy, unauthorized, rolled back or not describing the update fails the fetch as before.
436 436
437 -
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them.
437 +
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them. A tag signed by a short-lived key (§14.7), as a keyless agent's is, is authorized by a subject grant like a bookmark update: on a push at the receiver's clock, on a fetch at a time its signer did not choose (§14.7, *Acceptance*). The push carries the tag signer's identity and certificate as it does the bookmark signer's, since the tag may have been made by an earlier process's key (oxygen#129).
438 438
439 439
## 11. Checkpoints
440 440
…
272 unchanged lines
713 713
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it.
714 714
- A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate.
715 715
- A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2), or, on a fetch, at its integrated time when the host's log gives one (§14.8). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is.
716 -
- A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the attestation's integrated time when the host's log gives one (§14.8, *Integrated time*); without one, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged.
716 +
- A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. The time is the receiver's own clock when it receives a push (changes and the reference attestation), and, on a fetch, the attestation's integrated time when the host's log gives one (§14.8, *Integrated time*); without one, the time the certificate's issuer says it accepted the update (§14.7, *Acceptance*), or the attestation's own time when there is no such acceptance, which is weaker: a key whose certificate expired could backdate an attestation within its scope. A tag is never checked at its own time: a fetch without an integrated time or an acceptance for it leaves the tag out and says why, and fetches the rest (oxygen#129). A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged.
717 717
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
718 718
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
719 719
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors.
…
63 unchanged lines
783 783
784 784
A signed grant is `[grant bytes, Signature]`: the grantor's composite signature, in domain `grant`, over the grant's ObjectRef (type `grant`). It needs no maximum lifetime: it gives nothing until an issuer certifies a key under it, and the grantor revokes it by asking the issuer to stop. The issuer puts it in each certificate it issues under it (field 8), so it travels with the certificate and a verifier needs nothing else. A signature by a key that is neither a change's author nor its delegate (§14.4) still satisfies the author-or-delegate rule when the key's certificate verifies with its issuer and carries a grant that verifies with the change's author, names the certificate's subject and issuer, gives the scope needed (`changes` for a change signature, `seal` for a seal) in both the grant and the certificate, and covers the change's `hlc.wall_ms`, as a delegation does. Whether the certificate covered the time the key signed is the policy's check (§14.5), at a time the signer did not choose.
785 785
786 -
**Acceptance.** A host that issues certificates records when it accepted a reference attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation) and serves it with the bookmark (§16, `refs` key 5). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'.
786 +
**Acceptance.** A host that issues certificates records when it accepted a reference or tag attestation signed by a short-lived key: an `accepted` attestation (§14.4) naming that attestation's ObjectRef, its `hlc` the host's clock at the push, signed in domain `attest` by the issuer's key. The host keeps it indexed by the attestation it accepts (gc keeps it beside the bookmark's latest attestation or the tag's) and serves it with the bookmark (§16, `refs` entry key 5) or the tag (tag key 4). A fetch checks the certificate at the acceptance's time only when the acceptance names exactly the attestation being checked and verifies with the certificate's issuer; otherwise, including when there is none, at the attestation's own time for a bookmark, and not at all for a tag, which is left out (§14.5). Tags are kept for good and fetched long after a ten-minute certificate expired, so the issuer's acceptance travels with the tag: a client that fetched one keeps it and serves it with the tag, a push carries it, and a receiver checks a tag that comes with its issuer's acceptance at that time when its own clock is past the certificate's window (a tag passed on from the host that accepted it to another), keeping the acceptance rather than recording its own. An acceptance by any other key changes nothing: the issuer is already trusted to vouch for the key, so it adds no trust it did not have. A fetch that has the attestation's integrated time (§14.8) checks at that instead: the acceptance is the issuer's clock, the integrated time its witnesses'.
787 787
788 788
### 14.8 The transparency log
789 789
…
43 unchanged lines
833 833
**Integrated time** (oxygenhub#240). A cosignature is a witness's statement, by its own clock, that the checkpoint existed then, so every entry the checkpoint holds existed by that time, whatever its signer or the host claims. An entry's **integrated time** is the earliest time a witness the reader trusts cosigned a checkpoint that holds it: an upper bound on when its object was signed, in seconds, by a clock neither the signer nor the host chose.
834 834
- **Witness keys.** A host lists the witnesses it asks to cosign, each as its name in cosignatures and its identity (refs response key 7, §16.3; and `GET /log/witnesses`, one `<name> <base64 identity>` line each). A client pins them per user and hub beside the log key (`<oxy home>/tlog/<hub>.witnesses`): the first list silently, as the log key on first sight; a witness listed later, or listed under a pinned name with another key, is pinned with a note, as a new log key is. A witness the host stops listing stays pinned. A cosignature counts only when both its lines verify with a pinned witness's key; lines by anyone else are ignored. Trust-on-first-use gives no more than the host's own word on first contact, which is what the acceptance gives today (§14.7); after it, a host that names a witness of its own to move a time earlier is noted by every client that pinned the real ones.
835 835
- **What a host serves.** With a bookmark's entry (ref key 6), the cosigned checkpoints that first held it (ref key 7): each a checkpoint note with the log's signatures and its witnesses' cosignatures, and the entry's inclusion proof against it, one for each witness when they first cosigned different checkpoints. A client takes one when its origin is the log's, it verifies with the pinned log key, the entry is the attestation's (as above) and the proof verifies; the integrated time is the earliest trusted cosignature among those it takes. A host can only make the time later by serving a later checkpoint, which weakens the bound and helps no one but a signer it would refuse; it cannot make it earlier without a witness's key.
836 -
- **Use.** On a fetch, a check that depends on when a reference attestation was signed (whether a delegation with `refs` was in force, §14.5, or whether a certificate covered it, §14.7) is made at the integrated time when there is one. Without one (the host keeps no log, the entry is in no cosigned checkpoint yet, or no trusted witness cosigned it), it is made as before, at the issuer's acceptance or the attestation's own time, and the fetch says so when the host keeps a log. While logs are new (oxygenhub docs/transparency.md, decision 3), an attestation that fails at its integrated time but passes as before is accepted with a note rather than refused; one that fails both is refused, as before. A key the rule lists itself is authorized whenever it signed, and revocations are absolute (§14.5): neither depends on the time. A push is checked at the receiver's own clock, as before.
836 +
- **Use.** On a fetch, a check that depends on when a reference or tag attestation was signed (whether a delegation with `refs` was in force, §14.5, or whether a certificate covered it, §14.7) is made at the integrated time when there is one. Without one (the host keeps no log, the entry is in no cosigned checkpoint yet, or no trusted witness cosigned it), it is made as before, at the issuer's acceptance or the attestation's own time (for a tag, at the acceptance only, §14.7), and the fetch says so when the host keeps a log. While logs are new (oxygenhub docs/transparency.md, decision 3), an attestation that fails at its integrated time but passes as before is accepted with a note rather than refused; one that fails both is refused, as before. A key the rule lists itself is authorized whenever it signed, and revocations are absolute (§14.5): neither depends on the time. A push is checked at the receiver's own clock, as before.
837 837
- **A large gap.** For an attestation whose check depends on the time, a claimed time (`hlc.wall_ms`) more than 10 minutes before its integrated time, or more than 10 minutes after it, is noted, not refused: the attestation was backdated, held back before it was logged (one a host accepted before its log began, logged when first served), or signed on a wrong clock. A reference attestation is made at push and logged and cosigned within seconds, so ten minutes leaves room for drift and for a witness that was briefly away.
838 838
839 839
**`oxy tlog verify [HUB]`** audits a whole log: it accepts the checkpoint as a fetch does, downloads every entry bundle, and requires the entries to make the root the checkpoint signs. It names the trusted witnesses that cosigned the checkpoint and when (pinning those `GET /log/witnesses` lists, as a fetch does), a bound on when every entry was made. It then lists this repository's entries (`--key KEYID` for a key's, `--all` for every one, `--json` for machines).
…
55 unchanged lines
895 895
| Message | Encoding |
896 896
|---|---|
897 897
| Signed attestation | `[attest bytes, [Signature]]` |
898 -
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not |
898 +
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]], 3: [signed policy]}`; changes in dependency order; objects are chunks, blobs, identities, and states only, and include the text blobs of every change in the bundle and their chunks, so a bundle can be read on its own; a push that moves a bookmark also carries the identity that signed the move and, for a delegate, the identity it acts for, so a receiver that has seen no change by that key can check it (oxygenhub#129), and a push that sends tags carries each tag signer's identity and, for a short-lived key, its certificate likewise (oxygen#129); key 3 (optional) holds the policies between the receiver's serial and the one sent with a push, ascending; key 4 (optional, only when there are any) holds signed certificates (§14.7) of short-lived keys that signed what the bundle carries or the push's attestation, their keys' and issuers' identities among the objects; a receiver keeps each that verifies with its issuer and refuses a bundle with one that does not |
899 899
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
900 900
| Signed policy | `[policy bytes, [Signature]]` |
901 -
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
901 +
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null, 5: signed acceptance}], 1: repo id, 2: signed policy or null, 3: [tag], 4: [signed policy]}`; key 4 (optional) lists earlier policies ascending by serial; key 5 (optional, text or null) names the default bookmark, which a clone checks out; the attestation is present only when it describes the current state; an entry's key 5 (optional, only when there is one) is the host's acceptance of that attestation (§14.7); an entry's key 6 (optional) is the attestation's place in the host's transparency log, `[entry number, entry bytes, [proof hash]]`, an inclusion proof against the checkpoint in the response's key 6 (§14.8); an entry's key 7 (optional, only beside key 6 and when there are any) lists the cosigned checkpoints that first held that log entry, `[[checkpoint note text, [proof hash]]]`, each with the entry's inclusion proof against it, from which its integrated time is read (§14.8); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; the response's key 7 (optional, only when there are any) lists the witnesses the host asks to cosign, `[[name, identity bytes]]`, which a client pins (§14.8); a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}`, and optionally, each only when there is one, `4: signed acceptance`, `5: log place`, `6: [cosigned checkpoint]`, meaning for the tag's attestation what a bookmark entry's keys 5 to 7 mean for its attestation (oxygen#129); a reader that does not know them ignores them |
902 902
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
903 903
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size; also when it names no change, the bundle carries the identities of the keys that signed the attestations served with `refs` (each bookmark's latest and each tag's) and, for a short-lived key, its certificate and its issuer's identity (§14.7), each once, so a client can check an attestation whose signer signed none of the changes it receives, such as a delegate's or a keyless agent's bookmark move (oxygenhub#266) |
904 904
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
…
282 unchanged lines