Add Phase 1 frxd implementation with conformance test suite

This commit is contained in:
George Coles
2026-09-15 03:07:17 -04:00
parent 81ef27179b
commit 82a95fb907
23 changed files with 7295 additions and 3 deletions
+166
View File
@@ -0,0 +1,166 @@
use crate::PROTOCOL;
use crate::message::Envelope;
use anyhow::{Context, Result, anyhow};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use rand::RngCore;
use serde_json::Value;
pub struct Keypair {
signing: SigningKey,
}
impl Keypair {
pub fn generate() -> Self {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
Self {
signing: SigningKey::from_bytes(&bytes),
}
}
pub fn from_hex(s: &str) -> Result<Self> {
let raw = hex::decode(s.trim()).context("key is not hex")?;
let bytes: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow!("key must be 32 bytes"))?;
Ok(Self {
signing: SigningKey::from_bytes(&bytes),
})
}
pub fn to_hex(&self) -> String {
hex::encode(self.signing.to_bytes())
}
pub fn public_hex(&self) -> String {
hex::encode(self.signing.verifying_key().to_bytes())
}
pub fn verifying_key(&self) -> VerifyingKey {
self.signing.verifying_key()
}
pub fn sign(&self, bytes: &[u8]) -> String {
hex::encode(self.signing.sign(bytes).to_bytes())
}
}
pub fn random_nonce() -> String {
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn random_id() -> String {
let mut bytes = [0u8; 8];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn canonical_json(value: &Value) -> String {
let mut out = String::new();
write_canonical(value, &mut out);
out
}
fn write_canonical(value: &Value, out: &mut String) {
match value {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Number(n) => out.push_str(&n.to_string()),
Value::String(s) => out.push_str(&serde_json::to_string(s).expect("string serializes")),
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(item, out);
}
out.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&serde_json::to_string(key).expect("key serializes"));
out.push(':');
write_canonical(&map[*key], out);
}
out.push('}');
}
}
}
pub fn signing_bytes(envelope: &Envelope) -> Vec<u8> {
format!(
"{}\n{}\n{}\n{}\n{}\n{}",
PROTOCOL,
envelope.msg_type,
envelope.from,
envelope.ts,
envelope.nonce,
canonical_json(&envelope.body)
)
.into_bytes()
}
pub fn verify_envelope(envelope: &Envelope) -> Result<()> {
let key_bytes = hex::decode(&envelope.from).context("from is not hex")?;
let key_bytes: [u8; 32] = key_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("from must be a 32-byte ed25519 public key"))?;
let verifying_key =
VerifyingKey::from_bytes(&key_bytes).map_err(|e| anyhow!("bad public key: {e}"))?;
let sig_bytes = hex::decode(&envelope.sig).context("sig is not hex")?;
let sig_bytes: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("sig must be 64 bytes"))?;
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify_strict(&signing_bytes(envelope), &signature)
.map_err(|_| anyhow!("signature verification failed"))
}
pub fn now_ts() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonical_json_sorts_keys_recursively() {
let a = json!({"b": 1, "a": {"d": [3, 2], "c": "x"}});
let b = json!({"a": {"c": "x", "d": [3, 2]}, "b": 1});
assert_eq!(canonical_json(&a), canonical_json(&b));
assert_eq!(canonical_json(&a), r#"{"a":{"c":"x","d":[3,2]},"b":1}"#);
}
#[test]
fn envelope_sign_verify_roundtrip() {
let key = Keypair::generate();
let env = Envelope::new(&key, crate::message::TYPE_QUERY, json!({"text": "hello"}));
verify_envelope(&env).unwrap();
}
#[test]
fn tampered_body_fails_verification() {
let key = Keypair::generate();
let mut env = Envelope::new(&key, crate::message::TYPE_QUERY, json!({"text": "hello"}));
env.body = json!({"text": "hello", "extra": true});
assert!(verify_envelope(&env).is_err());
}
}