OXYGENHUB

Trusted time: judge a fetched attestation at its integrated time 028545dfa7a4

Step A6 of the transparency log (oxygenhub#240). An entry's integrated
time is the earliest time a trusted witness cosigned a checkpoint that
holds it: an upper bound on when it was signed, by a witness's clock,
which a hub can only make later, never earlier. The client pins the
hub's witnesses on first use beside its log key (<oxy home>/tlog/
<hub>.witnesses; a new or changed witness key is a note), counts a
cosignature only when both its lines verify against a pinned key, and
checks the cosigned checkpoint and the entry's inclusion proof.

On fetch, a delegate's refs delegation and a short-lived key's
certificate are checked at the integrated time when there is one;
without one, as before (the hub's acceptance, else the attestation's
own time), with a note when the host keeps a log. A claimed time more
than 10 minutes off the integrated time is noted. Flagged, not refused,
for now. Wire: refs key 7 (the hub's witnesses), ref key 7 (the
cosigned checkpoints that first held the key-6 entry, with proofs);
additive. oxy tlog verify shows who cosigned and when. Spec §14.5,
§14.7, §14.8, §16.3.

The hub serves the new fields next; until then fetches of keyless-signed
bookmarks note that they were checked at the hub's time.

Refs andymccall/oxygenhub#240
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 8b4e17071dfe
When
· 2026-10-18 20:29 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
028545dfa7a4797ef6fa2537bd255cc2f050b6bc3002f5acb00d911007567e2b
Depends on
02e691881a4e 0ed150d62994 132b884b9a8d 14edbaaf5291 28fc23d7706a 3526d339519f 3e5772ccf9da 4b9d06778581 5b9c6ad45f6e 750251b21a96 75167672ebcd 7ba155f20aa0 83e0677b07fe 982864068a59 a2387d547373 b0621c2f8cc4 b333a19976e4 e59512f2f075 e5f8eb2a2006 f0a517d28237 f53cbe7a204e f8c695764c95 ff0af1ee299d
Depended on by
2f0b4b0651ea b63fc7a77fc6 1ec6fdbc6bcb d36ba2f79f95
In bookmarks
doctor-trust identical-sides keyless-tags main push-notes subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
62 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/oxy-core/src/cert.rs +2 −1
…
7 unchanged lines
8 8
//! Whether to trust an issuer, and for which subjects, is the trust
9 9
//! policy's (§14.5); whether a signature was made inside the certificate's
10 10
//! window needs time the signer does not choose, which the caller passes
11 -
//! in (a host's acceptance now, the transparency log's later).
11 +
//! in (the transparency log's integrated time, §14.8, else a host's
12 +
//! acceptance).
12 13
13 14
use crate::cbor::{self, Value};
14 15
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
…
499 unchanged lines
modified crates/oxy-core/src/protocol.rs +124 −18
…
59 unchanged lines
60 60
    /// §14.8), against which `RemoteRef::logged` proofs are made (key 6,
61 61
    /// only when the host keeps a log).
62 62
    pub log: Option<String>,
63 +
    /// The witnesses the host asks to cosign its checkpoints: each one's
64 +
    /// name in cosignatures and its identity's bytes (key 7, only when it
65 +
    /// has any), which a client pins (spec §14.8, *Integrated time*).
66 +
    pub witnesses: Vec<(String, Vec<u8>)>,
63 67
}
64 68
65 69
/// Where a reference attestation sits in the host's transparency log
…
6 unchanged lines
72 76
    pub proof: Vec<[u8; 32]>,
73 77
}
74 78
79 +
/// A checkpoint witnesses cosigned that includes a logged entry (spec
80 +
/// §14.8, *Integrated time*): the cosigned note, and the entry's inclusion
81 +
/// proof against it.
82 +
#[derive(Clone, Debug, PartialEq, Eq)]
83 +
pub struct Integration {
84 +
    pub note: String,
85 +
    pub proof: Vec<[u8; 32]>,
86 +
}
87 +
75 88
/// A tag as a remote reports it (spec §10.1).
76 89
#[derive(Clone, Debug, PartialEq, Eq)]
77 90
pub struct TagRef {
…
21 unchanged lines
99 112
    /// The attestation's place in the host's transparency log (key 6, only
100 113
    /// when it has one).
101 114
    pub logged: Option<Logged>,
115 +
    /// The cosigned checkpoints that first included `logged`'s entry (key
116 +
    /// 7, only when there are any): what its integrated time is read from.
117 +
    pub integration: Vec<Integration>,
102 118
}
103 119
104 120
/// Everything needed to add a set of changes to a repository.
…
850 unchanged lines
955 971
                                        Value::Array(vec![
956 972
                                            Value::Uint(l.index),
957 973
                                            Value::Bytes(l.entry.clone()),
958 -
                                            Value::Array(
959 -
                                                l.proof
960 -
                                                    .iter()
961 -
                                                    .map(|h| Value::Bytes(h.to_vec()))
962 -
                                                    .collect(),
963 -
                                            ),
974 +
                                            proof_value(&l.proof),
964 975
                                        ]),
965 976
                                    );
966 977
                                }
978 +
                                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 +
                                    );
993 +
                                }
967 994
                            }
968 995
                            v
969 996
                        })
…
15 unchanged lines
985 1012
                    .unwrap_or(Value::Null),
986 1013
            ),
987 1014
        ]);
988 -
        if let (Some(log), Value::Map(m)) = (&resp.log, &mut v) {
989 -
            m.insert(6, Value::Text(log.clone()));
1015 +
        if let Value::Map(m) = &mut v {
1016 +
            if let Some(log) = &resp.log {
1017 +
                m.insert(6, Value::Text(log.clone()));
1018 +
            }
1019 +
            if !resp.witnesses.is_empty() {
1020 +
                m.insert(
1021 +
                    7,
1022 +
                    Value::Array(
1023 +
                        resp.witnesses
1024 +
                            .iter()
1025 +
                            .map(|(name, id)| {
1026 +
                                Value::Array(vec![
1027 +
                                    Value::Text(name.clone()),
1028 +
                                    Value::Bytes(id.clone()),
1029 +
                                ])
1030 +
                            })
1031 +
                            .collect(),
1032 +
                    ),
1033 +
                );
1034 +
            }
990 1035
        }
991 1036
        cbor::encode(&v)
992 1037
    }
…
26 unchanged lines
1019 1064
                        let a = o::array(l, "logged")?;
1020 1065
                        if a.len() != 3 {
1021 1066
                            return Err(schema("logged: expected [index, entry, proof]"));
1022 -
                        }
1023 -
                        let mut proof = Vec::new();
1024 -
                        for h in o::array(&a[2], "proof")? {
1025 -
                            proof.push(
1026 -
                                o::bytes(h, "proof hash")?
1027 -
                                    .try_into()
1028 -
                                    .map_err(|_| schema("proof hash: expected 32 bytes"))?,
1029 -
                            );
1030 1067
                        }
1031 1068
                        Some(Logged {
1032 1069
                            index: o::uint(&a[0], "index")?,
1033 1070
                            entry: o::bytes(&a[1], "entry")?.to_vec(),
1034 -
                            proof,
1071 +
                            proof: proof_from(&a[2])?,
1035 1072
                        })
1036 1073
                    }
1037 1074
                    None => None,
1038 1075
                },
1076 +
                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 +
                    }
1091 +
                    None => Vec::new(),
1092 +
                },
1039 1093
            });
1040 1094
        }
1041 1095
        let policy = match m.get(&2) {
…
18 unchanged lines
1060 1114
            Some(Value::Text(t)) => Some(t.clone()),
1061 1115
            _ => None,
1062 1116
        };
1117 +
        let mut witnesses = Vec::new();
1118 +
        if let Some(w) = m.get(&7) {
1119 +
            for x in o::array(w, "witnesses")? {
1120 +
                let a = o::array(x, "witness")?;
1121 +
                if a.len() != 2 {
1122 +
                    return Err(schema("witness: expected [name, identity]"));
1123 +
                }
1124 +
                witnesses.push((
1125 +
                    o::text(&a[0], "witness name")?.to_owned(),
1126 +
                    o::bytes(&a[1], "witness identity")?.to_vec(),
1127 +
                ));
1128 +
            }
1129 +
        }
1063 1130
        Ok((
1064 1131
            id,
1065 1132
            RefsResponse {
…
3 unchanged lines
1069 1136
                history,
1070 1137
                default_bookmark,
1071 1138
                log,
1139 +
                witnesses,
1072 1140
            },
1073 1141
        ))
1074 1142
    }
1075 1143
1144 +
    /// A proof's hashes, from the leaf upwards (spec §14.8).
1145 +
    fn proof_value(proof: &[[u8; 32]]) -> Value {
1146 +
        Value::Array(proof.iter().map(|h| Value::Bytes(h.to_vec())).collect())
1147 +
    }
1148 +
1149 +
    fn proof_from(v: &Value) -> Result<Vec<[u8; 32]>> {
1150 +
        let mut proof = Vec::new();
1151 +
        for h in o::array(v, "proof")? {
1152 +
            proof.push(
1153 +
                o::bytes(h, "proof hash")?
1154 +
                    .try_into()
1155 +
                    .map_err(|_| schema("proof hash: expected 32 bytes"))?,
1156 +
            );
1157 +
        }
1158 +
        Ok(proof)
1159 +
    }
1160 +
1076 1161
    pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> {
1077 1162
        cbor::encode(&Value::map([
1078 1163
            (0, hashes_value(have)),
…
245 unchanged lines
1324 1409
                last_serial: Some(4),
1325 1410
                accepted: None,
1326 1411
                logged: None,
1412 +
                integration: vec![],
1327 1413
            },
1328 1414
            RemoteRef {
1329 1415
                name: "dev".into(),
…
3 unchanged lines
1333 1419
                last_serial: None,
1334 1420
                accepted: None,
1335 1421
                logged: None,
1422 +
                integration: vec![],
1336 1423
            },
1337 1424
        ];
1338 1425
        let tag = TagRef {
…
15 unchanged lines
1354 1441
            }],
1355 1442
            default_bookmark: Some("develop".into()),
1356 1443
            log: Some("hub.example/log\n2\nroot\n\n- sig\n".into()),
1444 +
            witnesses: vec![],
1357 1445
        };
1446 +
        // Without integrated times (spec §14.8) nothing is added: an old
1447 +
        // peer's answer, and what it reads.
1448 +
        let old = wire::encode_refs([3; 16], &resp);
1449 +
        let back = wire::decode_refs(&old).unwrap().1;
1450 +
        assert!(back.witnesses.is_empty() && back.refs.iter().all(|r| r.integration.is_empty()));
1358 1451
        resp.refs[1].logged = Some(Logged {
1359 1452
            index: 41,
1360 1453
            entry: vec![1, 2, 3],
1361 1454
            proof: vec![[9; 32], [8; 32]],
1362 1455
        });
1363 -
        let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap();
1456 +
        resp.refs[1].integration = vec![Integration {
1457 +
            note: "hub.example/log\n42\nroot\n\n- sig\n".into(),
1458 +
            proof: vec![[7; 32]],
1459 +
        }];
1460 +
        resp.witnesses = vec![("w.example".into(), vec![4, 5, 6])];
1461 +
        let new = wire::encode_refs([3; 16], &resp);
1462 +
        let (id_back, back) = wire::decode_refs(&new).unwrap();
1364 1463
        assert_eq!(back.log, resp.log);
1365 1464
        assert_eq!(back.refs[1].logged, resp.refs[1].logged);
1465 +
        assert_eq!(back.refs[1].integration, resp.refs[1].integration);
1466 +
        assert_eq!(back.witnesses, resp.witnesses);
1366 1467
        assert_eq!(back.refs[0].logged, None);
1468 +
        // The new keys are 7s an old reader never asks for.
1469 +
        let crate::cbor::Value::Map(m) = crate::cbor::decode(&new).unwrap() else {
1470 +
            panic!()
1471 +
        };
1472 +
        assert!(m.contains_key(&7));
1367 1473
        assert_eq!(id_back, [3; 16]);
1368 1474
        assert_eq!(back.refs.len(), 2);
1369 1475
        assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att);
…
75 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +66 −13
…
104 unchanged lines
105 105
                last_serial,
106 106
                accepted,
107 107
                logged: None,
108 +
                integration: vec![],
108 109
            });
109 110
        }
110 111
        let policy = self.current_policy()?.map(|(_, sp)| sp);
…
13 unchanged lines
124 125
            history,
125 126
            default_bookmark: Some(self.config.bookmark.clone()),
126 127
            log: None,
128 +
            witnesses: vec![],
127 129
        })
128 130
    }
129 131
…
825 unchanged lines
955 957
        }
956 958
    }
957 959
960 +
    /// A fetched bookmark's attestation by a signer the policy does not
961 +
    /// list itself: a delegate or a short-lived key, whose authority
962 +
    /// depends on when it signed (spec §14.5, §14.8). Checked at the
963 +
    /// attestation's integrated time when the host's log gives one; else
964 +
    /// as before, at the host's acceptance for a certified signer, when it
965 +
    /// countersigned one, or at the attestation's own time, and said so
966 +
    /// when the host keeps a log. Failing at the integrated time but not
967 +
    /// as before is a note while logs are new; a large gap between the
968 +
    /// claimed and the integrated time is a note.
969 +
    #[allow(clippy::too_many_arguments)]
970 +
    pub(crate) fn authorize_in_time(
971 +
        &self,
972 +
        p: &crate::policy::Policy,
973 +
        remote_name: &str,
974 +
        r: &crate::protocol::RemoteRef,
975 +
        sa: &SignedAttest,
976 +
        signer: &KeyId,
977 +
        log: &super::transparency::LogCheck,
978 +
        notes: &mut Vec<String>,
979 +
    ) -> Result<()> {
980 +
        use super::transparency::{gap_note, judge, Timed};
981 +
        let own = sa.attest.hlc.wall_ms;
982 +
        let at = self.certified_time(signer, sa, r.accepted.as_ref());
983 +
        let time = log.time_of(&r.name);
984 +
        let check = |t_delegate: u64, t_cert: u64| {
985 +
            self.delegate_authorized(p, signer, &r.name, t_delegate)
986 +
                || self.certified_authorized(p, signer, &r.name, Scope::REFS, t_cert)
987 +
        };
988 +
        let before = if at != own {
989 +
            "the host's acceptance"
990 +
        } else {
991 +
            "the time it claims"
992 +
        };
993 +
        let what = format!("{remote_name}/{}", r.name);
994 +
        match judge(time.as_ref().ok().map(|i| i.ms()), |t| check(t, t), || check(own, at)) {
995 +
            Timed::Refused => {
996 +
                return fail(format!(
997 +
                    "{what}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`",
998 +
                    signer.short(12)
999 +
                ))
1000 +
            }
1001 +
            Timed::Integrated => {}
1002 +
            Timed::Disagrees => notes.push(format!(
1003 +
                "{what}: {}'s authority had lapsed when witness {} first saw its attestation, though not at {before}; accepted while transparency logs are new",
1004 +
                signer.short(12),
1005 +
                time.as_ref().map(|i| i.witness.as_str()).unwrap_or("?")
1006 +
            )),
1007 +
            Timed::Fallback if log.kept => notes.push(format!(
1008 +
                "{what}: {} checked at {before}, not at a witnessed time: {}",
1009 +
                signer.short(12),
1010 +
                time.as_ref().err().map(String::as_str).unwrap_or("none")
1011 +
            )),
1012 +
            Timed::Fallback => {}
1013 +
        }
1014 +
        if let Some(n) = time.ok().and_then(|i| gap_note(own, i)) {
1015 +
            notes.push(format!("{what}'s attestation {n}"));
1016 +
        }
1017 +
        Ok(())
1018 +
    }
1019 +
958 1020
    /// A deletion (spec §16.4): `new_state` is zero. The bookmark must exist
959 1021
    /// at `old_state`, is never the bookmark a non-bare repository has
960 1022
    /// checked out (a bare one's default is its host's to protect, see
…
450 unchanged lines
1411 1473
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
1412 1474
        let resp = remote.refs()?;
1413 1475
        // The host's transparency log (spec §14.8): a fork fails here.
1414 -
        let log_notes = self.check_log(
1476 +
        let log = self.check_log(
1415 1477
            &self.remote_url(remote_name)?,
1416 1478
            self.config.remote_cas.get(remote_name).map(Path::new),
1417 1479
            &resp,
1418 1480
        )?;
1481 +
        let mut log_notes = log.notes.clone();
1419 1482
        // Trust policy (spec §14.5). Adopt the remote's policy when it is newer
1420 1483
        // and signed by roots we trust, or on first contact. A remote that
1421 1484
        // once presented a policy may never present none or an older one:
…
88 unchanged lines
1510 1573
                    .first()
1511 1574
                    .map(|s| s.key)
1512 1575
                    .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
1513 -
                // A certified signer is checked at the host's acceptance,
1514 -
                // when it countersigned one; else at the attestation's time.
1515 -
                let at = self.certified_time(&signer, sa, r.accepted.as_ref());
1516 -
                if !p.authorizes(&signer, &r.name)
1517 -
                    && !self.delegate_authorized(p, &signer, &r.name, sa.attest.hlc.wall_ms)
1518 -
                    && !self.certified_authorized(p, &signer, &r.name, Scope::REFS, at)
1519 -
                {
1520 -
                    return fail(format!(
1521 -
                        "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`",
1522 -
                        r.name,
1523 -
                        signer.short(12)
1524 -
                    ));
1576 +
                if !p.authorizes(&signer, &r.name) {
1577 +
                    self.authorize_in_time(p, remote_name, &r, sa, &signer, &log, &mut log_notes)?;
1525 1578
                }
1526 1579
            }
1527 1580
            if let Some(sa) = &r.attestation {
…
853 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +279 −0
…
1126 unchanged lines
1127 1127
    );
1128 1128
}
1129 1129
1130 +
/// Trusted time (oxygenhub#240, spec §14.8): a fetched attestation by a
1131 +
/// short-lived key is judged at its integrated time, the earliest time a
1132 +
/// pinned witness cosigned a checkpoint holding it. A certificate valid
1133 +
/// then but expired now passes; a witness this user does not trust gives
1134 +
/// no time (the earlier rule, said so); a backdated attestation is noted;
1135 +
/// an answer without the new fields is judged as before.
1136 +
#[test]
1137 +
fn a_fetched_attestation_is_judged_at_its_integrated_time() {
1138 +
    use super::transparency::LogCheck;
1139 +
    use crate::cert::{Certificate, SignedCertificate};
1140 +
    use crate::identity::Scope;
1141 +
    use crate::protocol::{Integration, Logged, RefsResponse, RemoteRef};
1142 +
    use crate::tlog::{self, Checkpoint, Entry, Hash, Integrated, Note};
1143 +
    hermetic();
1144 +
    let dir = tempfile::tempdir().unwrap();
1145 +
    let root = dir.path();
1146 +
    let mut a = Repo::init(&root.join("a"))
1147 +
        .unwrap()
1148 +
        .with_home(root.join("home-a"));
1149 +
    a.create_identity("Ada", "[email protected]", IdentityScope::User)
1150 +
        .unwrap();
1151 +
    write(&root.join("a"), "a.txt", b"a\n");
1152 +
    a.commit("a", &[]).unwrap();
1153 +
    a.policy_init().unwrap();
1154 +
    let issuer_sk = SecretKey::generate();
1155 +
    let issuer = issuer_sk.identity("hub", "[email protected]", 1);
1156 +
    a.policy_grant_subject(
1157 +
        "agent:claude@ada",
1158 +
        issuer.clone(),
1159 +
        Scope::CHANGES | Scope::REFS,
1160 +
        None,
1161 +
    )
1162 +
    .unwrap();
1163 +
    let p = a.current_policy().unwrap().unwrap().1.policy;
1164 +
1165 +
    // An agent key the issuer certified for ten minutes two hours ago.
1166 +
    let now = Hlc::now().wall_ms;
1167 +
    let t0 = now - 2 * 3_600_000;
1168 +
    let agent_sk = SecretKey::generate();
1169 +
    let agent = agent_sk.identity("claude", "[email protected]", t0);
1170 +
    let cert = SignedCertificate::sign(
1171 +
        Certificate {
1172 +
            key: agent.clone(),
1173 +
            issuer: issuer.hash(),
1174 +
            subject: "agent:claude@ada".into(),
1175 +
            claims: vec![],
1176 +
            scope: Scope::CHANGES | Scope::REFS,
1177 +
            not_before: t0,
1178 +
            expires: t0 + 600_000,
1179 +
            grant: None,
1180 +
        },
1181 +
        &issuer,
1182 +
        &issuer_sk,
1183 +
    );
1184 +
    a.store().put_identity(&agent).unwrap();
1185 +
    a.store().put_certificate(&cert).unwrap();
1186 +
    let signer = agent.hash();
1187 +
    let attested = |claimed: u64| {
1188 +
        let attest = Attest {
1189 +
            repo: a.id().unwrap(),
1190 +
            hlc: Hlc {
1191 +
                wall_ms: claimed,
1192 +
                logical: 0,
1193 +
            },
1194 +
            body: AttestBody::Ref {
1195 +
                name: "main".into(),
1196 +
                old_state: ObjectRef([1; 32]),
1197 +
                new_state: ObjectRef([2; 32]),
1198 +
                serial: 1,
1199 +
            },
1200 +
        };
1201 +
        let sig = agent_sk.sign(Domain::Attest, &signer, &attest.hash());
1202 +
        SignedAttest {
1203 +
            attest,
1204 +
            sigs: vec![sig],
1205 +
        }
1206 +
    };
1207 +
1208 +
    // The hub's log: eight entries, the attestation's at 3, the latest
1209 +
    // checkpoint of 8, and one of 4 that witnesses cosigned.
1210 +
    let log_sk = SecretKey::generate();
1211 +
    let log_key = log_sk.identity("hub.example", "[email protected]", 1);
1212 +
    let origin = "hub.example/log";
1213 +
    let hub = "https://hub.example";
1214 +
    let url = "https://hub.example/ada/a";
1215 +
    let w_sk = SecretKey::generate();
1216 +
    let w = w_sk.identity("w", "[email protected]", 1);
1217 +
    let stranger_sk = SecretKey::generate();
1218 +
    let stranger = stranger_sk.identity("s", "[email protected]", 1);
1219 +
    let sa = attested(t0 + 300_000);
1220 +
    let entry_of = |i: u64| {
1221 +
        let (kind, object) = if i == 3 {
1222 +
            ("ref", sa.attest.hash())
1223 +
        } else {
1224 +
            ("seal", ObjectRef([i as u8; 32]))
1225 +
        };
1226 +
        Entry {
1227 +
            kind: kind.into(),
1228 +
            repo: sa.attest.repo,
1229 +
            object,
1230 +
            signers: vec![signer],
1231 +
            received: t0 + 300_000,
1232 +
        }
1233 +
        .encode()
1234 +
    };
1235 +
    let mut stored: Vec<Hash> = Vec::new();
1236 +
    for i in 0..8 {
1237 +
        let add = tlog::stored_hashes_for_leaf(i, tlog::leaf_hash(&entry_of(i)), &stored).unwrap();
1238 +
        stored.extend(add);
1239 +
    }
1240 +
    let note_of = |n: u64, cosigners: &[(&str, &Identity, &SecretKey, u64)]| {
1241 +
        let mut note = Checkpoint {
1242 +
            origin: origin.into(),
1243 +
            size: n,
1244 +
            root: tlog::tree_hash(n, &stored).unwrap(),
1245 +
            extensions: vec![],
1246 +
        }
1247 +
        .sign(&log_key, &log_sk)
1248 +
        .unwrap();
1249 +
        for (name, id, sk, t) in cosigners {
1250 +
            note.sigs
1251 +
                .extend(tlog::cosign(&note.text, name, id, sk, *t).unwrap());
1252 +
        }
1253 +
        note.encode()
1254 +
    };
1255 +
    let latest = note_of(8, &[]);
1256 +
    let witnessed_at = (t0 + 360_000) / 1000;
1257 +
    let cosigned = note_of(
1258 +
        4,
1259 +
        &[
1260 +
            ("w.example", &w, &w_sk, witnessed_at),
1261 +
            ("s.example", &stranger, &stranger_sk, 1),
1262 +
        ],
1263 +
    );
1264 +
    let answer = |integration: Vec<Integration>, witnesses: Vec<(String, Vec<u8>)>| RefsResponse {
1265 +
        refs: vec![RemoteRef {
1266 +
            name: "main".into(),
1267 +
            state: ObjectRef([2; 32]),
1268 +
            heads: vec![],
1269 +
            attestation: Some(sa.clone()),
1270 +
            last_serial: None,
1271 +
            accepted: None,
1272 +
            logged: Some(Logged {
1273 +
                index: 3,
1274 +
                entry: entry_of(3),
1275 +
                proof: tlog::prove_inclusion(8, 3, &stored).unwrap(),
1276 +
            }),
1277 +
            integration,
1278 +
        }],
1279 +
        log: Some(latest.clone()),
1280 +
        witnesses,
1281 +
        ..RefsResponse::default()
1282 +
    };
1283 +
    let proof4 = tlog::prove_inclusion(4, 3, &stored).unwrap();
1284 +
    let served = vec![Integration {
1285 +
        note: cosigned.clone(),
1286 +
        proof: proof4.clone(),
1287 +
    }];
1288 +
    let listed = vec![("w.example".to_string(), w.encode())];
1289 +
    // This user saw the hub's log before (pinned, so nothing is fetched).
1290 +
    a.remember_log(hub, &log_key, &Note::parse(&latest).unwrap())
1291 +
        .unwrap();
1292 +
1293 +
    // The witness is pinned on first sight, silently; the stranger's
1294 +
    // earlier cosignature counts for nothing.
1295 +
    let resp = answer(served.clone(), listed.clone());
1296 +
    let lc = a.check_log(url, None, &resp).unwrap();
1297 +
    assert!(lc.notes.is_empty(), "{:?}", lc.notes);
1298 +
    let t = lc.time_of("main").unwrap();
1299 +
    assert_eq!((t.time, t.witness.as_str()), (witnessed_at, "w.example"));
1300 +
    // The certificate expired an hour and fifty minutes ago, but covered
1301 +
    // the integrated time: accepted, and nothing to say.
1302 +
    assert!(cert.cert.expires < now);
1303 +
    let mut notes = Vec::new();
1304 +
    a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes)
1305 +
        .unwrap();
1306 +
    assert!(notes.is_empty(), "{notes:?}");
1307 +
1308 +
    // Only the stranger cosigned: no witnessed time, so the earlier rule
1309 +
    // (the attestation's own time, inside the window), said so.
1310 +
    let lonely = vec![Integration {
1311 +
        note: note_of(4, &[("s.example", &stranger, &stranger_sk, 1)]),
1312 +
        proof: proof4.clone(),
1313 +
    }];
1314 +
    let resp = answer(lonely, vec![]);
1315 +
    let lc = a.check_log(url, None, &resp).unwrap();
1316 +
    assert!(lc
1317 +
        .time_of("main")
1318 +
        .unwrap_err()
1319 +
        .contains("no witness this user trusts"));
1320 +
    let mut notes = Vec::new();
1321 +
    a.authorize_in_time(&p, "origin", &resp.refs[0], &sa, &signer, &lc, &mut notes)
1322 +
        .unwrap();
1323 +
    assert!(
1324 +
        notes.len() == 1 && notes[0].contains("not at a witnessed time"),
1325 +
        "{notes:?}"
1326 +
    );
1327 +
1328 +
    // A witness listed later under the pinned name with another key is
1329 +
    // pinned with a note, as a new log key is.
1330 +
    let resp = answer(
1331 +
        served.clone(),
1332 +
        vec![("w.example".into(), stranger.encode())],
1333 +
    );
1334 +
    let lc = a.check_log(url, None, &resp).unwrap();
1335 +
    assert!(
1336 +
        lc.notes.iter().any(|n| n.contains("another key")),
1337 +
        "{:?}",
1338 +
        lc.notes
1339 +
    );
1340 +
    assert!(
1341 +
        lc.time_of("main").is_err(),
1342 +
        "w's cosignature is by its old key"
1343 +
    );
1344 +
    a.check_log(url, None, &answer(served.clone(), listed.clone()))
1345 +
        .unwrap();
1346 +
1347 +
    // An old peer's answer: no log, no witnesses, no integration. Judged
1348 +
    // as before, silently.
1349 +
    let mut old = answer(vec![], vec![]);
1350 +
    old.log = None;
1351 +
    old.refs[0].logged = None;
1352 +
    let lc = a.check_log(url, None, &old).unwrap();
1353 +
    assert!(!lc.kept && lc.notes.is_empty());
1354 +
    let mut notes = Vec::new();
1355 +
    a.authorize_in_time(&p, "origin", &old.refs[0], &sa, &signer, &lc, &mut notes)
1356 +
        .unwrap();
1357 +
    assert!(notes.is_empty(), "{notes:?}");
1358 +
1359 +
    // Judged at given integrated times.
1360 +
    let at = |secs_after_t0: u64| {
1361 +
        let mut times = std::collections::HashMap::new();
1362 +
        times.insert(
1363 +
            "main".to_string(),
1364 +
            Ok(Integrated {
1365 +
                time: (t0 + secs_after_t0 * 1000) / 1000,
1366 +
                witness: "w.example".into(),
1367 +
                size: 4,
1368 +
            }),
1369 +
        );
1370 +
        LogCheck {
1371 +
            kept: true,
1372 +
            times,
1373 +
            ..LogCheck::default()
1374 +
        }
1375 +
    };
1376 +
    let judged = |sa: &SignedAttest, lc: &LogCheck| {
1377 +
        let mut notes = Vec::new();
1378 +
        a.authorize_in_time(&p, "origin", &old.refs[0], sa, &signer, lc, &mut notes)
1379 +
            .map(|_| notes)
1380 +
    };
1381 +
    // Claimed inside the window, first witnessed an hour later, after
1382 +
    // the certificate expired: backdated, or held back. Accepted by the
1383 +
    // earlier rule while logs are new, with both said.
1384 +
    let notes = judged(&sa, &at(3600)).unwrap();
1385 +
    assert!(
1386 +
        notes
1387 +
            .iter()
1388 +
            .any(|n| n.contains("had lapsed when witness w.example"))
1389 +
            && notes
1390 +
                .iter()
1391 +
                .any(|n| n.contains("55 minutes before witness")),
1392 +
        "{notes:?}"
1393 +
    );
1394 +
    // Claimed after the certificate expired, but witnessed inside its
1395 +
    // window: the clock the signer did not choose decides. The gap is
1396 +
    // still noted.
1397 +
    let fast = attested(now);
1398 +
    let notes = judged(&fast, &at(400)).unwrap();
1399 +
    assert!(
1400 +
        notes.len() == 1 && notes[0].contains("after witness w.example saw it"),
1401 +
        "{notes:?}"
1402 +
    );
1403 +
    // Claimed and witnessed after it expired: refused, as before.
1404 +
    assert!(judged(&fast, &at(3600)).is_err());
1405 +
    // A gap within ten minutes is nothing to note.
1406 +
    assert!(judged(&sa, &at(500)).unwrap().is_empty());
1407 +
}
1408 +
1130 1409
/// Keyless step 5 (oxygenhub#241): an agent's fresh key, certified for a
1131 1410
/// subject the author granted, seals changes in the author's name; with
1132 1411
/// no grant, a stranger's, or one that had ended, it is neither the
…
7662 unchanged lines
modified crates/oxy-core/src/repo/transparency.rs +267 −17
…
8 unchanged lines
9 9
//! showed two histories, and the fetch fails. An attestation that is not
10 10
//! in the log yet, or whose proof does not verify, is only reported while
11 11
//! the log is new (oxygenhub docs/transparency.md, decision 3).
12 +
//!
13 +
//! The witnesses that cosign a hub's checkpoints are pinned the same way
14 +
//! (`<oxy home>/tlog/<hub>.witnesses`), and give each bookmark's
15 +
//! attestation an *integrated time* (oxygenhub#240): the earliest time a
16 +
//! trusted witness cosigned a checkpoint holding it. A fetch checks
17 +
//! delegations and certificates at that time when it has one.
12 18
13 19
use super::*;
14 20
use crate::credentials::hub_of;
15 21
use crate::protocol::http_get;
16 -
use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Note, Tile, TlogError};
22 +
use crate::tlog::{self, Checkpoint, Entry, Hash, HashReader, Integrated, Note, Tile, TlogError};
17 23
use std::cell::RefCell;
18 24
19 25
/// Tiles fetched, by level and number, with the width fetched.
…
71 unchanged lines
91 97
        })
92 98
    }
93 99
94 -
    fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> {
100 +
    pub(crate) fn remember_log(&self, hub: &str, identity: &Identity, note: &Note) -> Result<()> {
95 101
        let path = self.tlog_file(hub);
96 102
        if let Some(dir) = path.parent() {
97 103
            fs::create_dir_all(dir)?;
…
16 unchanged lines
114 120
        hub: &str,
115 121
        text: &str,
116 122
        options: &RemoteOptions,
117 -
    ) -> Result<std::result::Result<(Checkpoint, Vec<String>), String>> {
123 +
    ) -> Result<std::result::Result<Accepted, String>> {
118 124
        let unchecked = |why: String| Ok(Err(why));
119 125
        let note = match Note::parse(text) {
120 126
            Ok(n) => n,
…
83 unchanged lines
204 210
            }
205 211
        }
206 212
        self.remember_log(hub, &identity, &note)?;
207 -
        Ok(Ok((cp, notes)))
213 +
        Ok(Ok(Accepted {
214 +
            checkpoint: cp,
215 +
            identity,
216 +
            note,
217 +
            notes,
218 +
        }))
219 +
    }
220 +
221 +
    fn witnesses_file(&self, hub: &str) -> PathBuf {
222 +
        let mut p = self.tlog_file(hub);
223 +
        p.set_file_name(format!("{}.witnesses", file_name(hub)));
224 +
        p
225 +
    }
226 +
227 +
    /// The witnesses this user pinned for `hub`: each one's name in
228 +
    /// cosignatures and its identity.
229 +
    pub(crate) fn pinned_witnesses(&self, hub: &str) -> Vec<(String, Identity)> {
230 +
        let Ok(text) = fs::read_to_string(self.witnesses_file(hub)) else {
231 +
            return vec![];
232 +
        };
233 +
        text.lines()
234 +
            .filter_map(|l| {
235 +
                let (name, key) = l.split_once(' ')?;
236 +
                let id = Identity::decode(&crate::dsse::base64_decode(key)?).ok()?;
237 +
                Some((name.to_string(), id))
238 +
            })
239 +
            .collect()
240 +
    }
241 +
242 +
    /// Pin the witnesses `hub` lists (name and identity bytes), as its log
243 +
    /// key is pinned: on first sight silently, later with a note for a
244 +
    /// witness that is new or has another key than before. A witness the
245 +
    /// hub no longer lists stays pinned. Returns every pinned witness.
246 +
    pub(crate) fn pin_witnesses(
247 +
        &self,
248 +
        hub: &str,
249 +
        listed: &[(String, Vec<u8>)],
250 +
        notes: &mut Vec<String>,
251 +
    ) -> Result<Vec<(String, Identity)>> {
252 +
        let path = self.witnesses_file(hub);
253 +
        let first = !path.exists();
254 +
        let mut pinned = self.pinned_witnesses(hub);
255 +
        let mut changed = false;
256 +
        for (name, bytes) in listed {
257 +
            if name.is_empty() || name.contains(char::is_whitespace) {
258 +
                notes.push(format!(
259 +
                    "{hub} lists a witness named {name:?}, which cannot name one"
260 +
                ));
261 +
                continue;
262 +
            }
263 +
            let Ok(id) = Identity::decode(bytes) else {
264 +
                notes.push(format!(
265 +
                    "{hub} lists witness {name} with a key that does not decode"
266 +
                ));
267 +
                continue;
268 +
            };
269 +
            match pinned.iter_mut().find(|(n, _)| n == name) {
270 +
                Some((_, old)) if old.hash() == id.hash() => {}
271 +
                Some((_, old)) => {
272 +
                    notes.push(format!(
273 +
                        "{hub}'s witness {name} has another key than before ({} now, {} then): if its operator did not announce one, {hub} may be naming a witness of its own",
274 +
                        id.hash().short(12),
275 +
                        old.hash().short(12)
276 +
                    ));
277 +
                    *old = id;
278 +
                    changed = true;
279 +
                }
280 +
                None => {
281 +
                    if !first {
282 +
                        notes.push(format!(
283 +
                            "{hub} is now cosigned by witness {name} (key {}), trusted from now on",
284 +
                            id.hash().short(12)
285 +
                        ));
286 +
                    }
287 +
                    pinned.push((name.clone(), id));
288 +
                    changed = true;
289 +
                }
290 +
            }
291 +
        }
292 +
        if changed {
293 +
            if let Some(dir) = path.parent() {
294 +
                fs::create_dir_all(dir)?;
295 +
            }
296 +
            let text: String = pinned
297 +
                .iter()
298 +
                .map(|(n, id)| format!("{n} {}\n", crate::dsse::base64(&id.encode())))
299 +
                .collect();
300 +
            crate::durable::write_atomic(&path, text.as_bytes())?;
301 +
        }
302 +
        Ok(pinned)
208 303
    }
209 304
210 -
    /// Check what a refs answer from `url` says about its host's log.
211 -
    /// Fails only when the host showed two histories under one key;
212 -
    /// everything else is a note.
305 +
    /// Check what a refs answer from `url` says about its host's log, and
306 +
    /// read each bookmark attestation's integrated time. Fails only when
307 +
    /// the host showed two histories under one key; everything else is a
308 +
    /// note.
213 309
    pub(crate) fn check_log(
214 310
        &self,
215 311
        url: &str,
216 312
        ca: Option<&Path>,
217 313
        resp: &RefsResponse,
218 -
    ) -> Result<Vec<String>> {
314 +
    ) -> Result<LogCheck> {
315 +
        let mut out = LogCheck::default();
219 316
        let (Some(text), Some(hub)) = (&resp.log, hub_of(url)) else {
220 -
            return Ok(vec![]);
317 +
            return Ok(out);
221 318
        };
319 +
        out.kept = true;
222 320
        let options = RemoteOptions {
223 321
            ca: ca.map(Path::to_path_buf),
224 322
            ..RemoteOptions::default()
225 323
        };
226 -
        let (cp, mut notes) = match self.accept_checkpoint(&hub, text, &options)? {
324 +
        let accepted = match self.accept_checkpoint(&hub, text, &options)? {
227 325
            Ok(c) => c,
228 326
            Err(why) => {
229 -
                return Ok(vec![format!(
230 -
                    "{hub}'s transparency log was not checked: {why}"
231 -
                )])
327 +
                out.notes
328 +
                    .push(format!("{hub}'s transparency log was not checked: {why}"));
329 +
                out.unchecked = Some(why);
330 +
                return Ok(out);
232 331
            }
233 332
        };
333 +
        let cp = &accepted.checkpoint;
334 +
        out.notes.extend(accepted.notes.iter().cloned());
335 +
        let witnesses = self.pin_witnesses(&hub, &resp.witnesses, &mut out.notes)?;
234 336
        let mut missing = 0;
235 337
        for r in &resp.refs {
236 338
            let Some(a) = &r.attestation else { continue };
237 339
            let Some(l) = &r.logged else {
238 340
                missing += 1;
341 +
                out.times
342 +
                    .insert(r.name.clone(), Err("it is not in the log yet".into()));
239 343
                continue;
240 344
            };
241 345
            let fits = Entry::decode(&l.entry).is_ok_and(|e| {
…
9 unchanged lines
251 355
                )
252 356
                .is_ok();
253 357
            if !included {
254 -
                notes.push(format!(
358 +
                out.notes.push(format!(
255 359
                    "{hub}'s log entry {} for {} does not prove its attestation is in the log",
256 360
                    l.index, r.name
257 361
                ));
258 362
            }
363 +
            let time = if !fits {
364 +
                Err("its log entry is not its attestation's".to_string())
365 +
            } else if witnesses.is_empty() {
366 +
                Err(format!("{hub} names no witness"))
367 +
            } else {
368 +
                let cosigned: Vec<(&str, &[Hash])> = r
369 +
                    .integration
370 +
                    .iter()
371 +
                    .map(|i| (i.note.as_str(), i.proof.as_slice()))
372 +
                    .collect();
373 +
                tlog::integrated_time(
374 +
                    &l.entry,
375 +
                    l.index,
376 +
                    &cosigned,
377 +
                    &cp.origin,
378 +
                    &accepted.identity,
379 +
                    &witnesses,
380 +
                )
381 +
            };
382 +
            out.times.insert(r.name.clone(), time);
259 383
        }
260 384
        if missing > 0 {
261 -
            notes.push(format!(
385 +
            out.notes.push(format!(
262 386
                "{} of {hub}'s attestations {} not in its transparency log yet (logged within seconds of a push; checked on a later fetch)",
263 387
                missing,
264 388
                if missing == 1 { "is" } else { "are" }
265 389
            ));
390 +
        }
391 +
        Ok(out)
392 +
    }
393 +
}
394 +
395 +
/// A checkpoint `accept_checkpoint` took: the checkpoint, the log key it
396 +
/// verified with, its note as served, and what accepting it noted.
397 +
pub(crate) struct Accepted {
398 +
    pub checkpoint: Checkpoint,
399 +
    pub identity: Identity,
400 +
    pub note: Note,
401 +
    pub notes: Vec<String>,
402 +
}
403 +
404 +
/// What a refs answer said about its host's log.
405 +
#[derive(Debug, Default)]
406 +
pub(crate) struct LogCheck {
407 +
    /// Whether the host keeps a log (it sent a checkpoint).
408 +
    pub kept: bool,
409 +
    /// Why the log could not be checked, when it could not.
410 +
    pub unchecked: Option<String>,
411 +
    pub notes: Vec<String>,
412 +
    /// Each attested bookmark's integrated time, or why it has none.
413 +
    pub times: HashMap<String, std::result::Result<Integrated, String>>,
414 +
}
415 +
416 +
impl LogCheck {
417 +
    /// Bookmark `name`'s integrated time, or why there is none.
418 +
    pub fn time_of(&self, name: &str) -> std::result::Result<&Integrated, String> {
419 +
        if let Some(why) = &self.unchecked {
420 +
            return Err(format!("its host's log was not checked ({why})"));
421 +
        }
422 +
        if !self.kept {
423 +
            return Err("its host keeps no transparency log".into());
266 424
        }
267 -
        Ok(notes)
425 +
        match self.times.get(name) {
426 +
            Some(Ok(i)) => Ok(i),
427 +
            Some(Err(why)) => Err(why.clone()),
428 +
            None => Err("it has no log entry".into()),
429 +
        }
430 +
    }
431 +
}
432 +
433 +
/// How a check that depends on when an attestation was signed came out
434 +
/// (spec §14.8, *Integrated time*).
435 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436 +
pub(crate) enum Timed {
437 +
    /// Passed at the integrated time.
438 +
    Integrated,
439 +
    /// No integrated time: passed by the earlier rule (the issuer's
440 +
    /// acceptance, else the attestation's own time).
441 +
    Fallback,
442 +
    /// Failed at the integrated time but passed by the earlier rule:
443 +
    /// accepted with a note while logs are new.
444 +
    Disagrees,
445 +
    /// Failed.
446 +
    Refused,
447 +
}
448 +
449 +
/// Judge a check (`at`, made at a time in milliseconds) at the integrated
450 +
/// time when there is one, else by the earlier rule (`before`).
451 +
pub(crate) fn judge(
452 +
    integrated: Option<u64>,
453 +
    at: impl Fn(u64) -> bool,
454 +
    before: impl FnOnce() -> bool,
455 +
) -> Timed {
456 +
    match integrated {
457 +
        Some(t) if at(t) => Timed::Integrated,
458 +
        Some(_) => {
459 +
            if before() {
460 +
                Timed::Disagrees
461 +
            } else {
462 +
                Timed::Refused
463 +
            }
464 +
        }
465 +
        None => {
466 +
            if before() {
467 +
                Timed::Fallback
468 +
            } else {
469 +
                Timed::Refused
470 +
            }
471 +
        }
472 +
    }
473 +
}
474 +
475 +
/// A note when an attestation's claimed time (`claimed`, its own clock)
476 +
/// is more than `tlog::TIME_TOLERANCE_MS` from its integrated time.
477 +
pub(crate) fn gap_note(claimed: u64, integrated: &Integrated) -> Option<String> {
478 +
    let t = integrated.ms();
479 +
    let minutes = |d: u64| d.div_ceil(60_000);
480 +
    if claimed.saturating_add(tlog::TIME_TOLERANCE_MS) < t {
481 +
        Some(format!(
482 +
            "claims to be from {} minutes before witness {} first saw it: backdated, held back before it was logged, or signed on a slow clock",
483 +
            minutes(t - claimed),
484 +
            integrated.witness
485 +
        ))
486 +
    } else if claimed > t.saturating_add(tlog::TIME_TOLERANCE_MS) {
487 +
        Some(format!(
488 +
            "claims to be from {} minutes after witness {} saw it: signed on a fast clock",
489 +
            minutes(claimed - t),
490 +
            integrated.witness
491 +
        ))
492 +
    } else {
493 +
        None
268 494
    }
269 495
}
270 496
…
6 unchanged lines
277 503
    pub entries: Vec<Entry>,
278 504
    /// What accepting the checkpoint noted (a new log key, a new name).
279 505
    pub notes: Vec<String>,
506 +
    /// The trusted witnesses that cosigned the checkpoint, with their
507 +
    /// times in seconds, earliest first.
508 +
    pub witnessed: Vec<(String, u64)>,
280 509
}
281 510
282 511
impl Repo {
…
10 unchanged lines
293 522
        let tl = |e: TlogError| RepoError::Msg(e.to_string());
294 523
        let text = String::from_utf8(http_get(&format!("{hub}/log/checkpoint"), &options)?)
295 524
            .map_err(|_| RepoError::Msg(format!("{hub}'s checkpoint is not text")))?;
296 -
        let (cp, notes) = match self.accept_checkpoint(&hub, &text, &options)? {
525 +
        let accepted = match self.accept_checkpoint(&hub, &text, &options)? {
297 526
            Ok(c) => c,
298 527
            Err(why) => return fail(format!("{hub}'s log could not be checked: {why}")),
299 528
        };
529 +
        let mut notes = accepted.notes;
530 +
        // The witnesses it names (one `<name> <base64 identity>` line
531 +
        // each), pinned as a fetch pins them; a hub that does not list
532 +
        // them leaves those pinned before.
533 +
        let listed: Vec<(String, Vec<u8>)> = http_get(&format!("{hub}/log/witnesses"), &options)
534 +
            .ok()
535 +
            .and_then(|b| String::from_utf8(b).ok())
536 +
            .map(|t| {
537 +
                t.lines()
538 +
                    .filter_map(|l| {
539 +
                        let (n, k) = l.split_once(' ')?;
540 +
                        Some((n.to_string(), crate::dsse::base64_decode(k.trim())?))
541 +
                    })
542 +
                    .collect()
543 +
            })
544 +
            .unwrap_or_default();
545 +
        let witnesses = self.pin_witnesses(&hub, &listed, &mut notes)?;
546 +
        let mut witnessed = accepted.note.cosignatures(&witnesses);
547 +
        witnessed.sort_by_key(|(_, t)| *t);
548 +
        let cp = accepted.checkpoint;
300 549
        let mut entries = Vec::with_capacity(cp.size as usize);
301 550
        let mut stored: Vec<Hash> = Vec::new();
302 551
        let mut n = 0u64;
…
32 unchanged lines
335 584
            checkpoint: cp,
336 585
            entries,
337 586
            notes,
587 +
            witnessed,
338 588
        })
339 589
    }
340 590
}
modified crates/oxy-core/src/tlog.rs +201 −0
…
1162 unchanged lines
1163 1163
    })
1164 1164
}
1165 1165
1166 +
// -------------------------------------------------------- integrated time
1167 +
1168 +
/// How far a claimed time may stray from an integrated time before it is
1169 +
/// noted (spec §14.8, *Integrated time*): ten minutes, in milliseconds.
1170 +
pub const TIME_TOLERANCE_MS: u64 = 10 * 60 * 1000;
1171 +
1172 +
impl Note {
1173 +
    /// The witnesses among `trusted` (name and identity) whose
1174 +
    /// cosignatures of this note verify, with their times in seconds.
1175 +
    /// Lines by anyone else are ignored.
1176 +
    pub fn cosignatures(&self, trusted: &[(String, Identity)]) -> Vec<(String, u64)> {
1177 +
        trusted
1178 +
            .iter()
1179 +
            .filter_map(|(n, id)| self.cosigned_by(n, id).ok().map(|t| (n.clone(), t)))
1180 +
            .collect()
1181 +
    }
1182 +
}
1183 +
1184 +
/// When witnesses first saw a log entry (spec §14.8): an upper bound on
1185 +
/// when what it logs was signed, by a clock neither its signer nor the
1186 +
/// log chose.
1187 +
#[derive(Clone, Debug, PartialEq, Eq)]
1188 +
pub struct Integrated {
1189 +
    /// The earliest trusted cosignature's time, in seconds.
1190 +
    pub time: u64,
1191 +
    /// The witness that made it.
1192 +
    pub witness: String,
1193 +
    /// The size of the checkpoint it cosigned.
1194 +
    pub size: u64,
1195 +
}
1196 +
1197 +
impl Integrated {
1198 +
    /// The time in milliseconds, as Oxygen's other times are.
1199 +
    pub fn ms(&self) -> u64 {
1200 +
        self.time.saturating_mul(1000)
1201 +
    }
1202 +
}
1203 +
1204 +
/// Entry `index` (`entry`, its bytes)'s integrated time: the earliest time
1205 +
/// a witness in `trusted` cosigned one of `cosigned` (checkpoint notes,
1206 +
/// each with the entry's inclusion proof against it) that `log_key`
1207 +
/// signed for `origin` and that includes the entry. A note that does not
1208 +
/// verify, or does not include the entry, counts for nothing; `Err` says
1209 +
/// why no time could be read.
1210 +
pub fn integrated_time(
1211 +
    entry: &[u8],
1212 +
    index: u64,
1213 +
    cosigned: &[(&str, &[Hash])],
1214 +
    origin: &str,
1215 +
    log_key: &Identity,
1216 +
    trusted: &[(String, Identity)],
1217 +
) -> std::result::Result<Integrated, String> {
1218 +
    if cosigned.is_empty() {
1219 +
        return Err("no witness has cosigned a checkpoint holding it yet".into());
1220 +
    }
1221 +
    let leaf = leaf_hash(entry);
1222 +
    let mut best: Option<Integrated> = None;
1223 +
    let mut why = String::new();
1224 +
    for (text, proof) in cosigned {
1225 +
        let checked = Note::parse(text)
1226 +
            .and_then(|n| Checkpoint::verify(&n, origin, log_key).map(|c| (n, c)))
1227 +
            .and_then(|(n, c)| {
1228 +
                check_inclusion(proof, c.size, &c.root, index, &leaf)?;
1229 +
                Ok((n, c))
1230 +
            });
1231 +
        let (note, cp) = match checked {
1232 +
            Ok(x) => x,
1233 +
            Err(e) => {
1234 +
                why = format!("a cosigned checkpoint served for it does not hold it ({e})");
1235 +
                continue;
1236 +
            }
1237 +
        };
1238 +
        let times = note.cosignatures(trusted);
1239 +
        if times.is_empty() {
1240 +
            why = format!(
1241 +
                "the checkpoint of {} entries holding it is cosigned by no witness this user trusts",
1242 +
                cp.size
1243 +
            );
1244 +
            continue;
1245 +
        }
1246 +
        for (witness, time) in times {
1247 +
            if best.as_ref().is_none_or(|b| time < b.time) {
1248 +
                best = Some(Integrated {
1249 +
                    time,
1250 +
                    witness,
1251 +
                    size: cp.size,
1252 +
                });
1253 +
            }
1254 +
        }
1255 +
    }
1256 +
    best.ok_or(why)
1257 +
}
1258 +
1166 1259
#[cfg(test)]
1167 1260
mod tests {
1168 1261
    use super::*;
…
272 unchanged lines
1441 1534
        assert!(hub
1442 1535
            .verify_ed25519(back.text.as_bytes(), &back.sigs[0].sig)
1443 1536
            .is_ok());
1537 +
    }
1538 +
1539 +
    #[test]
1540 +
    fn an_entry_is_integrated_when_a_trusted_witness_first_cosigned_it() {
1541 +
        let log_sk = SecretKey::generate();
1542 +
        let log = log_sk.identity("log", "[email protected]", 1);
1543 +
        let origin = "hub.example/log";
1544 +
        let witness = |name: &str| {
1545 +
            let sk = SecretKey::generate();
1546 +
            (name.to_string(), sk.identity(name, "[email protected]", 1), sk)
1547 +
        };
1548 +
        let (a, b, c) = (
1549 +
            witness("a.example"),
1550 +
            witness("b.example"),
1551 +
            witness("c.example"),
1552 +
        );
1553 +
        let trusted = vec![(a.0.clone(), a.1.clone()), (b.0.clone(), b.1.clone())];
1554 +
        // A log of 40 real entries, so `integrated_time` hashes the leaf.
1555 +
        let leaf_bytes = |n: u64| {
1556 +
            Entry {
1557 +
                kind: "ref".into(),
1558 +
                repo: [n as u8; 16],
1559 +
                object: ObjectRef([n as u8; 32]),
1560 +
                signers: vec![],
1561 +
                received: n,
1562 +
            }
1563 +
            .encode()
1564 +
        };
1565 +
        let mut stored: Vec<Hash> = Vec::new();
1566 +
        for n in 0..40 {
1567 +
            let add = stored_hashes_for_leaf(n, leaf_hash(&leaf_bytes(n)), &stored).unwrap();
1568 +
            stored.extend(add);
1569 +
        }
1570 +
        // Checkpoint `n`, cosigned by each of `by` at its time.
1571 +
        type W = (String, Identity, SecretKey);
1572 +
        let cosigned = |n: u64, by: &[(&W, u64)]| {
1573 +
            let cp = Checkpoint {
1574 +
                origin: origin.into(),
1575 +
                size: n,
1576 +
                root: tree_hash(n, &stored).unwrap(),
1577 +
                extensions: vec![],
1578 +
            };
1579 +
            let mut note = cp.sign(&log, &log_sk).unwrap();
1580 +
            for ((name, id, sk), t) in by {
1581 +
                note.sigs
1582 +
                    .extend(cosign(&note.text, name, id, sk, *t).unwrap());
1583 +
            }
1584 +
            note.encode()
1585 +
        };
1586 +
        let proof = |n: u64| prove_inclusion(n, 7, &stored).unwrap();
1587 +
        let entry = leaf_bytes(7);
1588 +
        let time = |served: &[(String, Vec<Hash>)]| {
1589 +
            let s: Vec<(&str, &[Hash])> = served
1590 +
                .iter()
1591 +
                .map(|(n, p)| (n.as_str(), p.as_slice()))
1592 +
                .collect();
1593 +
            integrated_time(&entry, 7, &s, origin, &log, &trusted)
1594 +
        };
1595 +
1596 +
        // One checkpoint, two witnesses: the earlier cosignature.
1597 +
        let one = cosigned(10, &[(&a, 1_792_300_050), (&b, 1_792_300_020)]);
1598 +
        let got = time(&[(one.clone(), proof(10))]).unwrap();
1599 +
        assert_eq!(
1600 +
            (got.time, got.witness.as_str(), got.size),
1601 +
            (1_792_300_020, "b.example", 10)
1602 +
        );
1603 +
        assert_eq!(got.ms(), 1_792_300_020_000);
1604 +
        // Witnesses that first cosigned different checkpoints: the
1605 +
        // earliest of all.
1606 +
        let later = cosigned(20, &[(&a, 1_792_300_010)]);
1607 +
        let got = time(&[(later.clone(), proof(20)), (one.clone(), proof(10))]).unwrap();
1608 +
        assert_eq!(
1609 +
            (got.time, got.witness.as_str()),
1610 +
            (1_792_300_010, "a.example")
1611 +
        );
1612 +
1613 +
        // A witness this user does not trust is no witness, however early.
1614 +
        let stranger = cosigned(10, &[(&c, 1)]);
1615 +
        let err = time(&[(stranger, proof(10))]).unwrap_err();
1616 +
        assert!(err.contains("no witness this user trusts"), "{err}");
1617 +
        // A forged cosignature under a trusted name: c's key signed it.
1618 +
        let forged = cosigned(10, &[(&(a.0.clone(), c.1.clone(), c.2.clone()), 1)]);
1619 +
        assert!(time(&[(forged.clone(), proof(10))]).is_err());
1620 +
        // Beside a real one, it changes nothing.
1621 +
        let got = time(&[(forged, proof(10)), (one.clone(), proof(10))]).unwrap();
1622 +
        assert_eq!(got.time, 1_792_300_020);
1623 +
        // A time moved after cosigning no longer verifies.
1624 +
        let mut moved = Note::parse(&one).unwrap();
1625 +
        for s in moved.sigs.iter_mut().filter(|s| s.name == "b.example") {
1626 +
            s.sig[..8].copy_from_slice(&1u64.to_be_bytes());
1627 +
        }
1628 +
        let got = time(&[(moved.encode(), proof(10))]).unwrap();
1629 +
        assert_eq!(got.witness, "a.example", "b's lines no longer verify");
1630 +
1631 +
        // A checkpoint that does not hold the entry, a proof for another
1632 +
        // size, another log's key: no time.
1633 +
        let early = cosigned(5, &[(&a, 1)]);
1634 +
        assert!(time(&[(early, proof(10))]).is_err());
1635 +
        assert!(time(&[(one.clone(), proof(20))]).is_err());
1636 +
        let other_sk = SecretKey::generate();
1637 +
        let other = other_sk.identity("x", "x@x", 1);
1638 +
        let s = [(one.as_str(), proof(10))];
1639 +
        let s: Vec<(&str, &[Hash])> = s.iter().map(|(n, p)| (*n, p.as_slice())).collect();
1640 +
        assert!(integrated_time(&entry, 7, &s, origin, &other, &trusted).is_err());
1641 +
        assert!(integrated_time(&entry, 7, &s, "elsewhere/log", &log, &trusted).is_err());
1642 +
        assert!(integrated_time(&leaf_bytes(8), 7, &s, origin, &log, &trusted).is_err());
1643 +
        // Nothing served.
1644 +
        assert!(time(&[]).unwrap_err().contains("no witness has cosigned"));
1444 1645
    }
1445 1646
}
modified docs/spec.md +11 −5
…
681 unchanged lines
682 682
683 683
- 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.
684 684
- 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.
685 -
- 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). 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.
686 -
- 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 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.
685 +
- 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.
686 +
- 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.
687 687
- 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`.
688 688
- 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.
689 689
- 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
753 753
754 754
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.
755 755
756 -
**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.
756 +
**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'.
757 757
758 758
### 14.8 The transparency log
759 759
…
40 unchanged lines
800 800
- requires every later checkpoint to verify with that key, keep its origin, and extend the last one seen: a smaller tree, the same size with another root, or a consistency proof that fails (computed from the host's tiles) means the host showed two histories, and the fetch fails;
801 801
- checks each bookmark's attestation against its entry (kind `ref`, the attestation's ObjectRef and repository id) and inclusion proof, reporting one that is missing or does not verify. While logs are new this is a warning, not a refusal.
802 802
803 -
**`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 then lists this repository's entries (`--key KEYID` for a key's, `--all` for every one, `--json` for machines).
803 +
**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.
804 +
- **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.
805 +
- **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.
806 +
- **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.
807 +
- **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.
804 808
809 +
**`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).
810 +
805 811
## 15. Transport
806 812
807 813
Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.
…
54 unchanged lines
862 868
| `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 |
863 869
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
864 870
| Signed policy | `[policy bytes, [Signature]]` |
865 -
| `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); the response's key 6 (optional, text) is the host's latest checkpoint, a signed note; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
871 +
| `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}` |
866 872
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
867 873
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size |
868 874
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
…
282 unchanged lines
modified src/cli/tlog.rs +29 −0
…
59 unchanged lines
60 60
                    json::s(&oxy_core::hash::ObjectRef(audit.checkpoint.root).to_hex())
61 61
                ),
62 62
                (
63 +
                    "witnesses",
64 +
                    json::arr(audit.witnessed.iter().map(|(name, t)| {
65 +
                        json::obj([
66 +
                            ("name", json::s(name)),
67 +
                            ("time_ms", json::n(t.saturating_mul(1000))),
68 +
                        ])
69 +
                    })),
70 +
                ),
71 +
                (
63 72
                    "entries",
64 73
                    json::arr(listed.iter().map(|(i, e)| {
65 74
                        json::obj([
…
30 unchanged lines
96 105
        ui::path(&audit.checkpoint.origin),
97 106
        audit.checkpoint.size
98 107
    );
108 +
    // Its cosignatures: an upper bound on when every entry in it was made
109 +
    // (spec §14.8, *Integrated time*).
110 +
    if audit.witnessed.is_empty() {
111 +
        println!(
112 +
            "  {} {}",
113 +
            ui::note(),
114 +
            ui::dim("cosigned by no witness you trust")
115 +
        );
116 +
    } else {
117 +
        let by: Vec<String> = audit
118 +
            .witnessed
119 +
            .iter()
120 +
            .map(|(name, t)| format!("{name} {}", ui::ago(t.saturating_mul(1000))))
121 +
            .collect();
122 +
        println!(
123 +
            "  {} {}",
124 +
            ui::note(),
125 +
            ui::dim(format!("cosigned by {}", by.join(", ")))
126 +
        );
127 +
    }
99 128
    if listed.is_empty() {
100 129
        println!("  {} {}", ui::note(), ui::dim(format!("no {what} in it")));
101 130
        return Ok(0);
…
22 unchanged lines