34500cce
Wireformat overhaul: separate signatures, manifest blob, namespaced actions
a73x 2026-03-21 12:10
- Store Ed25519 signature and pubkey as separate blobs in commit tree instead of embedding in event.json (closes 91cd185f) - Add manifest.json blob to every event commit for version migration (closes dd572839) - Namespace action types: issue.open, patch.create, etc. (closes e23bf237) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/dag.rs b/src/dag.rs index c56b42b..d585071 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -3,7 +3,10 @@ use git2::{Oid, Repository, Sort}; use crate::error::Error; use crate::event::{Action, Event}; use crate::identity::author_signature; use crate::signing::{sign_event, SignedEvent}; use crate::signing::sign_event; /// The manifest blob content included in every event commit tree. const MANIFEST_JSON: &[u8] = br#"{"version":1,"format":"git-collab"}"#; /// Create an orphan commit (no parents) with the given event. /// Returns the new commit OID which also serves as the entity ID. @@ -12,12 +15,19 @@ pub fn create_root_event( event: &Event, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { let signed = sign_event(event, signing_key)?; let json = serde_json::to_vec_pretty(&signed)?; let blob_oid = repo.blob(&json)?; let detached = sign_event(event, signing_key)?; let event_json = serde_json::to_vec_pretty(event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let mut tb = repo.treebuilder(None)?; tb.insert("event.json", blob_oid, 0o100644)?; tb.insert("event.json", event_blob, 0o100644)?; tb.insert("signature", sig_blob, 0o100644)?; tb.insert("pubkey", pubkey_blob, 0o100644)?; tb.insert("manifest.json", manifest_blob, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; @@ -35,12 +45,19 @@ pub fn append_event( event: &Event, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { let signed = sign_event(event, signing_key)?; let json = serde_json::to_vec_pretty(&signed)?; let blob_oid = repo.blob(&json)?; let detached = sign_event(event, signing_key)?; let event_json = serde_json::to_vec_pretty(event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let mut tb = repo.treebuilder(None)?; tb.insert("event.json", blob_oid, 0o100644)?; tb.insert("event.json", event_blob, 0o100644)?; tb.insert("signature", sig_blob, 0o100644)?; tb.insert("pubkey", pubkey_blob, 0o100644)?; tb.insert("manifest.json", manifest_blob, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; @@ -71,13 +88,7 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) .get_name("event.json") .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; let blob = repo.find_blob(entry.id())?; let content = blob.content(); // Try SignedEvent first, fall back to plain Event for backward compat let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { signed.event } else { serde_json::from_slice(content)? }; let event: Event = serde_json::from_slice(blob.content())?; events.push((oid, event)); } Ok(events) @@ -123,11 +134,18 @@ pub fn reconcile( action: Action::Merge, }; let signed = sign_event(&merge_event, signing_key)?; let json = serde_json::to_vec_pretty(&signed)?; let blob_oid = repo.blob(&json)?; let detached = sign_event(&merge_event, signing_key)?; let event_json = serde_json::to_vec_pretty(&merge_event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let mut tb = repo.treebuilder(None)?; tb.insert("event.json", blob_oid, 0o100644)?; tb.insert("event.json", event_blob, 0o100644)?; tb.insert("signature", sig_blob, 0o100644)?; tb.insert("pubkey", pubkey_blob, 0o100644)?; tb.insert("manifest.json", manifest_blob, 0o100644)?; let tree_oid = tb.write()?; let tree = repo.find_tree(tree_oid)?; diff --git a/src/event.rs b/src/event.rs index c320717..ec1a83c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -16,33 +16,43 @@ pub struct Event { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Action { #[serde(rename = "issue.open")] IssueOpen { title: String, body: String, }, #[serde(rename = "issue.comment")] IssueComment { body: String, }, #[serde(rename = "issue.close")] IssueClose { reason: Option<String>, }, #[serde(rename = "issue.edit")] IssueEdit { title: Option<String>, body: Option<String>, }, #[serde(rename = "issue.label")] IssueLabel { label: String, }, #[serde(rename = "issue.unlabel")] IssueUnlabel { label: String, }, #[serde(rename = "issue.assign")] IssueAssign { assignee: String, }, #[serde(rename = "issue.unassign")] IssueUnassign { assignee: String, }, #[serde(rename = "issue.reopen")] IssueReopen, #[serde(rename = "patch.create")] PatchCreate { title: String, body: String, @@ -51,25 +61,32 @@ pub enum Action { #[serde(default, skip_serializing_if = "Option::is_none")] fixes: Option<String>, }, #[serde(rename = "patch.revise")] PatchRevise { body: Option<String>, }, #[serde(rename = "patch.review")] PatchReview { verdict: ReviewVerdict, body: String, }, #[serde(rename = "patch.comment")] PatchComment { body: String, }, #[serde(rename = "patch.inline_comment")] PatchInlineComment { file: String, line: u32, body: String, }, #[serde(rename = "patch.close")] PatchClose { reason: Option<String>, }, #[serde(rename = "patch.merge")] PatchMerge, #[serde(rename = "collab.merge")] Merge, } diff --git a/src/signing.rs b/src/signing.rs index bcc95bd..089a249 100644 --- a/src/signing.rs +++ b/src/signing.rs @@ -6,7 +6,6 @@ use base64::Engine; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use git2::Oid; use rand_core::OsRng; use serde::{Deserialize, Serialize}; use git2::{Repository, Sort}; @@ -28,12 +27,11 @@ pub fn signing_key_dir() -> Result<PathBuf, Error> { } } /// Wrapper around Event that adds Ed25519 signature fields. /// Serialized as the event.json blob in git commits. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignedEvent { #[serde(flatten)] pub event: Event, /// Detached signature data for an event commit. /// Stored as separate blobs (`signature` and `pubkey`) in the commit tree, /// alongside the plain `event.json`. #[derive(Debug, Clone)] pub struct DetachedSignature { pub signature: String, pub pubkey: String, } @@ -152,33 +150,35 @@ pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> { Ok(json.into_bytes()) } /// Sign an Event with the given signing key, producing a SignedEvent. pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> { /// Sign an Event with the given signing key, producing a detached signature. pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<DetachedSignature, Error> { let canonical = canonical_json(event)?; let signature = signing_key.sign(&canonical); let verifying_key = signing_key.verifying_key(); Ok(SignedEvent { event: event.clone(), Ok(DetachedSignature { signature: STANDARD.encode(signature.to_bytes()), pubkey: STANDARD.encode(verifying_key.to_bytes()), }) } /// Verify a SignedEvent's signature against its embedded public key. /// Verify a detached signature against an event and its public key. /// /// Returns `Missing` if signature or pubkey fields are empty, /// `Valid` if the signature checks out, `Invalid` otherwise. pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> { if signed.signature.is_empty() || signed.pubkey.is_empty() { pub fn verify_detached( event: &Event, sig: &DetachedSignature, ) -> Result<VerifyStatus, Error> { if sig.signature.is_empty() || sig.pubkey.is_empty() { return Ok(VerifyStatus::Missing); } let sig_bytes = match STANDARD.decode(&signed.signature) { let sig_bytes = match STANDARD.decode(&sig.signature) { Ok(b) => b, Err(_) => return Ok(VerifyStatus::Invalid), }; let pubkey_bytes = match STANDARD.decode(&signed.pubkey) { let pubkey_bytes = match STANDARD.decode(&sig.pubkey) { Ok(b) => b, Err(_) => return Ok(VerifyStatus::Invalid), }; @@ -198,7 +198,7 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> Err(_) => return Ok(VerifyStatus::Invalid), }; let canonical = canonical_json(&signed.event)?; let canonical = canonical_json(event)?; match verifying_key.verify(&canonical, &signature) { Ok(()) => Ok(VerifyStatus::Valid), @@ -208,9 +208,8 @@ pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> /// Walk the DAG for the given ref and verify every event commit's signature. /// /// For each commit, reads `event.json` from the tree: /// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`. /// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`. /// For each commit, reads `event.json`, `signature`, and `pubkey` blobs from the tree. /// If signature/pubkey blobs are missing, marks as `Missing`. /// /// Returns one `SignatureVerificationResult` per commit. pub fn verify_ref( @@ -227,28 +226,42 @@ pub fn verify_ref( let oid = oid_result?; let commit = repo.find_commit(oid)?; let tree = commit.tree()?; let entry = tree // Read event.json let event_entry = tree .get_name("event.json") .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; let blob = repo.find_blob(entry.id())?; let content = blob.content(); let event_blob = repo.find_blob(event_entry.id())?; let event: Event = serde_json::from_slice(event_blob.content())?; // Try to deserialize as SignedEvent first if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { if signed.signature.is_empty() || signed.pubkey.is_empty() { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Missing, pubkey: None, error: Some("missing signature".to_string()), }); } else { match verify_signed_event(&signed)? { // Read signature and pubkey blob OIDs let sig_oid = tree.get_name("signature").map(|e| e.id()); let pubkey_oid = tree.get_name("pubkey").map(|e| e.id()); match (sig_oid, pubkey_oid) { (Some(sid), Some(pid)) => { let sig_blob = repo.find_blob(sid)?; let pubkey_blob = repo.find_blob(pid)?; let sig_str = std::str::from_utf8(sig_blob.content()) .unwrap_or("") .trim() .to_string(); let pubkey_str = std::str::from_utf8(pubkey_blob.content()) .unwrap_or("") .trim() .to_string(); let detached = DetachedSignature { signature: sig_str.clone(), pubkey: pubkey_str.clone(), }; match verify_detached(&event, &detached)? { VerifyStatus::Valid => { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Valid, pubkey: Some(signed.pubkey), pubkey: Some(pubkey_str), error: None, }); } @@ -256,7 +269,7 @@ pub fn verify_ref( results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Invalid, pubkey: Some(signed.pubkey), pubkey: Some(pubkey_str), error: Some("invalid signature".to_string()), }); } @@ -269,25 +282,23 @@ pub fn verify_ref( }); } VerifyStatus::Untrusted => { // verify_signed_event never returns Untrusted, // but handle it for exhaustiveness results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Untrusted, pubkey: Some(signed.pubkey), pubkey: Some(pubkey_str), error: Some("untrusted key".to_string()), }); } } } } else { // Plain Event without signature fields results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Missing, pubkey: None, error: Some("missing signature".to_string()), }); _ => { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Missing, pubkey: None, error: Some("missing signature".to_string()), }); } } } @@ -300,7 +311,7 @@ mod tests { use crate::event::{Action, Author}; #[test] fn signed_event_flatten_round_trip() { fn detached_signature_round_trip() { let event = Event { timestamp: "2026-03-21T00:00:00Z".to_string(), author: Author { @@ -312,18 +323,14 @@ mod tests { body: "Body".to_string(), }, }; let signed = SignedEvent { event, signature: "dGVzdA==".to_string(), pubkey: "cHVia2V5".to_string(), }; let json = serde_json::to_string_pretty(&signed).unwrap(); let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.signature, "dGVzdA=="); assert_eq!(deserialized.pubkey, "cHVia2V5"); match deserialized.event.action { Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"), _ => panic!("Wrong action type after round-trip"), } let sk = SigningKey::generate(&mut rand_core::OsRng); let sig = sign_event(&event, &sk).unwrap(); assert!(!sig.signature.is_empty()); assert!(!sig.pubkey.is_empty()); let status = verify_detached(&event, &sig).unwrap(); assert_eq!(status, VerifyStatus::Valid); } } diff --git a/tests/collab_test.rs b/tests/collab_test.rs index 48a1868..d68835b 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -5,7 +5,7 @@ use tempfile::TempDir; use git_collab::dag; use git_collab::error::Error; use git_collab::event::{Action, Author, Event, ReviewVerdict}; use git_collab::signing::{self, SignedEvent, VerifyStatus}; use git_collab::signing::{self, DetachedSignature, VerifyStatus}; use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; use common::{ @@ -510,20 +510,40 @@ fn test_signed_event_in_dag() { let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "test open").unwrap(); // Read the raw blob from the commit and deserialize as SignedEvent // Read the raw blobs from the commit tree let tip = repo.refname_to_id(&ref_name).unwrap(); let commit = repo.find_commit(tip).unwrap(); let tree = commit.tree().unwrap(); let entry = tree.get_name("event.json").unwrap(); let blob = repo.find_blob(entry.id()).unwrap(); let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap(); // Assert signature and pubkey are present assert!(!signed.signature.is_empty(), "signature should be present"); assert!(!signed.pubkey.is_empty(), "pubkey should be present"); // Verify the signature let status = signing::verify_signed_event(&signed).unwrap(); // event.json should be a plain Event (no signature fields) let event_entry = tree.get_name("event.json").unwrap(); let event_blob = repo.find_blob(event_entry.id()).unwrap(); let event: Event = serde_json::from_slice(event_blob.content()).unwrap(); assert!(matches!(event.action, Action::IssueOpen { .. })); // signature and pubkey should be separate blobs let sig_entry = tree.get_name("signature").expect("signature blob should exist"); let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist"); let sig_blob = repo.find_blob(sig_entry.id()).unwrap(); let pk_blob = repo.find_blob(pk_entry.id()).unwrap(); let sig_str = std::str::from_utf8(sig_blob.content()).unwrap(); let pk_str = std::str::from_utf8(pk_blob.content()).unwrap(); assert!(!sig_str.is_empty(), "signature should be present"); assert!(!pk_str.is_empty(), "pubkey should be present"); // manifest.json should exist let manifest_entry = tree.get_name("manifest.json").expect("manifest.json should exist"); let manifest_blob = repo.find_blob(manifest_entry.id()).unwrap(); let manifest_str = std::str::from_utf8(manifest_blob.content()).unwrap(); assert!(manifest_str.contains("\"version\":1"), "manifest should contain version"); assert!(manifest_str.contains("\"format\":\"git-collab\""), "manifest should contain format"); // Verify the detached signature let detached = DetachedSignature { signature: sig_str.to_string(), pubkey: pk_str.to_string(), }; let status = signing::verify_detached(&event, &detached).unwrap(); assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid"); // walk_events should still extract the Event correctly diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dc34a98..f5343aa 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -272,14 +272,18 @@ impl TestRepo { } } /// Create an unsigned event commit (plain Event JSON, no signature fields). /// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs). /// Returns the commit OID. pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { let json = serde_json::to_vec_pretty(event).unwrap(); let blob_oid = repo.blob(&json).unwrap(); let manifest = br#"{"version":1,"format":"git-collab"}"#; let manifest_blob = repo.blob(manifest).unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); // No signature or pubkey blobs — this is an unsigned event let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); @@ -288,18 +292,27 @@ pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { .unwrap() } /// Create a tampered event commit: sign the event, then modify the body but keep /// Create a tampered event commit: sign the event, then modify the event.json but keep /// the original signature. Returns the commit OID. pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid { let sk = test_signing_key(); let mut signed = signing::sign_event(event, &sk).unwrap(); let detached = signing::sign_event(event, &sk).unwrap(); // Tamper with the event content while keeping the original signature signed.event.timestamp = "2099-01-01T00:00:00Z".to_string(); let json = serde_json::to_vec_pretty(&signed).unwrap(); let blob_oid = repo.blob(&json).unwrap(); let mut tampered_event = event.clone(); tampered_event.timestamp = "2099-01-01T00:00:00Z".to_string(); let json = serde_json::to_vec_pretty(&tampered_event).unwrap(); let event_blob = repo.blob(&json).unwrap(); let sig_blob = repo.blob(detached.signature.as_bytes()).unwrap(); let pubkey_blob = repo.blob(detached.pubkey.as_bytes()).unwrap(); let manifest = br#"{"version":1,"format":"git-collab"}"#; let manifest_blob = repo.blob(manifest).unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); tb.insert("event.json", event_blob, 0o100644).unwrap(); tb.insert("signature", sig_blob, 0o100644).unwrap(); tb.insert("pubkey", pubkey_blob, 0o100644).unwrap(); tb.insert("manifest.json", manifest_blob, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); diff --git a/tests/signing_test.rs b/tests/signing_test.rs index 04db8ce..c56c581 100644 --- a/tests/signing_test.rs +++ b/tests/signing_test.rs @@ -1,7 +1,7 @@ use git_collab::event::{Action, Author, Event}; use git_collab::signing::{ canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event, verify_signed_event, SignedEvent, VerifyStatus, verify_detached, DetachedSignature, VerifyStatus, }; use tempfile::tempdir; @@ -19,7 +19,7 @@ fn make_event() -> Event { } } // ── T004: Key generation and storage ── // -- T004: Key generation and storage -- #[test] fn generate_keypair_creates_key_files() { @@ -93,7 +93,7 @@ fn load_verifying_key_missing_returns_error() { ); } // ── T005: Sign/verify round-trip ── // -- T005: Sign/verify round-trip -- #[test] fn sign_event_produces_nonempty_signature_and_pubkey() { @@ -103,33 +103,33 @@ fn sign_event_produces_nonempty_signature_and_pubkey() { let sk = load_signing_key(&config).unwrap(); let event = make_event(); let signed = sign_event(&event, &sk).unwrap(); let detached = sign_event(&event, &sk).unwrap(); assert!(!signed.signature.is_empty(), "signature should not be empty"); assert!(!signed.pubkey.is_empty(), "pubkey should not be empty"); assert!(!detached.signature.is_empty(), "signature should not be empty"); assert!(!detached.pubkey.is_empty(), "pubkey should not be empty"); // Verify they are valid base64 use base64::engine::general_purpose::STANDARD; use base64::Engine; STANDARD .decode(&signed.signature) .decode(&detached.signature) .expect("signature should be valid base64"); STANDARD .decode(&signed.pubkey) .decode(&detached.pubkey) .expect("pubkey should be valid base64"); } #[test] fn verify_valid_signed_event_returns_valid() { fn verify_valid_detached_signature_returns_valid() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let event = make_event(); let signed = sign_event(&event, &sk).unwrap(); let detached = sign_event(&event, &sk).unwrap(); let status = verify_signed_event(&signed).unwrap(); let status = verify_detached(&event, &detached).unwrap(); assert_eq!(status, VerifyStatus::Valid); } @@ -141,29 +141,29 @@ fn verify_tampered_event_returns_invalid() { let sk = load_signing_key(&config).unwrap(); let event = make_event(); let mut signed = sign_event(&event, &sk).unwrap(); let detached = sign_event(&event, &sk).unwrap(); // Tamper with the event signed.event.author.name = "Mallory".to_string(); let mut tampered = event.clone(); tampered.author.name = "Mallory".to_string(); let status = verify_signed_event(&signed).unwrap(); let status = verify_detached(&tampered, &detached).unwrap(); assert_eq!(status, VerifyStatus::Invalid); } #[test] fn verify_missing_signature_returns_missing() { let event = make_event(); let signed = SignedEvent { event, let detached = DetachedSignature { signature: String::new(), pubkey: String::new(), }; let status = verify_signed_event(&signed).unwrap(); let status = verify_detached(&event, &detached).unwrap(); assert_eq!(status, VerifyStatus::Missing); } // ── T006: Canonical serialization ── // -- T006: Canonical serialization -- #[test] fn canonical_json_deterministic() { @@ -176,31 +176,26 @@ fn canonical_json_deterministic() { } #[test] fn signed_event_json_contains_all_fields() { fn detached_signature_fields_are_valid() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let event = make_event(); let signed = sign_event(&event, &sk).unwrap(); let detached = sign_event(&event, &sk).unwrap(); let json = serde_json::to_string(&signed).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); let obj = value.as_object().unwrap(); // Flattened event fields assert!(obj.contains_key("timestamp"), "missing timestamp"); assert!(obj.contains_key("author"), "missing author"); assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type"); // Signature fields assert!(obj.contains_key("signature"), "missing signature"); assert!(obj.contains_key("pubkey"), "missing pubkey"); // Verify signature and pubkey are valid base64 strings use base64::engine::general_purpose::STANDARD; use base64::Engine; let sig_bytes = STANDARD.decode(&detached.signature).unwrap(); let pk_bytes = STANDARD.decode(&detached.pubkey).unwrap(); assert_eq!(sig_bytes.len(), 64, "Ed25519 signature should be 64 bytes"); assert_eq!(pk_bytes.len(), 32, "Ed25519 public key should be 32 bytes"); } #[test] fn signed_event_flatten_round_trip_with_tagged_enum() { fn event_json_uses_namespaced_action_types() { let event = Event { timestamp: "2026-03-21T12:00:00Z".to_string(), author: Author { @@ -216,23 +211,13 @@ fn signed_event_flatten_round_trip_with_tagged_enum() { }, }; let signed = SignedEvent { event, signature: "dGVzdHNpZw==".to_string(), pubkey: "dGVzdGtleQ==".to_string(), }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("\"type\":\"patch.create\""), "action type should be namespaced: {}", json); let json = serde_json::to_string_pretty(&signed).unwrap(); let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.signature, signed.signature); assert_eq!(deserialized.pubkey, signed.pubkey); match deserialized.event.action { Action::PatchCreate { ref title, ref fixes, .. } => { // Round-trip let deserialized: Event = serde_json::from_str(&json).unwrap(); match deserialized.action { Action::PatchCreate { ref title, ref fixes, .. } => { assert_eq!(title, "Fix bug"); assert_eq!(fixes.as_deref(), Some("deadbeef")); } diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 34e6eb0..b73adb5 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -609,22 +609,38 @@ fn test_reconciliation_merge_commit_is_signed() { let commit = alice_repo.find_commit(tip).unwrap(); // The tip should be the merge commit (it's the most recent) let tree = commit.tree().unwrap(); let entry = tree.get_name("event.json").unwrap(); let blob = alice_repo.find_blob(entry.id()).unwrap(); let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap(); // Read event.json as a plain Event let event_entry = tree.get_name("event.json").unwrap(); let event_blob = alice_repo.find_blob(event_entry.id()).unwrap(); let event: git_collab::event::Event = serde_json::from_slice(event_blob.content()).unwrap(); assert!( matches!(signed.event.action, Action::Merge), matches!(event.action, Action::Merge), "Expected tip commit to be a Merge event, got {:?}", signed.event.action event.action ); // Read pubkey from separate blob let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist"); let pk_blob = alice_repo.find_blob(pk_entry.id()).unwrap(); let commit_pubkey = std::str::from_utf8(pk_blob.content()).unwrap().trim().to_string(); assert_eq!( signed.pubkey, syncing_pubkey, commit_pubkey, syncing_pubkey, "Merge commit should be signed by the syncing user's key" ); // Verify the signature is cryptographically valid let status = signing::verify_signed_event(&signed).unwrap(); // Read signature from separate blob and verify let sig_entry = tree.get_name("signature").expect("signature blob should exist"); let sig_blob = alice_repo.find_blob(sig_entry.id()).unwrap(); let sig_str = std::str::from_utf8(sig_blob.content()).unwrap().trim().to_string(); let detached = signing::DetachedSignature { signature: sig_str, pubkey: commit_pubkey, }; let status = signing::verify_detached(&event, &detached).unwrap(); assert_eq!( status, signing::VerifyStatus::Valid,