From 6bb66fde1f9333b26bfb5c5411ba8caeb2ef7d4d Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sun, 27 Sep 2026 21:50:46 +0900 Subject: [PATCH] knot-xrpc: sign git-push events Signed-off-by: Seongmin Lee --- knot2/crates/knot-xrpc/src/firehose.rs | 149 ++++++++++++++++++++--- knot2/crates/knot-xrpc/src/refrecords.rs | 24 ++-- lexicons/org/tangled/git/pushEvent.json | 2 +- 3 files changed, 151 insertions(+), 24 deletions(-) diff --git a/knot2/crates/knot-xrpc/src/firehose.rs b/knot2/crates/knot-xrpc/src/firehose.rs index b2ec57f2d..8e0a19cd6 100644 --- a/knot2/crates/knot-xrpc/src/firehose.rs +++ b/knot2/crates/knot-xrpc/src/firehose.rs @@ -17,7 +17,7 @@ use knot_events::{ }; use knot_git::{Layout, RefUpdate, Repo}; use knot_record::chain::{self, CommitChunk, Emit, MAX_COMMIT_BLOCKS_BYTES, Op, blocks_bytes}; -use knot_runtime::{Clock, HttpTransport, UnixMicros}; +use knot_runtime::{Clock, HttpTransport, Signer, UnixMicros}; use knot_types::{ AccountDid, Datetime, Oid, PushOption, PushOptions, RefName, RepoDid, UnixSeconds, }; @@ -212,6 +212,7 @@ pub fn publish_push( pusher: &AccountDid, applied: &[RefUpdate], options: &PushOptions, + signer: &dyn Signer, now: UnixSeconds, ) { let options = options.as_slice(); @@ -225,21 +226,25 @@ pub fn publish_push( } let reservation = log.reserve(); let seq = reservation.micros(); + let unsigned = UnsignedPushEventMessage { + repo: did, + pusher, + edits: applied + .iter() + .take(MAX_PUSH_EDITS) + .map(RefEdit::of) + .collect(), + time: datetime_of(now), + options: (!options.is_empty()).then(|| options.iter().take(MAX_PUSH_OPTIONS).collect()), + }; + let sig = signer.sign(&encode(&unsigned)).as_bytes().to_vec(); fulfill( reservation, FrameKind::Push, &PushEventMessage { + unsigned: &unsigned, seq, - repo: did, - pusher, - edits: applied - .iter() - .take(MAX_PUSH_EDITS) - .map(RefEdit::of) - .collect(), - time: datetime_of(now), - options: (!options.is_empty()).then(|| options.iter().take(MAX_PUSH_OPTIONS).collect()), - sig: b"", + sig, }, ); } @@ -408,17 +413,22 @@ impl<'a> RefEdit<'a> { } #[derive(Serialize)] -struct PushEventMessage<'a> { - seq: UnixMicros, +struct UnsignedPushEventMessage<'a> { repo: &'a RepoDid, pusher: &'a AccountDid, edits: Vec>, time: Datetime, #[serde(skip_serializing_if = "Option::is_none")] options: Option>, - // the signing scheme this field names isn't defined yet, so it goes out empty +} + +#[derive(Serialize)] +struct PushEventMessage<'a> { + #[serde(flatten)] + unsigned: &'a UnsignedPushEventMessage<'a>, + seq: UnixMicros, #[serde(with = "serde_bytes")] - sig: &'static [u8], + sig: Vec, } fn encode(value: &T) -> Bytes { @@ -1514,4 +1524,113 @@ mod tests { "the unread frame stays queued for the consumer's next attempt" ); } + + #[test] + fn push_events_verify_against_the_signing_key_over_the_sigless_encoding() { + let log = log(); + let signer = knot_runtime::K256Signer::from_slice(&[7u8; 32]).unwrap(); + let did = RepoDid::new("did:plc:squid").unwrap(); + let pusher = AccountDid::new("did:plc:octopus").unwrap(); + let applied = [ + RefUpdate::Create { + name: RefName::new("refs/heads/main").unwrap(), + new: Oid::from_hex("1111111111111111111111111111111111111111").unwrap(), + }, + RefUpdate::Delete { + name: RefName::new("refs/heads/gone").unwrap(), + old: Oid::from_hex("2222222222222222222222222222222222222222").unwrap(), + }, + ]; + let options = PushOptions::new([PushOption::new("verbose-ci").unwrap()]); + publish_push( + &log, + &did, + &pusher, + &applied, + &options, + &signer, + UnixSeconds::new(1_700_000_000), + ); + + let [event] = &log.replay(EventCursor::START, drain_bounds()).events[..] else { + panic!("the push event reaches the ring"); + }; + let mut read = &event.frame[..]; + let header: Header = serde_ipld_dagcbor::de::from_reader_once(&mut read).unwrap(); + assert_eq!(header.t, FrameKind::Push); + let ipld_core::ipld::Ipld::Map(mut payload) = + serde_ipld_dagcbor::from_slice::(read).unwrap() + else { + panic!("a push event payload is a map"); + }; + let Some(ipld_core::ipld::Ipld::Bytes(sig)) = payload.remove("sig") else { + panic!("a push event carries sig bytes"); + }; + assert_eq!(sig.len(), 64, "secp256k1 signatures are 64 compact bytes"); + assert!( + matches!(payload.remove("seq"), Some(ipld_core::ipld::Ipld::Integer(_))), + "a push event still carries seq on the wire, just outside the signature" + ); + assert!( + payload.contains_key("options"), + "the signed bytes cover the push options" + ); + + let unsigned = serde_ipld_dagcbor::to_vec(&ipld_core::ipld::Ipld::Map(payload)).unwrap(); + assert!( + knot_runtime::verify( + &signer.public_key(), + &unsigned, + &knot_runtime::Signature::from_bytes(sig), + ), + "sig must cover the dag-cbor of this very message with the seq and sig keys \ + absent, which is what a consumer reconstructs" + ); + } + + #[test] + fn push_event_signatures_survive_a_relay_reassigning_seq() { + let log = log(); + let signer = knot_runtime::K256Signer::from_slice(&[7u8; 32]).unwrap(); + publish_push( + &log, + &RepoDid::new("did:plc:squid").unwrap(), + &AccountDid::new("did:plc:octopus").unwrap(), + &[RefUpdate::Create { + name: RefName::new("refs/heads/main").unwrap(), + new: Oid::from_hex("1111111111111111111111111111111111111111").unwrap(), + }], + &PushOptions::new([]), + &signer, + UnixSeconds::new(1_700_000_000), + ); + + let [event] = &log.replay(EventCursor::START, drain_bounds()).events[..] else { + panic!("the push event reaches the ring"); + }; + let mut read = &event.frame[..]; + let _: Header = serde_ipld_dagcbor::de::from_reader_once(&mut read).unwrap(); + let ipld_core::ipld::Ipld::Map(mut payload) = + serde_ipld_dagcbor::from_slice::(read).unwrap() + else { + panic!("a push event payload is a map"); + }; + + // what a relay does to a frame before passing it on + payload.insert("seq".to_owned(), ipld_core::ipld::Ipld::Integer(9_001)); + + let Some(ipld_core::ipld::Ipld::Bytes(sig)) = payload.remove("sig") else { + panic!("a push event carries sig bytes"); + }; + payload.remove("seq"); + assert!( + knot_runtime::verify( + &signer.public_key(), + &serde_ipld_dagcbor::to_vec(&ipld_core::ipld::Ipld::Map(payload)).unwrap(), + &knot_runtime::Signature::from_bytes(sig), + ), + "a re-sequenced frame must still verify, or the event cannot cross a relay" + ); + } } + diff --git a/knot2/crates/knot-xrpc/src/refrecords.rs b/knot2/crates/knot-xrpc/src/refrecords.rs index 2d39572d2..414305fcc 100644 --- a/knot2/crates/knot-xrpc/src/refrecords.rs +++ b/knot2/crates/knot-xrpc/src/refrecords.rs @@ -214,13 +214,21 @@ impl knot_receive::RefSurface for RefProjection firehose::publish_push( + &self.state.firehose, + repo_did, + pusher, + applied, + options, + &signer, + self.state.now(), + ), + Err(error) => tracing::error!( + repo = repo_did.as_str(), + %error, + "the signing key wouldn't load, so this push goes unannounced" + ), + } } } diff --git a/lexicons/org/tangled/git/pushEvent.json b/lexicons/org/tangled/git/pushEvent.json index 5507c35a0..5f6f04dd4 100644 --- a/lexicons/org/tangled/git/pushEvent.json +++ b/lexicons/org/tangled/git/pushEvent.json @@ -54,7 +54,7 @@ }, "sig": { "type": "bytes", - "description": "Signature over ctx (seq, repo/pusher DID, edits, time, options) by the repo's atproto signing key." + "description": "Signature over the DAG-CBOR of this message with the seq and sig keys absent (repo/pusher DID, edits, time, options), by the repo's atproto signing key. seq is excluded so a relay can reassign it without invalidating the signature." } } }, -- 2.51.2