OXYGENHUB

Witnesses for the transparency log: cosignatures and the witness rule b0621c2f8cc4

tlog gains what a witness needs (spec §14.8, C2SP tlog-witness and
tlog-cosignature, oxygenhub#238 step A5):
- cosign/cosigned_by: a cosignature/v1 line (Ed25519 over the witness's
  time and the checkpoint) and a composite line beside it, both
  required and agreeing on the time;
- AddCheckpoint, the add-checkpoint request (old size, consistency
  proof, note);
- witness(): cosign a checkpoint that verifies with the log's key and
  extends the last one cosigned; tell the log the witness's size when it
  guessed wrong (409); refuse one that does not extend it (422) or is
  not the log's (403).

Test: first sight, a later checkpoint with its proof, a wrong old size,
a fork and a stranger's note.

Refs oxygenhub#238
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ae2cc1925e07
When
· 2026-10-18 00:25 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b0621c2f8cc4e8ec87ce68b61be887253e92f15a66743d98450e039b2183996b
Depends on
0ed150d62994 e5f8eb2a2006 ff0af1ee299d
Depended on by
028545dfa7a4
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
3 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-core/src/tlog.rs +318 −0
…
930 unchanged lines
931 931
    }
932 932
}
933 933
934 +
// ------------------------------------------------------------- witnesses
935 +
936 +
/// C2SP tlog-cosignature: a witness's Ed25519 cosignature.
937 +
const SIG_COSIGNATURE: u8 = 0x04;
938 +
/// Oxygen's composite cosignature beside it (spec §14.8).
939 +
const SIG_COSIG_COMPOSITE: u8 = 0x4e;
940 +
941 +
/// What a cosignature signs: `cosignature/v1`, the witness's time in
942 +
/// seconds, then the checkpoint's text.
943 +
fn cosigned_message(text: &str, time: u64) -> String {
944 +
    format!("cosignature/v1\ntime {time}\n{text}")
945 +
}
946 +
947 +
fn cosig_target(message: &str) -> ObjectRef {
948 +
    let mut h = blake3::Hasher::new();
949 +
    h.update(b"oxygen-tlog-cosig\0");
950 +
    h.update(message.as_bytes());
951 +
    ObjectRef(*h.finalize().as_bytes())
952 +
}
953 +
954 +
/// A witness's cosignature of a checkpoint's text at `time` (seconds):
955 +
/// the C2SP cosignature/v1 line, and a composite line that keeps it
956 +
/// post-quantum. Each carries the time before the signature.
957 +
pub fn cosign(
958 +
    text: &str,
959 +
    name: &str,
960 +
    witness: &Identity,
961 +
    sk: &SecretKey,
962 +
    time: u64,
963 +
) -> Result<Vec<NoteSignature>> {
964 +
    if !valid_name(name) {
965 +
        return Err(TlogError::BadNote(format!(
966 +
            "{name:?} cannot name a witness"
967 +
        )));
968 +
    }
969 +
    let message = cosigned_message(text, time);
970 +
    let mut ed = time.to_be_bytes().to_vec();
971 +
    ed.extend_from_slice(&sk.sign_ed25519(message.as_bytes()));
972 +
    let mut composite = time.to_be_bytes().to_vec();
973 +
    composite.extend_from_slice(&cbor::encode(
974 +
        &sk.sign(Domain::Checkpoint, &witness.hash(), &cosig_target(&message))
975 +
            .to_value(),
976 +
    ));
977 +
    Ok(vec![
978 +
        NoteSignature {
979 +
            name: name.to_string(),
980 +
            key_hash: key_hash(name, SIG_COSIGNATURE, &witness.ed25519_pk),
981 +
            sig: ed,
982 +
        },
983 +
        NoteSignature {
984 +
            name: name.to_string(),
985 +
            key_hash: key_hash(name, SIG_COSIG_COMPOSITE, &witness.encode()),
986 +
            sig: composite,
987 +
        },
988 +
    ])
989 +
}
990 +
991 +
impl Note {
992 +
    /// The time `witness` (named `name`) cosigned this note, once both its
993 +
    /// lines verify and agree on the time.
994 +
    pub fn cosigned_by(&self, name: &str, witness: &Identity) -> Result<u64> {
995 +
        let find = |t: u8, key: &[u8]| {
996 +
            let h = key_hash(name, t, key);
997 +
            self.sigs.iter().find(|s| s.name == name && s.key_hash == h)
998 +
        };
999 +
        let time_of = |sig: &[u8]| -> Result<(u64, Vec<u8>)> {
1000 +
            if sig.len() < 9 {
1001 +
                return Err(TlogError::BadSignature(format!(
1002 +
                    "{name}'s cosignature is short"
1003 +
                )));
1004 +
            }
1005 +
            let mut t = [0u8; 8];
1006 +
            t.copy_from_slice(&sig[..8]);
1007 +
            Ok((u64::from_be_bytes(t), sig[8..].to_vec()))
1008 +
        };
1009 +
        let ed = find(SIG_COSIGNATURE, &witness.ed25519_pk)
1010 +
            .ok_or_else(|| TlogError::BadSignature(format!("no cosignature by {name}")))?;
1011 +
        let (time, sig) = time_of(&ed.sig)?;
1012 +
        let message = cosigned_message(&self.text, time);
1013 +
        witness
1014 +
            .verify_ed25519(message.as_bytes(), &sig)
1015 +
            .map_err(|_| {
1016 +
                TlogError::BadSignature(format!("{name}'s cosignature does not verify"))
1017 +
            })?;
1018 +
        let c = find(SIG_COSIG_COMPOSITE, &witness.encode()).ok_or_else(|| {
1019 +
            TlogError::BadSignature(format!("no composite cosignature by {name}"))
1020 +
        })?;
1021 +
        let (ctime, csig) = time_of(&c.sig)?;
1022 +
        if ctime != time {
1023 +
            return Err(TlogError::BadSignature(format!(
1024 +
                "{name}'s two cosignatures disagree on the time"
1025 +
            )));
1026 +
        }
1027 +
        let sig = cbor::decode(&csig)
1028 +
            .map_err(|e| e.to_string())
1029 +
            .and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string()))
1030 +
            .map_err(|e| TlogError::BadSignature(format!("{name}'s composite cosignature: {e}")))?;
1031 +
        verify(witness, &sig, Domain::Checkpoint, &cosig_target(&message)).map_err(|e| {
1032 +
            TlogError::BadSignature(format!(
1033 +
                "{name}'s composite cosignature does not verify: {e}"
1034 +
            ))
1035 +
        })?;
1036 +
        Ok(time)
1037 +
    }
1038 +
}
1039 +
1040 +
/// A C2SP tlog-witness `add-checkpoint` request: the size of the last
1041 +
/// checkpoint the witness cosigned for this log, the consistency proof
1042 +
/// from it, and the new checkpoint note.
1043 +
#[derive(Clone, Debug, PartialEq, Eq)]
1044 +
pub struct AddCheckpoint {
1045 +
    pub old_size: u64,
1046 +
    pub proof: Vec<Hash>,
1047 +
    pub note: String,
1048 +
}
1049 +
1050 +
impl AddCheckpoint {
1051 +
    pub fn encode(&self) -> String {
1052 +
        let mut out = format!("old {}\n", self.old_size);
1053 +
        for h in &self.proof {
1054 +
            out.push_str(&crate::dsse::base64(h));
1055 +
            out.push('\n');
1056 +
        }
1057 +
        out.push('\n');
1058 +
        out.push_str(&self.note);
1059 +
        out
1060 +
    }
1061 +
1062 +
    pub fn parse(body: &str) -> Result<AddCheckpoint> {
1063 +
        let bad = |w: &str| TlogError::BadNote(format!("add-checkpoint: {w}"));
1064 +
        let (head, note) = body
1065 +
            .split_once("\n\n")
1066 +
            .ok_or_else(|| bad("no blank line before the checkpoint"))?;
1067 +
        let mut lines = head.split('\n');
1068 +
        let old_size = lines
1069 +
            .next()
1070 +
            .and_then(|l| l.strip_prefix("old "))
1071 +
            .and_then(|n| n.parse().ok())
1072 +
            .ok_or_else(|| bad("no old size"))?;
1073 +
        let mut proof = Vec::new();
1074 +
        for l in lines {
1075 +
            let h = crate::dsse::base64_decode(l)
1076 +
                .and_then(|b| <Hash>::try_from(b).ok())
1077 +
                .ok_or_else(|| bad("a proof line is not a hash"))?;
1078 +
            proof.push(h);
1079 +
        }
1080 +
        Ok(AddCheckpoint {
1081 +
            old_size,
1082 +
            proof,
1083 +
            note: note.to_string(),
1084 +
        })
1085 +
    }
1086 +
}
1087 +
1088 +
/// What a witness answers.
1089 +
#[derive(Clone, Debug, PartialEq, Eq)]
1090 +
pub enum Witnessed {
1091 +
    /// Cosigned: the checkpoint, and the cosignature lines to return.
1092 +
    Cosigned {
1093 +
        checkpoint: Checkpoint,
1094 +
        lines: Vec<NoteSignature>,
1095 +
    },
1096 +
    /// The request's old size is not the witness's: it answers with its own
1097 +
    /// (409, so the log retries with a proof from there).
1098 +
    Conflict(u64),
1099 +
}
1100 +
1101 +
/// Why a witness refused.
1102 +
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
1103 +
pub enum WitnessError {
1104 +
    /// Not signed by the log's key (403).
1105 +
    #[error("{0}")]
1106 +
    Unsigned(String),
1107 +
    /// The checkpoint does not extend what the witness cosigned (422).
1108 +
    #[error("{0}")]
1109 +
    Inconsistent(String),
1110 +
    /// Malformed (400).
1111 +
    #[error("{0}")]
1112 +
    Bad(String),
1113 +
}
1114 +
1115 +
/// A witness: its name in cosignatures, and its key.
1116 +
pub struct WitnessKey<'a> {
1117 +
    pub name: &'a str,
1118 +
    pub identity: &'a Identity,
1119 +
    pub sk: &'a SecretKey,
1120 +
}
1121 +
1122 +
/// A witness's decision (C2SP tlog-witness) on `req` for the log named
1123 +
/// `origin` with key `log_key`, whose last cosigned checkpoint was
1124 +
/// `latest` (size and root): cosign when the new checkpoint verifies and
1125 +
/// extends it, as `me` at `now` (seconds).
1126 +
pub fn witness(
1127 +
    req: &AddCheckpoint,
1128 +
    origin: &str,
1129 +
    log_key: &Identity,
1130 +
    latest: Option<(u64, Hash)>,
1131 +
    me: &WitnessKey,
1132 +
    now: u64,
1133 +
) -> std::result::Result<Witnessed, WitnessError> {
1134 +
    let note = Note::parse(&req.note).map_err(|e| WitnessError::Bad(e.to_string()))?;
1135 +
    let cp = Checkpoint::verify(&note, origin, log_key)
1136 +
        .map_err(|e| WitnessError::Unsigned(e.to_string()))?;
1137 +
    let (old, old_root) = latest.unwrap_or((0, empty_root()));
1138 +
    if req.old_size != old {
1139 +
        return Ok(Witnessed::Conflict(old));
1140 +
    }
1141 +
    if cp.size < old {
1142 +
        return Err(WitnessError::Bad(format!(
1143 +
            "a checkpoint of {} entries is older than the {old} cosigned",
1144 +
            cp.size
1145 +
        )));
1146 +
    }
1147 +
    let consistent = match (old, cp.size == old) {
1148 +
        (0, _) => req.proof.is_empty(),
1149 +
        (_, true) => req.proof.is_empty() && cp.root == old_root,
1150 +
        _ => check_consistency(&req.proof, cp.size, &cp.root, old, &old_root).is_ok(),
1151 +
    };
1152 +
    if !consistent {
1153 +
        return Err(WitnessError::Inconsistent(format!(
1154 +
            "the checkpoint of {} entries does not extend the one of {old} cosigned",
1155 +
            cp.size
1156 +
        )));
1157 +
    }
1158 +
    let lines = cosign(&note.text, me.name, me.identity, me.sk, now)
1159 +
        .map_err(|e| WitnessError::Bad(e.to_string()))?;
1160 +
    Ok(Witnessed::Cosigned {
1161 +
        checkpoint: cp,
1162 +
        lines,
1163 +
    })
1164 +
}
1165 +
934 1166
#[cfg(test)]
935 1167
mod tests {
936 1168
    use super::*;
…
140 unchanged lines
1077 1309
        assert_eq!(decode_bundle(&b).unwrap(), vec![e.encode(), b"x".to_vec()]);
1078 1310
        assert!(decode_bundle(&b[..b.len() - 1]).is_err());
1079 1311
        assert_eq!(e.leaf_hash(), leaf_hash(&e.encode()));
1312 +
    }
1313 +
1314 +
    #[test]
1315 +
    fn a_witness_cosigns_what_extends_what_it_saw_and_nothing_else() {
1316 +
        let log_sk = SecretKey::generate();
1317 +
        let log = log_sk.identity("log", "[email protected]", 1);
1318 +
        let w_sk = SecretKey::generate();
1319 +
        let w = w_sk.identity("witness", "[email protected]", 1);
1320 +
        let origin = "hub.example/log";
1321 +
        let (stored, _) = grow(40);
1322 +
        let note_of = |n: u64| {
1323 +
            Checkpoint {
1324 +
                origin: origin.into(),
1325 +
                size: n,
1326 +
                root: tree_hash(n, &stored).unwrap(),
1327 +
                extensions: vec![],
1328 +
            }
1329 +
            .sign(&log, &log_sk)
1330 +
            .unwrap()
1331 +
            .encode()
1332 +
        };
1333 +
        let ask = |old: u64, n: u64, latest: Option<(u64, Hash)>| {
1334 +
            let proof = if old == 0 || old == n {
1335 +
                vec![]
1336 +
            } else {
1337 +
                prove_consistency(n, old, &stored).unwrap()
1338 +
            };
1339 +
            let req = AddCheckpoint {
1340 +
                old_size: old,
1341 +
                proof,
1342 +
                note: note_of(n),
1343 +
            };
1344 +
            let req = AddCheckpoint::parse(&req.encode()).unwrap();
1345 +
            let me = WitnessKey {
1346 +
                name: "w.example",
1347 +
                identity: &w,
1348 +
                sk: &w_sk,
1349 +
            };
1350 +
            witness(&req, origin, &log, latest, &me, 1_792_300_000)
1351 +
        };
1352 +
        // First sight, then a later checkpoint with its proof.
1353 +
        let Ok(Witnessed::Cosigned { checkpoint, lines }) = ask(0, 10, None) else {
1354 +
            panic!()
1355 +
        };
1356 +
        let mut note = Note::parse(&note_of(10)).unwrap();
1357 +
        note.sigs.extend(lines);
1358 +
        let back = Note::parse(&note.encode()).unwrap();
1359 +
        assert_eq!(back.cosigned_by("w.example", &w).unwrap(), 1_792_300_000);
1360 +
        assert!(
1361 +
            back.cosigned_by("w.example", &log).is_err(),
1362 +
            "another witness's key"
1363 +
        );
1364 +
        let latest = Some((10, checkpoint.root));
1365 +
        assert!(matches!(
1366 +
            ask(10, 40, latest),
1367 +
            Ok(Witnessed::Cosigned { .. })
1368 +
        ));
1369 +
        // The log's idea of what the witness saw is wrong: told the size.
1370 +
        assert_eq!(ask(5, 40, latest), Ok(Witnessed::Conflict(10)));
1371 +
        // A fork: what the witness saw is not a prefix.
1372 +
        let forked = Some((10, [9; 32]));
1373 +
        assert!(matches!(
1374 +
            ask(10, 40, forked),
1375 +
            Err(WitnessError::Inconsistent(_))
1376 +
        ));
1377 +
        // Signed by someone else.
1378 +
        let req = AddCheckpoint {
1379 +
            old_size: 0,
1380 +
            proof: vec![],
1381 +
            note: note_of(10),
1382 +
        };
1383 +
        assert!(matches!(
1384 +
            witness(
1385 +
                &req,
1386 +
                origin,
1387 +
                &w,
1388 +
                None,
1389 +
                &WitnessKey {
1390 +
                    name: "w.example",
1391 +
                    identity: &w,
1392 +
                    sk: &w_sk
1393 +
                },
1394 +
                1
1395 +
            ),
1396 +
            Err(WitnessError::Unsigned(_))
1397 +
        ));
1080 1398
    }
1081 1399
1082 1400
    #[test]
…
45 unchanged lines
modified docs/spec.md +12 −0
…
782 unchanged lines
783 783
784 784
A reader accepts a checkpoint only when both verify, so the log stays post-quantum. Which witnesses must cosign it is oxygenhub docs/transparency.md's.
785 785
786 +
**Witnesses** (C2SP tlog-witness) cosign a log's checkpoints with their own keys and clocks, so a log cannot show two histories without someone seeing both. A log sends a witness `POST add-checkpoint`: `old <size>` (of the last checkpoint that witness cosigned), the consistency proof's hashes in base64 one per line, a blank line, then the checkpoint note. The witness:
787 +
- answers 409 with its own size when `old` is not it;
788 +
- answers 403 when the note does not verify with the log's key;
789 +
- answers 422 when the checkpoint does not extend the one it cosigned;
790 +
- otherwise remembers the new size and root, and returns its cosignature lines, which the log adds to the checkpoint it serves.
791 +
792 +
A cosignature is two lines under the witness's name:
793 +
- C2SP cosignature/v1 (type 0x04): the witness's time in seconds as 8 big-endian bytes, then an Ed25519 signature over `cosignature/v1\ntime <seconds>\n` followed by the checkpoint's text;
794 +
- a composite line (type 0x4e): the same time, then the CBOR of a composite Signature in domain `checkpoint` over `BLAKE3("oxygen-tlog-cosig\0" || that message)`.
795 +
796 +
A reader counts a cosignature only when both lines verify and agree on the time.
797 +
786 798
**On fetch**, when a refs response carries a checkpoint (§16), a client:
787 799
- pins the host's log key on first sight (the hub's published identity), per user and hub, with the checkpoint;
788 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;
…
348 unchanged lines