From a5f165d7e0c8e7ac893b4b9e3b9ce46e249e55fc Mon Sep 17 00:00:00 2001 From: root Date: Thu, 28 May 2026 05:39:02 +0000 Subject: [PATCH] feat(runtime): add worker pairing fingerprint primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PairingFingerprint (SHA-256 hex, 64 chars) from identity bytes. - Add PairingPin (first 4 hex nibbles) for OOB human verification. - Add PairingVerificationResult (Match/Mismatch) for verify gates. - Add PairingPreview (initiator + responder identities + PIN) for pre-approval UI. - All functions are deterministic: same bytes → same fingerprint. - No real keypair generation, No Noise XX, No mDNS, No network code. - 17 tests covering stability, determinism, pin formatting, verify, round-trip. - sha2 workspace dependency added. Co-authored-by: CommandCodeBot --- Cargo.lock | 1 + Cargo.toml | 1 + crates/codra-runtime/Cargo.toml | 1 + crates/codra-runtime/src/lib.rs | 2 + crates/codra-runtime/src/pairing.rs | 340 ++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+) create mode 100644 crates/codra-runtime/src/pairing.rs diff --git a/Cargo.lock b/Cargo.lock index c3808ac..fb5e00d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ dependencies = [ "futures", "serde", "serde_json", + "sha2", "thiserror 1.0.69", "tokio", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 9cc4963..e619f43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.37", features = ["full"] } thiserror = "1.0" +sha2 = "0.10" anyhow = "1.0" reqwest = { version = "0.12", features = ["json", "blocking"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/codra-runtime/Cargo.toml b/crates/codra-runtime/Cargo.toml index f052d59..e2cfa35 100644 --- a/crates/codra-runtime/Cargo.toml +++ b/crates/codra-runtime/Cargo.toml @@ -12,3 +12,4 @@ chrono = { workspace = true } uuid = { version = "1.7", features = ["v4", "fast-rng"] } async-trait = "0.1" futures = "0.3" +sha2 = { workspace = true } diff --git a/crates/codra-runtime/src/lib.rs b/crates/codra-runtime/src/lib.rs index 523f091..b87fde2 100644 --- a/crates/codra-runtime/src/lib.rs +++ b/crates/codra-runtime/src/lib.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod pairing; pub mod registry; pub mod traits; pub mod types; @@ -7,6 +8,7 @@ pub mod stub; // Re-exports for external consumers pub use error::{RuntimeError, RuntimeResult}; +pub use pairing::{PairingFingerprint, PairingPin, PairingPreview, PairingVerificationResult}; pub use registry::RuntimeRegistry; pub use traits::{CodraRuntime, EventStream}; pub use types::*; diff --git a/crates/codra-runtime/src/pairing.rs b/crates/codra-runtime/src/pairing.rs new file mode 100644 index 0000000..6acd2ca --- /dev/null +++ b/crates/codra-runtime/src/pairing.rs @@ -0,0 +1,340 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; + +// ── Core Types ────────────────────────────────────────────────── + +/// A SHA-256 fingerprint derived from identity material (e.g. a +/// X25519 static public key). Used for out-of-band verification +/// during controller-worker pairing. +/// +/// The hex encoding is lowercase, 64 characters. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PairingFingerprint(String); + +impl PairingFingerprint { + /// Number of hex characters in a SHA-256 digest. + pub const HEX_LEN: usize = 64; + + /// The number of hex nibbles used for the short PIN. + pub const PIN_NIBBLES: usize = 4; + + /// Derive a fingerprint from raw identity bytes (e.g. a public key). + /// + /// The input is hashed with SHA-256. The hex encoding is lowercase, + /// 64 characters. This is deterministic: same bytes → same fingerprint. + pub fn from_bytes(bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let result = hasher.finalize(); + Self(format!("{:x}", result)) + } + + /// Return the full 64-character hex string. + pub fn as_hex(&self) -> &str { + &self.0 + } + + /// Return the short human-checkable PIN (first 4 hex characters). + /// + /// This gives 65,536 possible values — sufficient for a + /// same-room OOB verification where the user visually compares + /// two displays. + pub fn pin(&self) -> PairingPin { + PairingPin(self.0[..Self::PIN_NIBBLES].to_string()) + } + + /// Check whether this fingerprint matches another. + pub fn matches(&self, other: &PairingFingerprint) -> PairingVerificationResult { + if self.0 == other.0 { + PairingVerificationResult::Match + } else { + PairingVerificationResult::Mismatch + } + } +} + +impl fmt::Display for PairingFingerprint { + /// Display as a shortened form suitable for UI labels. + /// Shows first 8 hex chars + "..." to avoid overwhelming the user. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let truncated: String = self.0.chars().take(8).collect(); + write!(f, "{}…", truncated) + } +} + +// ── Pairing PIN ────────────────────────────────────────────────── + +/// A short human-checkable PIN derived from a [`PairingFingerprint`]. +/// +/// The PIN is the first 4 hex characters of the SHA-256 digest. +/// Users compare this PIN out-of-band (e.g. "my display shows `a3f1` — +/// does yours?"). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PairingPin(String); + +impl PairingPin { + /// Return the PIN as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PairingPin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// ── Verification Result ────────────────────────────────────────── + +/// The result of checking whether two fingerprints match. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PairingVerificationResult { + /// The two fingerprints are identical — trust can proceed. + #[serde(rename = "match")] + Match, + /// The two fingerprints differ — do not trust. + #[serde(rename = "mismatch")] + Mismatch, +} + +impl PairingVerificationResult { + /// Returns `true` if the result is a match. + pub fn is_match(&self) -> bool { + matches!(self, Self::Match) + } +} + +// ── Pairing Preview ────────────────────────────────────────────── + +/// A snapshot of a pending pairing that can be displayed to the user +/// before they approve or reject. +/// +/// Both sides construct their own preview and show it so the user +/// can compare the PIN out-of-band. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingPreview { + /// The party that initiated the pairing request. + pub initiator_label: String, + /// The fingerprint of the initiator. + pub initiator_fingerprint: PairingFingerprint, + /// The party that received the pairing request. + pub responder_label: String, + /// The fingerprint of the responder. + pub responder_fingerprint: PairingFingerprint, + /// The short PIN — first 4 hex chars of the responder's fingerprint. + /// This is what the user compares out-of-band. + pub pin: PairingPin, +} + +impl PairingPreview { + /// Build a preview for a pending pairing. + /// + /// # Arguments + /// * `initiator_label` — Human-readable name of the party requesting the pair. + /// * `initiator_bytes` — Identity bytes of the initiator (e.g. public key). + /// * `responder_label` — Human-readable name of the party being paired to. + /// * `responder_bytes` — Identity bytes of the responder (e.g. public key). + pub fn new( + initiator_label: impl Into, + initiator_bytes: &[u8], + responder_label: impl Into, + responder_bytes: &[u8], + ) -> Self { + let responder_fp = PairingFingerprint::from_bytes(responder_bytes); + let pin = responder_fp.pin(); + Self { + initiator_label: initiator_label.into(), + initiator_fingerprint: PairingFingerprint::from_bytes(initiator_bytes), + responder_label: responder_label.into(), + responder_fingerprint: responder_fp, + pin, + } + } + + /// Verify that the responder's fingerprint matches the expected one. + pub fn verify_responder(&self, responder_bytes: &[u8]) -> PairingVerificationResult { + let expected = PairingFingerprint::from_bytes(responder_bytes); + self.responder_fingerprint.matches(&expected) + } +} + +// ── Tests ──────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn alice_material() -> Vec { + b"alice-identity-material-x25519-pubkey".to_vec() + } + + fn bob_material() -> Vec { + b"bob-identity-material-x25519-pubkey".to_vec() + } + + #[test] + fn same_input_gives_same_fingerprint() { + let material = b"test-identity-bytes"; + let fp1 = PairingFingerprint::from_bytes(material); + let fp2 = PairingFingerprint::from_bytes(material); + assert_eq!(fp1, fp2); + assert_eq!(fp1.as_hex(), fp2.as_hex()); + } + + #[test] + fn different_input_gives_different_fingerprint() { + let fp1 = PairingFingerprint::from_bytes(b"hello"); + let fp2 = PairingFingerprint::from_bytes(b"world"); + assert_ne!(fp1, fp2); + } + + #[test] + fn fingerprint_is_64_hex_chars() { + let fp = PairingFingerprint::from_bytes(b"anything"); + assert_eq!(fp.as_hex().len(), 64); + + // Verify all chars are hex + let all_hex = fp.as_hex().chars().all(|c| c.is_ascii_hexdigit()); + assert!(all_hex); + } + + #[test] + fn fingerprint_is_lowercase() { + let fp = PairingFingerprint::from_bytes(b"stuff"); + assert_eq!(fp.as_hex(), fp.as_hex().to_lowercase()); + } + + #[test] + fn pin_is_first_4_hex_chars() { + let fp = PairingFingerprint::from_bytes(b"pin-test"); + let pin = fp.pin(); + assert_eq!(pin.as_str().len(), 4); + assert_eq!(pin.as_str(), &fp.as_hex()[..4]); + } + + #[test] + fn pin_stability() { + let fp = PairingFingerprint::from_bytes(b"stable-pin"); + let pin1 = fp.pin(); + let pin2 = fp.pin(); + assert_eq!(pin1, pin2); + } + + #[test] + fn pin_is_hex() { + let fp = PairingFingerprint::from_bytes(b"hex-pin"); + let pin = fp.pin(); + assert!(pin.as_str().chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn verify_match_returns_success() { + let material = b"matching-material"; + let fp1 = PairingFingerprint::from_bytes(material); + let fp2 = PairingFingerprint::from_bytes(material); + let result = fp1.matches(&fp2); + assert_eq!(result, PairingVerificationResult::Match); + assert!(result.is_match()); + } + + #[test] + fn verify_mismatch_returns_failure() { + let fp1 = PairingFingerprint::from_bytes(b"material-a"); + let fp2 = PairingFingerprint::from_bytes(b"material-b"); + let result = fp1.matches(&fp2); + assert_eq!(result, PairingVerificationResult::Mismatch); + assert!(!result.is_match()); + } + + #[test] + fn pairing_preview_serializes_deserializes() { + let preview = PairingPreview::new("Alice", &alice_material(), "Bob", &bob_material()); + let json = serde_json::to_string(&preview).unwrap(); + let deserialized: PairingPreview = serde_json::from_str(&json).unwrap(); + + assert_eq!(preview.initiator_label, deserialized.initiator_label); + assert_eq!(preview.responder_label, deserialized.responder_label); + assert_eq!( + preview.initiator_fingerprint, + deserialized.initiator_fingerprint + ); + assert_eq!( + preview.responder_fingerprint, + deserialized.responder_fingerprint + ); + assert_eq!(preview.pin, deserialized.pin); + } + + #[test] + fn pairing_preview_pin_comes_from_responder() { + let preview = + PairingPreview::new("Controller", b"ctrl-material", "Worker", b"worker-material"); + let worker_fp = PairingFingerprint::from_bytes(b"worker-material"); + assert_eq!(preview.pin, worker_fp.pin()); + assert_ne!( + preview.pin, + PairingFingerprint::from_bytes(b"ctrl-material").pin() + ); + } + + #[test] + fn pairing_preview_verify_responder_success() { + let preview = PairingPreview::new("Me", b"my-material", "Worker", b"worker-material"); + assert!(preview.verify_responder(b"worker-material").is_match()); + } + + #[test] + fn pairing_preview_verify_responder_failure() { + let preview = PairingPreview::new("Me", b"my-material", "Worker", b"worker-material"); + assert!(!preview + .verify_responder(b"wrong-worker-material") + .is_match()); + } + + #[test] + fn fingerprint_display_truncates() { + let fp = PairingFingerprint::from_bytes(b"display-test"); + let display = format!("{}", fp); + // Should show first 8 hex chars + "…" = 9 characters + assert_eq!(display.chars().count(), 9); + assert!(display.ends_with('…')); + let truncated: String = display.chars().take(8).collect(); + assert_eq!(truncated, &fp.as_hex()[..8]); + } + + #[test] + fn pairing_preview_json_structure() { + let preview = PairingPreview::new("Controller", b"ctrl-key", "Worker One", b"worker-key"); + let json = serde_json::to_value(&preview).unwrap(); + assert_eq!(json["initiator_label"], "Controller"); + assert_eq!(json["responder_label"], "Worker One"); + assert_eq!(json["pin"], preview.pin.as_str()); + assert_eq!(json["initiator_fingerprint"].as_str().unwrap().len(), 64); + assert_eq!(json["responder_fingerprint"].as_str().unwrap().len(), 64); + } + + #[test] + fn fingerprint_is_deterministic() { + // Run 10 times — must produce identical hex each time + let hexes: Vec = (0..10) + .map(|_| { + PairingFingerprint::from_bytes(b"deterministic") + .as_hex() + .to_string() + }) + .collect(); + for h in &hexes[1..] { + assert_eq!(*h, hexes[0]); + } + } + + #[test] + fn empty_bytes_produces_valid_fingerprint() { + let fp = PairingFingerprint::from_bytes(b""); + assert_eq!(fp.as_hex().len(), 64); + } +}