|
|
… |
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(¬e, 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(¬e.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(¬e_of(10)).unwrap(); |
|
1357 |
+ |
note.sigs.extend(lines); |
|
1358 |
+ |
let back = Note::parse(¬e.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 |