From 15e35177ddcedae05e55811dea482d265199425e Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 8 Apr 2026 07:28:06 -0500 Subject: [PATCH] feat: add attestation support to atproto api --- ...0000_plugin_tables_timestamptz_to_text.sql | 21 +++ ...0000_plugin_tables_timestamptz_to_text.sql | 2 + src/lua/atproto_api.rs | 159 +++++++++++++++++- src/lua/execute.rs | 12 +- src/plugin/attestation.rs | 145 ++++++++++++++++ 5 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 migrations/postgres/20260327000000_plugin_tables_timestamptz_to_text.sql create mode 100644 migrations/sqlite/20260327000000_plugin_tables_timestamptz_to_text.sql diff --git a/migrations/postgres/20260327000000_plugin_tables_timestamptz_to_text.sql b/migrations/postgres/20260327000000_plugin_tables_timestamptz_to_text.sql new file mode 100644 index 0000000..00e17df --- /dev/null +++ b/migrations/postgres/20260327000000_plugin_tables_timestamptz_to_text.sql @@ -0,0 +1,21 @@ +-- Fix plugin tables to use TEXT instead of TIMESTAMPTZ. +-- sqlx's AnyPool does not support native Postgres TIMESTAMPTZ, so all +-- timestamp columns must be TEXT with RFC 3339 strings. + +-- external_account_tokens +ALTER TABLE external_account_tokens + ALTER COLUMN expires_at TYPE TEXT USING expires_at::text, + ALTER COLUMN created_at TYPE TEXT USING created_at::text, + ALTER COLUMN updated_at TYPE TEXT USING updated_at::text; + +ALTER TABLE external_account_tokens + ALTER COLUMN created_at SET DEFAULT '', + ALTER COLUMN updated_at SET DEFAULT ''; + +-- external_auth_state +ALTER TABLE external_auth_state + ALTER COLUMN created_at TYPE TEXT USING created_at::text, + ALTER COLUMN expires_at TYPE TEXT USING expires_at::text; + +ALTER TABLE external_auth_state + ALTER COLUMN created_at SET DEFAULT ''; diff --git a/migrations/sqlite/20260327000000_plugin_tables_timestamptz_to_text.sql b/migrations/sqlite/20260327000000_plugin_tables_timestamptz_to_text.sql new file mode 100644 index 0000000..b1515c6 --- /dev/null +++ b/migrations/sqlite/20260327000000_plugin_tables_timestamptz_to_text.sql @@ -0,0 +1,2 @@ +-- No-op for SQLite (no strict column types). +SELECT 1; diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index 4b5a671..9573d89 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -1,4 +1,4 @@ -use mlua::{Lua, Result as LuaResult}; +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; use std::sync::Arc; use crate::AppState; @@ -6,7 +6,14 @@ use crate::db::{adapt_sql, now_rfc3339}; use crate::profile; /// Register the `atproto` table with AT Protocol utility functions. -pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { +/// +/// When `caller_did` is provided, the `atproto.sign(record)` function is +/// available for inline attestation signing. +pub fn register_atproto_api( + lua: &Lua, + state: Arc, + caller_did: Option<&str>, +) -> LuaResult<()> { let atproto_table = lua.create_table()?; let state_clone = state.clone(); @@ -192,6 +199,64 @@ pub fn register_atproto_api(lua: &Lua, state: Arc) -> LuaResult<()> { })?; atproto_table.set("get_labels_batch", get_labels_batch_fn)?; + // atproto.sign(record_table) -> inline signature object or nil + // + // Signs a record using the attestation signer and returns the inline + // signature object ({ $type, key, signature: { $bytes } }). + // Returns nil if no signer is configured. + if let Some(signer) = &state.attestation_signer { + let signer = signer.clone(); + let did = caller_did.unwrap_or("").to_string(); + let sign_fn = lua.create_function(move |lua, table: mlua::Value| { + let mut record: serde_json::Value = lua + .from_value(table) + .map_err(|e| mlua::Error::runtime(format!("atproto.sign: {e}")))?; + + signer + .sign_record(&mut record, &did) + .map_err(|e| mlua::Error::runtime(format!("atproto.sign: {e}")))?; + + // Extract the last signature (the one we just added) + let sig = record + .get("signatures") + .and_then(|s| s.as_array()) + .and_then(|arr| arr.last()) + .cloned() + .ok_or_else(|| mlua::Error::runtime("atproto.sign: no signature produced"))?; + + lua.to_value(&sig) + .map_err(|e| mlua::Error::runtime(format!("atproto.sign: {e}"))) + })?; + atproto_table.set("sign", sign_fn)?; + } + + // atproto.verify_signature(record_table, sig_table, repository_did) -> boolean + // + // Verifies that an inline signature was produced by this HappyView instance. + // Recomputes the CID and verifies the ECDSA signature. + if let Some(signer) = &state.attestation_signer { + let signer = signer.clone(); + let verify_fn = lua.create_function( + move |lua, (record, sig, repo_did): (mlua::Value, mlua::Value, String)| { + let record_json: serde_json::Value = lua + .from_value(record) + .map_err(|e| mlua::Error::runtime(format!("atproto.verify_signature: {e}")))?; + let sig_json: serde_json::Value = lua + .from_value(sig) + .map_err(|e| mlua::Error::runtime(format!("atproto.verify_signature: {e}")))?; + + match signer.verify_record_signature(&record_json, &sig_json, &repo_did) { + Ok(valid) => Ok(valid), + Err(e) => { + tracing::debug!(error = %e, "atproto.verify_signature failed"); + Ok(false) + } + } + }, + )?; + atproto_table.set("verify_signature", verify_fn)?; + } + lua.globals().set("atproto", atproto_table)?; Ok(()) } @@ -321,7 +386,7 @@ mod tests { let state = test_state_with_plc(&mock.uri()); let lua = mlua::Lua::new(); - register_atproto_api(&lua, Arc::new(state)).unwrap(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); let chunk = r#"return atproto.resolve_service_endpoint("did:plc:test123")"#; let result: String = lua.load(chunk).eval_async().await.unwrap(); @@ -340,7 +405,7 @@ mod tests { let state = test_state_with_plc(&mock.uri()); let lua = mlua::Lua::new(); - register_atproto_api(&lua, Arc::new(state)).unwrap(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); let chunk = r#"return atproto.resolve_service_endpoint("did:plc:unknown")"#; let result: mlua::Value = lua.load(chunk).eval_async().await.unwrap(); @@ -353,10 +418,94 @@ mod tests { let state = test_state_with_plc(&mock.uri()); let lua = mlua::Lua::new(); - register_atproto_api(&lua, Arc::new(state)).unwrap(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); let chunk = r#"return type(atproto.resolve_service_endpoint)"#; let result: String = lua.load(chunk).eval_async().await.unwrap(); assert_eq!(result, "function"); } + + fn test_state_with_signer(plc_url: &str) -> AppState { + let mut state = test_state_with_plc(plc_url); + state.attestation_signer = Some(Arc::new( + crate::plugin::attestation::AttestationSigner::for_testing( + "did:web:test.example#signing".to_string(), + "test.signature".to_string(), + ), + )); + state + } + + #[tokio::test] + async fn sign_returns_signature_object() { + let state = test_state_with_signer(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), Some("did:plc:caller")).unwrap(); + + let chunk = r#" + local record = { contributionType = "correction", changes = { name = "Test" } } + local sig = atproto.sign(record) + return sig.key + "#; + let result: String = lua.load(chunk).eval_async().await.unwrap(); + assert_eq!(result, "did:web:test.example#signing"); + } + + #[tokio::test] + async fn sign_returns_nil_without_signer() { + let state = test_state_with_plc(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), Some("did:plc:caller")).unwrap(); + + let chunk = r#"return atproto.sign ~= nil"#; + let result: bool = lua.load(chunk).eval_async().await.unwrap(); + // sign should not be registered when no signer is configured + assert!(!result); + } + + #[tokio::test] + async fn verify_signature_roundtrip() { + let state = test_state_with_signer(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), Some("did:plc:caller")).unwrap(); + + let chunk = r#" + local record = { contributionType = "correction", changes = { name = "Test" } } + local sig = atproto.sign(record) + return atproto.verify_signature(record, sig, "did:plc:caller") + "#; + let result: bool = lua.load(chunk).eval_async().await.unwrap(); + assert!(result); + } + + #[tokio::test] + async fn verify_signature_rejects_wrong_did() { + let state = test_state_with_signer(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), Some("did:plc:caller")).unwrap(); + + let chunk = r#" + local record = { contributionType = "correction", changes = { name = "Test" } } + local sig = atproto.sign(record) + return atproto.verify_signature(record, sig, "did:plc:wrong") + "#; + let result: bool = lua.load(chunk).eval_async().await.unwrap(); + assert!(!result); + } + + #[tokio::test] + async fn verify_signature_rejects_tampered_record() { + let state = test_state_with_signer(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), Some("did:plc:caller")).unwrap(); + + let chunk = r#" + local record = { contributionType = "correction", changes = { name = "Original" } } + local sig = atproto.sign(record) + record.changes.name = "Tampered" + return atproto.verify_signature(record, sig, "did:plc:caller") + "#; + let result: bool = lua.load(chunk).eval_async().await.unwrap(); + assert!(!result); + } } diff --git a/src/lua/execute.rs b/src/lua/execute.rs index a5e4d69..69e8ffd 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -163,7 +163,9 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = atproto_api::register_atproto_api(&lua, state_arc.clone()) { + if let Err(e) = + atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(claims.did())) + { let error_message = format!("failed to register atproto API: {e}"); log_event( &state.db, @@ -520,7 +522,11 @@ pub async fn execute_query_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = atproto_api::register_atproto_api(&lua, state_arc) { + if let Err(e) = atproto_api::register_atproto_api( + &lua, + state_arc, + claims.map(|c| c.did()), + ) { let error_message = format!("failed to register atproto API: {e}"); log_event( &state.db, @@ -891,7 +897,7 @@ async fn run_hook_once(event: &HookEvent<'_>) -> Result, String> { http_api::register_http_api(&lua, state_arc.clone()) .map_err(|e| format!("failed to register http API: {e}"))?; - atproto_api::register_atproto_api(&lua, state_arc) + atproto_api::register_atproto_api(&lua, state_arc, None) .map_err(|e| format!("failed to register atproto API: {e}"))?; context::set_hook_context( diff --git a/src/plugin/attestation.rs b/src/plugin/attestation.rs index 883e611..46dc509 100644 --- a/src/plugin/attestation.rs +++ b/src/plugin/attestation.rs @@ -188,6 +188,68 @@ impl AttestationSigner { Ok(signature.to_bytes().to_vec()) } + + /// Verify that a signature in a record was produced by this signer. + /// + /// Recomputes the CID from the record (same process as signing) and verifies + /// the ECDSA signature using our public key. + pub fn verify_record_signature( + &self, + record: &Value, + signature_obj: &Value, + repository_did: &str, + ) -> Result { + use k256::ecdsa::{VerifyingKey, signature::Verifier}; + + // Check key ID matches + let key = signature_obj + .get("key") + .and_then(|k| k.as_str()) + .ok_or_else(|| AttestationError::MissingField("signature.key".into()))?; + + if key != self.key_id { + return Ok(false); + } + + // Extract signature bytes + let sig_bytes_b64 = signature_obj + .get("signature") + .and_then(|s| s.get("$bytes")) + .and_then(|b| b.as_str()) + .ok_or_else(|| AttestationError::MissingField("signature.signature.$bytes".into()))?; + + let sig_bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + sig_bytes_b64, + ) + .map_err(|e| AttestationError::Encoding(format!("invalid base64: {e}")))?; + + let signature = Signature::from_bytes((&sig_bytes[..]).into()) + .map_err(|e| AttestationError::Signing(format!("invalid signature bytes: {e}")))?; + + // Recompute CID from record (same as signing) + let mut obj = record + .as_object() + .ok_or_else(|| AttestationError::Encoding("record must be an object".into()))? + .clone(); + + // Remove signatures for CID computation + obj.remove("signatures"); + + // Inject $sig metadata + let sig_metadata = serde_json::json!({ + "$type": &self.sig_type, + "repository": repository_did, + }); + obj.insert("$sig".to_string(), sig_metadata); + + let cbor_bytes = self.encode_dag_cbor(&obj)?; + let cid = self.compute_cid(&cbor_bytes); + + // Verify + let verifying_key = VerifyingKey::from(&self.signing_key); + Ok(verifying_key.verify(&cid.to_bytes(), &signature).is_ok()) + } } /// Convert JSON Value to ciborium Value with deterministic ordering @@ -328,4 +390,87 @@ mod tests { // and the key ordering is normalized assert_eq!(cid1, cid2); } + + #[test] + fn test_verify_record_signature() { + let signer = AttestationSigner::for_testing( + "did:web:test.example#signing".to_string(), + "test.signature".to_string(), + ); + + let original = serde_json::json!({ + "$type": "games.gamesgamesgamesgames.contribution", + "contributionType": "correction", + "changes": {"name": "Fixed Name"}, + "createdAt": "2024-01-01T00:00:00Z" + }); + + let mut record = original.clone(); + signer + .sign_record(&mut record, "did:plc:contributor") + .expect("signing should succeed"); + + let sig = &record["signatures"].as_array().unwrap()[0]; + + // Verification should succeed with correct DID + assert!(signer + .verify_record_signature(&record, sig, "did:plc:contributor") + .unwrap()); + + // Verification should fail with wrong DID (replay protection) + assert!(!signer + .verify_record_signature(&record, sig, "did:plc:wrong") + .unwrap()); + } + + #[test] + fn test_verify_rejects_wrong_key_id() { + let signer = AttestationSigner::for_testing( + "did:web:test.example#signing".to_string(), + "test.signature".to_string(), + ); + + let forged_sig = serde_json::json!({ + "$type": "test.signature", + "key": "did:web:evil.example#signing", + "signature": { "$bytes": "AAAA" } + }); + + let record = serde_json::json!({ + "contributionType": "correction", + "changes": {"name": "test"} + }); + + assert!(!signer + .verify_record_signature(&record, &forged_sig, "did:plc:test") + .unwrap()); + } + + #[test] + fn test_verify_rejects_tampered_record() { + let signer = AttestationSigner::for_testing( + "did:web:test.example#signing".to_string(), + "test.signature".to_string(), + ); + + let mut record = serde_json::json!({ + "contributionType": "correction", + "changes": {"name": "Original"}, + "createdAt": "2024-01-01T00:00:00Z" + }); + + signer + .sign_record(&mut record, "did:plc:test") + .expect("signing should succeed"); + + let sig = record["signatures"].as_array().unwrap()[0].clone(); + + // Tamper with the record + record["changes"]["name"] = serde_json::json!("Tampered"); + + // Verification should fail + assert!(!signer + .verify_record_signature(&record, &sig, "did:plc:test") + .unwrap()); + } } -- 2.51.2