mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(nip-oa): Owner Attestation — cryptographic proof of agent authorization (#406)
This commit is contained in:
@@ -2246,6 +2246,16 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Forward SPROUT_AUTH_TAG (NIP-OA owner attestation credential)
|
||||
// so the MCP server can attach it to every signed event.
|
||||
if let Ok(auth_tag) = std::env::var("SPROUT_AUTH_TAG") {
|
||||
if !auth_tag.is_empty() {
|
||||
env.push(EnvVar {
|
||||
name: "SPROUT_AUTH_TAG".into(),
|
||||
value: auth_tag,
|
||||
});
|
||||
}
|
||||
}
|
||||
env
|
||||
},
|
||||
}]
|
||||
@@ -2503,3 +2513,110 @@ mod sibling_cache_tests {
|
||||
assert_eq!(cache.check(AUTHOR_2, OWNER_B), Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod build_mcp_servers_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Env-var-touching tests must run serially — env vars are process-global.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn test_config() -> Config {
|
||||
Config {
|
||||
keys: nostr::Keys::generate(),
|
||||
api_token: None,
|
||||
relay_url: "ws://localhost:3000".into(),
|
||||
agent_command: "goose".into(),
|
||||
agent_args: vec!["acp".into()],
|
||||
mcp_command: "sprout-mcp-server".into(),
|
||||
idle_timeout_secs: 320,
|
||||
max_turn_duration_secs: 3600,
|
||||
agents: 1,
|
||||
heartbeat_interval_secs: 0,
|
||||
heartbeat_prompt: None,
|
||||
system_prompt: None,
|
||||
initial_message: None,
|
||||
subscribe_mode: config::SubscribeMode::All,
|
||||
dedup_mode: config::DedupMode::Queue,
|
||||
multiple_event_handling: config::MultipleEventHandling::Queue,
|
||||
ignore_self: true,
|
||||
kinds_override: None,
|
||||
channels_override: None,
|
||||
no_mention_filter: false,
|
||||
config_path: std::path::PathBuf::from("./sprout-acp.toml"),
|
||||
context_message_limit: 12,
|
||||
max_turns_per_session: 0,
|
||||
presence_enabled: true,
|
||||
typing_enabled: true,
|
||||
model: None,
|
||||
permission_mode: config::PermissionMode::BypassPermissions,
|
||||
respond_to: config::RespondTo::Anyone,
|
||||
respond_to_allowlist: std::collections::HashSet::new(),
|
||||
persona_env_vars: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_mcp_server_has_required_fields() {
|
||||
let config = test_config();
|
||||
let servers = build_mcp_servers(&config);
|
||||
assert_eq!(servers.len(), 1);
|
||||
let server = &servers[0];
|
||||
assert_eq!(server.name, "sprout-mcp");
|
||||
|
||||
let names: Vec<&str> = server.env.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"SPROUT_RELAY_URL"),
|
||||
"missing SPROUT_RELAY_URL; got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"SPROUT_PRIVATE_KEY"),
|
||||
"missing SPROUT_PRIVATE_KEY; got {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_mcp_server_forwards_sprout_auth_tag() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("SPROUT_AUTH_TAG", "test-attestation-tag");
|
||||
let config = test_config();
|
||||
let servers = build_mcp_servers(&config);
|
||||
std::env::remove_var("SPROUT_AUTH_TAG");
|
||||
|
||||
let server = &servers[0];
|
||||
let auth_tag_env = server.env.iter().find(|e| e.name == "SPROUT_AUTH_TAG");
|
||||
assert!(
|
||||
auth_tag_env.is_some(),
|
||||
"SPROUT_AUTH_TAG should be forwarded when set"
|
||||
);
|
||||
assert_eq!(auth_tag_env.unwrap().value, "test-attestation-tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_mcp_server_skips_empty_sprout_auth_tag() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("SPROUT_AUTH_TAG", "");
|
||||
let config = test_config();
|
||||
let servers = build_mcp_servers(&config);
|
||||
std::env::remove_var("SPROUT_AUTH_TAG");
|
||||
|
||||
let server = &servers[0];
|
||||
let has_auth_tag = server.env.iter().any(|e| e.name == "SPROUT_AUTH_TAG");
|
||||
assert!(
|
||||
!has_auth_tag,
|
||||
"empty SPROUT_AUTH_TAG should not be forwarded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_new_mcp_server_forwards_api_token_when_set() {
|
||||
let mut config = test_config();
|
||||
config.api_token = Some("tok_test_123".into());
|
||||
let servers = build_mcp_servers(&config);
|
||||
let server = &servers[0];
|
||||
let token_env = server.env.iter().find(|e| e.name == "SPROUT_API_TOKEN");
|
||||
assert!(token_env.is_some(), "SPROUT_API_TOKEN should be forwarded");
|
||||
assert_eq!(token_env.unwrap().value, "tok_test_123");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use nostr::Keys;
|
||||
use nostr::{Keys, Tag};
|
||||
use rmcp::{transport::stdio, ServiceExt};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -7,6 +7,42 @@ use sprout_mcp::relay_client::RelayClient;
|
||||
use sprout_mcp::server::SproutMcpServer;
|
||||
use sprout_mcp::toolsets::ToolsetConfig;
|
||||
|
||||
/// Parse and validate the NIP-OA auth tag from the environment.
|
||||
///
|
||||
/// Returns `Ok(Some(tag))` if a valid auth tag is configured,
|
||||
/// `Ok(None)` if no auth tag is set (or empty),
|
||||
/// `Err` if the tag is present but malformed, invalid, or non-Unicode.
|
||||
fn resolve_auth_tag(
|
||||
env_value: Result<String, std::env::VarError>,
|
||||
agent_pubkey: &nostr::PublicKey,
|
||||
) -> anyhow::Result<Option<Tag>> {
|
||||
let tag_json = match env_value {
|
||||
Ok(s) if !s.is_empty() => s,
|
||||
Ok(_) => return Ok(None), // empty string — treat as not set
|
||||
Err(std::env::VarError::NotPresent) => return Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
anyhow::bail!("SPROUT_AUTH_TAG contains non-Unicode data");
|
||||
}
|
||||
};
|
||||
|
||||
let tag = sprout_sdk::nip_oa::parse_auth_tag(&tag_json)
|
||||
.map_err(|e| anyhow::anyhow!("SPROUT_AUTH_TAG is malformed: {e}"))?;
|
||||
|
||||
sprout_sdk::nip_oa::verify_auth_tag(&tag_json, agent_pubkey).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"SPROUT_AUTH_TAG signature verification failed for agent pubkey {}: {e}",
|
||||
agent_pubkey.to_hex()
|
||||
)
|
||||
})?;
|
||||
|
||||
eprintln!(
|
||||
"sprout-mcp: NIP-OA auth tag configured (owner: {})",
|
||||
tag.as_slice().get(1).map(|s| &s[..8]).unwrap_or("?")
|
||||
);
|
||||
|
||||
Ok(Some(tag))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Install the ring crypto provider for rustls (required for wss:// connections).
|
||||
@@ -36,11 +72,17 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let auth_tag = resolve_auth_tag(std::env::var("SPROUT_AUTH_TAG"), &keys.public_key())?;
|
||||
|
||||
if auth_tag.is_some() {
|
||||
eprintln!("sprout-mcp: NIP-OA auth tag verified ✓");
|
||||
}
|
||||
|
||||
let toolset_config = ToolsetConfig::from_env();
|
||||
eprintln!("sprout-mcp: toolsets: {:?}", toolset_config);
|
||||
|
||||
eprintln!("sprout-mcp: connecting to relay at {relay_url}...");
|
||||
let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref()).await?;
|
||||
let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref(), auth_tag).await?;
|
||||
eprintln!("sprout-mcp: connected and authenticated.");
|
||||
|
||||
let tools_to_remove = toolset_config.tools_to_remove();
|
||||
@@ -50,3 +92,70 @@ async fn main() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String {
|
||||
let owner_keys = nostr::Keys::generate();
|
||||
sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auth_tag_valid() {
|
||||
let agent_keys = nostr::Keys::generate();
|
||||
let json = make_valid_auth_tag(&agent_keys);
|
||||
let result = resolve_auth_tag(Ok(json), &agent_keys.public_key());
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auth_tag_not_present() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let result = resolve_auth_tag(Err(std::env::VarError::NotPresent), &keys.public_key());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auth_tag_empty_string() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let result = resolve_auth_tag(Ok(String::new()), &keys.public_key());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auth_tag_malformed_json() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let result = resolve_auth_tag(Ok("not json".into()), &keys.public_key());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("malformed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auth_tag_bad_signature() {
|
||||
let agent_keys = nostr::Keys::generate();
|
||||
// Structurally valid (4 elements, correct hex lengths) but cryptographically wrong.
|
||||
let fake_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
|
||||
let result = resolve_auth_tag(Ok(fake_json), &agent_keys.public_key());
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("verification failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_auth_tag_non_unicode() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let result = resolve_auth_tag(
|
||||
Err(std::env::VarError::NotUnicode(std::ffi::OsString::from(
|
||||
"bad",
|
||||
))),
|
||||
&keys.public_key(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("non-Unicode"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,6 +699,8 @@ pub struct RelayClient {
|
||||
http: reqwest::Client,
|
||||
/// Optional API token for Bearer auth on REST endpoints.
|
||||
api_token: Option<String>,
|
||||
/// Optional NIP-OA auth tag injected into every signed event.
|
||||
auth_tag: Option<nostr::Tag>,
|
||||
}
|
||||
|
||||
impl RelayClient {
|
||||
@@ -706,10 +708,14 @@ impl RelayClient {
|
||||
///
|
||||
/// Performs the initial NIP-42 handshake synchronously so startup failures
|
||||
/// are surfaced immediately. After that, reconnection is automatic.
|
||||
///
|
||||
/// `auth_tag` is an optional NIP-OA tag that will be injected into every
|
||||
/// event signed via [`sign_event`](Self::sign_event).
|
||||
pub async fn connect(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
api_token: Option<&str>,
|
||||
auth_tag: Option<nostr::Tag>,
|
||||
) -> Result<Self, RelayClientError> {
|
||||
let ws = do_connect(relay_url, keys, api_token).await?;
|
||||
|
||||
@@ -734,12 +740,45 @@ impl RelayClient {
|
||||
.build()
|
||||
.map_err(|e| RelayClientError::Url(format!("HTTP client build failed: {e}")))?,
|
||||
api_token: api_token.map(|t| t.to_string()),
|
||||
auth_tag,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the Nostr keypair used for signing and authentication.
|
||||
pub fn keys(&self) -> &Keys {
|
||||
&self.keys
|
||||
/// Sign an event builder, injecting the NIP-OA auth tag if configured.
|
||||
///
|
||||
/// This is the canonical signing path in the MCP server. All event creation
|
||||
/// should go through this method to ensure consistent auth tag injection.
|
||||
///
|
||||
/// **Callers MUST NOT add `auth` tags to the builder before calling this
|
||||
/// method.** The only `auth` tag that may appear in the signed event is the
|
||||
/// one injected by this method. Any pre-existing `auth` tag — whether
|
||||
/// `self.auth_tag` is configured or not — is rejected immediately.
|
||||
pub fn sign_event(&self, builder: EventBuilder) -> Result<Event, RelayClientError> {
|
||||
let builder = if let Some(ref tag) = self.auth_tag {
|
||||
builder.add_tags([tag.clone()])
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
let event = builder
|
||||
.sign_with_keys(&self.keys)
|
||||
.map_err(RelayClientError::from)?;
|
||||
|
||||
// Enforce: auth tags may only come from self.auth_tag injection.
|
||||
// - If auth_tag is Some: exactly 1 auth tag must exist (the one we injected)
|
||||
// - If auth_tag is None: zero auth tags must exist (no caller bypass)
|
||||
let auth_count = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
|
||||
.count();
|
||||
let expected = if self.auth_tag.is_some() { 1 } else { 0 };
|
||||
if auth_count != expected {
|
||||
return Err(RelayClientError::EventBuilder(format!(
|
||||
"event has {auth_count} auth tags — expected {expected}; callers must not add auth tags manually"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Returns the WebSocket URL the client connected to.
|
||||
@@ -753,7 +792,7 @@ impl RelayClient {
|
||||
relay_ws_to_http(&self.relay_url)
|
||||
}
|
||||
|
||||
fn pubkey_hex(&self) -> String {
|
||||
pub(crate) fn pubkey_hex(&self) -> String {
|
||||
self.keys.public_key().to_hex()
|
||||
}
|
||||
|
||||
@@ -850,7 +889,55 @@ impl RelayClient {
|
||||
}
|
||||
|
||||
/// Publish a signed Nostr event to the relay and wait for the `OK` acknowledgement.
|
||||
///
|
||||
/// Defense-in-depth: validates that the event carries the expected number of
|
||||
/// `auth` tags before publishing. This catches any code path that bypasses
|
||||
/// [`sign_event`](Self::sign_event).
|
||||
pub async fn send_event(&self, event: Event) -> Result<OkResponse, RelayClientError> {
|
||||
// Verify the event was authored by this client's keypair.
|
||||
if event.pubkey != self.keys.public_key() {
|
||||
return Err(RelayClientError::EventBuilder(format!(
|
||||
"send_event rejected: event pubkey {} does not match client pubkey {}",
|
||||
event.pubkey.to_hex(),
|
||||
self.keys.public_key().to_hex()
|
||||
)));
|
||||
}
|
||||
|
||||
// Defense-in-depth: validate auth tags match configuration exactly.
|
||||
let auth_tags: Vec<&nostr::Tag> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
|
||||
.collect();
|
||||
|
||||
match (&self.auth_tag, auth_tags.as_slice()) {
|
||||
// Configured: exactly 1 auth tag that matches our configured tag byte-for-byte
|
||||
(Some(expected), [actual]) => {
|
||||
if actual.as_slice() != expected.as_slice() {
|
||||
return Err(RelayClientError::EventBuilder(
|
||||
"send_event rejected: auth tag does not match configured attestation"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Configured but wrong count
|
||||
(Some(_), tags) => {
|
||||
return Err(RelayClientError::EventBuilder(format!(
|
||||
"send_event rejected: expected 1 auth tag, found {}",
|
||||
tags.len()
|
||||
)));
|
||||
}
|
||||
// Unconfigured: no auth tags allowed
|
||||
(None, tags) if !tags.is_empty() => {
|
||||
return Err(RelayClientError::EventBuilder(format!(
|
||||
"send_event rejected: auth tags not allowed when unconfigured, found {}",
|
||||
tags.len()
|
||||
)));
|
||||
}
|
||||
// Unconfigured, no auth tags: OK
|
||||
(None, _) => {}
|
||||
}
|
||||
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.bg
|
||||
.cmd_tx
|
||||
@@ -1213,6 +1300,168 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── sign_event auth tag injection ────────────────────────────────────────
|
||||
|
||||
/// Build a minimal `RelayClient` without a live relay connection.
|
||||
///
|
||||
/// Only `keys` and `auth_tag` matter for `sign_event`; the other fields
|
||||
/// are inert stubs (the background task immediately exits, the HTTP
|
||||
/// client is never used).
|
||||
fn make_client(keys: Keys, auth_tag: Option<nostr::Tag>) -> RelayClient {
|
||||
let (cmd_tx, _cmd_rx) = mpsc::channel(1);
|
||||
let handle = tokio::runtime::Handle::current().spawn(async {});
|
||||
RelayClient {
|
||||
bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }),
|
||||
keys,
|
||||
relay_url: "ws://127.0.0.1:1".to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
api_token: None,
|
||||
auth_tag,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_event_injects_auth_tag() {
|
||||
let keys = Keys::generate();
|
||||
// Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "<conditions>", "<128-char-hex-sig>"]
|
||||
let owner_pubkey = "a".repeat(64);
|
||||
let conditions = "";
|
||||
let signature = "b".repeat(128);
|
||||
let auth_tag = nostr::Tag::parse(&["auth", &owner_pubkey, conditions, &signature]).unwrap();
|
||||
|
||||
// With auth_tag: the signed event must contain it.
|
||||
let client = make_client(keys.clone(), Some(auth_tag.clone()));
|
||||
let event = client
|
||||
.sign_event(EventBuilder::new(Kind::TextNote, "hello", []))
|
||||
.expect("sign_event should succeed");
|
||||
|
||||
let tag_values: Vec<Vec<String>> = event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
|
||||
.collect();
|
||||
assert!(
|
||||
tag_values
|
||||
.iter()
|
||||
.any(|t| t.first().map(|s| s.as_str()) == Some("auth")
|
||||
&& t.get(1).map(|s| s.as_str()) == Some(owner_pubkey.as_str())
|
||||
&& t.get(3).map(|s| s.as_str()) == Some(signature.as_str())),
|
||||
"expected NIP-OA auth tag in event; got: {tag_values:?}"
|
||||
);
|
||||
|
||||
// Without auth_tag: the signed event must NOT contain an auth tag.
|
||||
let client_no_auth = make_client(keys, None);
|
||||
let event_no_auth = client_no_auth
|
||||
.sign_event(EventBuilder::new(Kind::TextNote, "hello", []))
|
||||
.expect("sign_event should succeed");
|
||||
|
||||
let has_auth_tag = event_no_auth
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.as_slice().first().map(|s| s.as_str()).unwrap_or("") == "auth");
|
||||
assert!(!has_auth_tag, "expected no auth tag when auth_tag is None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_event_rejects_duplicate_auth_tag() {
|
||||
let keys = Keys::generate();
|
||||
// Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "<conditions>", "<128-char-hex-sig>"]
|
||||
let owner_pubkey = "c".repeat(64);
|
||||
let conditions = "";
|
||||
let signature = "d".repeat(128);
|
||||
let auth_tag = nostr::Tag::parse(&["auth", &owner_pubkey, conditions, &signature]).unwrap();
|
||||
|
||||
// Case 1: client has auth_tag configured, caller also pre-adds one → duplicate → reject.
|
||||
let client = make_client(keys.clone(), Some(auth_tag.clone()));
|
||||
let builder = EventBuilder::new(Kind::TextNote, "oops", [auth_tag.clone()]);
|
||||
let result = client.sign_event(builder);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"sign_event should return an error when the event would have duplicate auth tags"
|
||||
);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("auth tags"),
|
||||
"error message should mention auth tags; got: {err_msg}"
|
||||
);
|
||||
|
||||
// Case 2: client has NO auth_tag configured, but caller manually adds one → bypass → reject.
|
||||
let client_no_auth = make_client(keys, None);
|
||||
let builder_with_manual = EventBuilder::new(Kind::TextNote, "bypass", [auth_tag]);
|
||||
let result2 = client_no_auth.sign_event(builder_with_manual);
|
||||
assert!(
|
||||
result2.is_err(),
|
||||
"sign_event should reject a manually added auth tag even when auth_tag is None"
|
||||
);
|
||||
let err_msg2 = result2.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg2.contains("auth tags"),
|
||||
"error message should mention auth tags; got: {err_msg2}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── send_event auth tag validation ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_event_rejects_forged_auth_tag() {
|
||||
let keys = Keys::generate();
|
||||
let real_tag = nostr::Tag::parse(&["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap();
|
||||
let forged_tag =
|
||||
nostr::Tag::parse(&["auth", &"c".repeat(64), "", &"d".repeat(128)]).unwrap();
|
||||
|
||||
let client = make_client(keys.clone(), Some(real_tag));
|
||||
|
||||
// Build event with forged auth tag, bypassing sign_event
|
||||
let event = EventBuilder::new(Kind::TextNote, "forged", [forged_tag])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let result = client.send_event(event).await;
|
||||
assert!(result.is_err(), "send_event should reject forged auth tag");
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("does not match"),
|
||||
"error should mention mismatch: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_event_rejects_auth_tag_when_unconfigured() {
|
||||
let keys = Keys::generate();
|
||||
let sneaky_tag =
|
||||
nostr::Tag::parse(&["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap();
|
||||
|
||||
let client = make_client(keys.clone(), None);
|
||||
|
||||
let event = EventBuilder::new(Kind::TextNote, "sneaky", [sneaky_tag])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let result = client.send_event(event).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"send_event should reject auth tags when unconfigured"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_event_rejects_wrong_pubkey() {
|
||||
let client_keys = Keys::generate();
|
||||
let other_keys = Keys::generate();
|
||||
let client = make_client(client_keys, None);
|
||||
|
||||
// Event signed by a different keypair
|
||||
let event = EventBuilder::new(Kind::TextNote, "wrong author", [])
|
||||
.sign_with_keys(&other_keys)
|
||||
.unwrap();
|
||||
|
||||
let result = client.send_event(event).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"send_event should reject events from wrong pubkey"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Integration tests: mini relay ─────────────────────────────────────────
|
||||
//
|
||||
// Each test spins up a lightweight in-process WebSocket server that performs
|
||||
@@ -1296,7 +1545,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
// Give the background task a moment to process the Ping.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let _ = client.close().await;
|
||||
@@ -1337,7 +1586,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let _ = client.close().await;
|
||||
}
|
||||
@@ -1366,7 +1615,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(9), "test", [])
|
||||
.sign_with_keys(&keys)
|
||||
@@ -1418,7 +1667,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
|
||||
let events = client
|
||||
.subscribe("sub-1", vec![Filter::new()])
|
||||
@@ -1444,7 +1693,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(9), "timeout-test", [])
|
||||
.sign_with_keys(&keys)
|
||||
@@ -1517,7 +1766,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
|
||||
// Subscribe (EOSE comes back immediately).
|
||||
client
|
||||
@@ -1634,7 +1883,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
|
||||
// Wait for the second relay to complete auth (background task reconnected).
|
||||
tokio::time::timeout(Duration::from_secs(5), ok_rx)
|
||||
@@ -1697,7 +1946,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let keys = Keys::generate();
|
||||
let client = RelayClient::connect(&url, &keys, None).await.unwrap();
|
||||
let client = RelayClient::connect(&url, &keys, None, None).await.unwrap();
|
||||
|
||||
// Give the background task time to notice the close and enter
|
||||
// the reconnect backoff loop.
|
||||
|
||||
@@ -1001,7 +1001,7 @@ Default kind is 9 (stream message)."
|
||||
}
|
||||
};
|
||||
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign message event: {e}"),
|
||||
};
|
||||
@@ -1115,7 +1115,7 @@ The diff is rendered with GitHub-quality visualization in the desktop client."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign diff event: {e}"),
|
||||
};
|
||||
@@ -1166,7 +1166,7 @@ The diff is rendered with GitHub-quality visualization in the desktop client."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign edit event: {e}"),
|
||||
};
|
||||
@@ -1233,7 +1233,7 @@ The diff is rendered with GitHub-quality visualization in the desktop client."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign delete event: {e}"),
|
||||
};
|
||||
@@ -1349,7 +1349,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign create_channel event: {e}"),
|
||||
};
|
||||
@@ -1407,7 +1407,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign set_canvas event: {e}"),
|
||||
};
|
||||
@@ -1643,7 +1643,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign add_member event: {e}"),
|
||||
};
|
||||
@@ -1678,7 +1678,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign remove_member event: {e}"),
|
||||
};
|
||||
@@ -1732,7 +1732,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign join event: {e}"),
|
||||
};
|
||||
@@ -1764,7 +1764,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign leave event: {e}"),
|
||||
};
|
||||
@@ -1821,7 +1821,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign update_channel event: {e}"),
|
||||
};
|
||||
@@ -1856,7 +1856,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign set_topic event: {e}"),
|
||||
};
|
||||
@@ -1891,7 +1891,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign set_purpose event: {e}"),
|
||||
};
|
||||
@@ -1923,7 +1923,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign archive event: {e}"),
|
||||
};
|
||||
@@ -1958,7 +1958,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign unarchive event: {e}"),
|
||||
};
|
||||
@@ -2117,7 +2117,7 @@ with kind:45003 comments)."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign reaction event: {e}"),
|
||||
};
|
||||
@@ -2140,7 +2140,7 @@ with kind:45003 comments)."
|
||||
pub async fn remove_reaction(&self, Parameters(p): Parameters<RemoveReactionParams>) -> String {
|
||||
// Fetch the reactions list to find the current user's reaction event ID for this emoji.
|
||||
let encoded_event_id = percent_encode(&p.event_id);
|
||||
let my_pubkey = self.client.keys().public_key().to_hex();
|
||||
let my_pubkey = self.client.pubkey_hex();
|
||||
let reactions_resp = match self
|
||||
.client
|
||||
.get(&format!("/api/messages/{}/reactions", encoded_event_id))
|
||||
@@ -2186,7 +2186,7 @@ with kind:45003 comments)."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign remove_reaction event: {e}"),
|
||||
};
|
||||
@@ -2262,7 +2262,7 @@ with kind:45003 comments)."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign profile event: {e}"),
|
||||
};
|
||||
@@ -2415,7 +2415,7 @@ with kind:45003 comments)."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign vote event: {e}"),
|
||||
};
|
||||
@@ -2447,7 +2447,7 @@ with kind:45003 comments)."
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign delete_channel event: {e}"),
|
||||
};
|
||||
@@ -2490,7 +2490,7 @@ with kind:45003 comments)."
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign event: {e}"),
|
||||
};
|
||||
@@ -2532,7 +2532,7 @@ with kind:45003 comments)."
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
};
|
||||
|
||||
let event = match builder.sign_with_keys(self.client.keys()) {
|
||||
let event = match self.client.sign_event(builder) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error: failed to sign event: {e}"),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! No keys are held here. No network calls are made.
|
||||
|
||||
pub mod builders;
|
||||
pub mod nip_oa;
|
||||
|
||||
pub use builders::*;
|
||||
|
||||
|
||||
601
crates/sprout-sdk/src/nip_oa.rs
Normal file
601
crates/sprout-sdk/src/nip_oa.rs
Normal file
@@ -0,0 +1,601 @@
|
||||
//! NIP-OA — Owner Attestation
|
||||
//!
|
||||
//! Computes and verifies `auth` tags that prove an owner key authorized
|
||||
//! an agent key to publish events under the agent's own authorship.
|
||||
//!
|
||||
//! # Tag format
|
||||
//!
|
||||
//! ```json
|
||||
//! ["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
|
||||
//! ```
|
||||
//!
|
||||
//! # Signing preimage
|
||||
//!
|
||||
//! ```text
|
||||
//! preimage = "nostr:agent-auth:" || agent_pubkey_hex || ":" || conditions
|
||||
//! message = SHA256(preimage)
|
||||
//! sig = BIP-340 Schnorr(message, owner_secret_key)
|
||||
//! ```
|
||||
|
||||
use core::str::FromStr;
|
||||
|
||||
use nostr::bitcoin::hashes::sha256::Hash as Sha256Hash;
|
||||
use nostr::bitcoin::hashes::Hash;
|
||||
use nostr::bitcoin::secp256k1::schnorr::Signature;
|
||||
use nostr::bitcoin::secp256k1::{Message, XOnlyPublicKey};
|
||||
use nostr::{Keys, PublicKey, Tag, SECP256K1};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::SdkError;
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate the `conditions` string per the NIP-OA spec.
|
||||
///
|
||||
/// Empty string is valid. Non-empty must be `clause` or `clause&clause&...`
|
||||
/// where each clause is `kind=<0-65535>`, `created_at<<0-4294967295>`, or
|
||||
/// `created_at><0-4294967295>`. Canonical decimals only (no leading zeros).
|
||||
fn validate_conditions(conditions: &str) -> Result<(), SdkError> {
|
||||
if conditions.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No whitespace anywhere
|
||||
if conditions.bytes().any(|b| b.is_ascii_whitespace()) {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"conditions must not contain whitespace".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Split on '&' — each part must be non-empty and a valid clause
|
||||
for clause in conditions.split('&') {
|
||||
if clause.is_empty() {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"empty clause in conditions (leading/trailing/double '&')".into(),
|
||||
));
|
||||
}
|
||||
validate_clause(clause)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_clause(clause: &str) -> Result<(), SdkError> {
|
||||
if let Some(value) = clause.strip_prefix("kind=") {
|
||||
validate_canonical_decimal(value, 0, 65535, "kind")
|
||||
} else if let Some(value) = clause.strip_prefix("created_at<") {
|
||||
validate_canonical_decimal(value, 0, 4294967295, "created_at<")
|
||||
} else if let Some(value) = clause.strip_prefix("created_at>") {
|
||||
validate_canonical_decimal(value, 0, 4294967295, "created_at>")
|
||||
} else {
|
||||
Err(SdkError::InvalidInput(format!(
|
||||
"unsupported clause: {clause:?}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_canonical_decimal(s: &str, min: u64, max: u64, label: &str) -> Result<(), SdkError> {
|
||||
if s.is_empty() {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value must not be empty"
|
||||
)));
|
||||
}
|
||||
|
||||
// No leading zeros except "0" itself
|
||||
if s.len() > 1 && s.starts_with('0') {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value has leading zero: {s:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Must be all digits
|
||||
if !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value is not a valid decimal: {s:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
let value: u64 = s
|
||||
.parse()
|
||||
.map_err(|e| SdkError::InvalidInput(format!("{label} value out of range: {e}")))?;
|
||||
|
||||
if value < min || value > max {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"{label} value {value} out of range [{min}, {max}]"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_preimage(agent_pubkey: &PublicKey, conditions: &str) -> String {
|
||||
format!("nostr:agent-auth:{}:{}", agent_pubkey.to_hex(), conditions)
|
||||
}
|
||||
|
||||
fn hash_preimage(preimage: &str) -> Message {
|
||||
let digest = Sha256Hash::hash(preimage.as_bytes());
|
||||
Message::from_digest(digest.to_byte_array())
|
||||
}
|
||||
|
||||
/// Check that a character is a lowercase hex digit (`0-9`, `a-f`).
|
||||
/// Nostr convention requires lowercase hex for pubkeys and signatures.
|
||||
fn is_lowercase_hex(c: char) -> bool {
|
||||
c.is_ascii_digit() || matches!(c, 'a'..='f')
|
||||
}
|
||||
|
||||
fn parse_json_array(s: &str) -> Result<Vec<Value>, SdkError> {
|
||||
let v: Value = serde_json::from_str(s)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("invalid JSON: {e}")))?;
|
||||
match v {
|
||||
Value::Array(arr) => Ok(arr),
|
||||
_ => Err(SdkError::InvalidInput(
|
||||
"auth tag must be a JSON array".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`.
|
||||
///
|
||||
/// Signs the preimage with `owner_keys` using BIP-340 Schnorr.
|
||||
///
|
||||
/// Returns a JSON string of the form:
|
||||
/// `["auth","<owner_pubkey_hex>","<conditions>","<sig_hex>"]`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SdkError::InvalidInput`] if `owner_pubkey == agent_pubkey`
|
||||
/// (self-attestation is meaningless and rejected).
|
||||
pub fn compute_auth_tag(
|
||||
owner_keys: &Keys,
|
||||
agent_pubkey: &PublicKey,
|
||||
conditions: &str,
|
||||
) -> Result<String, SdkError> {
|
||||
let owner_pubkey = owner_keys.public_key();
|
||||
if owner_pubkey == *agent_pubkey {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"owner and agent pubkeys must differ (self-attestation rejected)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_conditions(conditions)?;
|
||||
|
||||
let preimage = build_preimage(agent_pubkey, conditions);
|
||||
let message = hash_preimage(&preimage);
|
||||
let sig = owner_keys.sign_schnorr(&message);
|
||||
|
||||
let tag_json = serde_json::json!(["auth", owner_pubkey.to_hex(), conditions, sig.to_string(),]);
|
||||
Ok(tag_json.to_string())
|
||||
}
|
||||
|
||||
/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`.
|
||||
///
|
||||
/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature
|
||||
/// against the owner pubkey embedded in the tag.
|
||||
///
|
||||
/// Returns the owner's [`PublicKey`] on success.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count,
|
||||
/// bad hex, self-attestation, or signature verification failure.
|
||||
pub fn verify_auth_tag(
|
||||
auth_tag_json: &str,
|
||||
agent_pubkey: &PublicKey,
|
||||
) -> Result<PublicKey, SdkError> {
|
||||
let arr = parse_json_array(auth_tag_json)?;
|
||||
|
||||
if arr.len() != 4 {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"auth tag must have 4 elements, got {}",
|
||||
arr.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let label = arr[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?;
|
||||
if label != "auth" {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"first element must be \"auth\", got \"{label}\""
|
||||
)));
|
||||
}
|
||||
|
||||
let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| {
|
||||
SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into())
|
||||
})?;
|
||||
let conditions = arr[2]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?;
|
||||
let sig_hex = arr[3]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?;
|
||||
|
||||
let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?;
|
||||
|
||||
validate_conditions(conditions)?;
|
||||
|
||||
if owner_pubkey == *agent_pubkey {
|
||||
return Err(SdkError::InvalidInput(
|
||||
"owner and agent pubkeys must differ (self-attestation rejected)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let sig = Signature::from_str(sig_hex)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?;
|
||||
|
||||
let preimage = build_preimage(agent_pubkey, conditions);
|
||||
let message = hash_preimage(&preimage);
|
||||
|
||||
let xonly: &XOnlyPublicKey = &owner_pubkey;
|
||||
SECP256K1
|
||||
.verify_schnorr(&sig, &message, xonly)
|
||||
.map_err(|e| SdkError::InvalidInput(format!("signature verification failed: {e}")))?;
|
||||
|
||||
Ok(owner_pubkey)
|
||||
}
|
||||
|
||||
/// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the
|
||||
/// signature.
|
||||
///
|
||||
/// Validates structure only:
|
||||
/// - Exactly 4 elements
|
||||
/// - First element is `"auth"`
|
||||
/// - Second element is a 64-character hex string (owner pubkey)
|
||||
/// - Fourth element is a 128-character hex string (signature)
|
||||
///
|
||||
/// This is the fast path used at MCP startup — no crypto is performed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`SdkError::InvalidInput`] for any structural violation.
|
||||
pub fn parse_auth_tag(json_str: &str) -> Result<Tag, SdkError> {
|
||||
let arr = parse_json_array(json_str)?;
|
||||
|
||||
if arr.len() != 4 {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"auth tag must have 4 elements, got {}",
|
||||
arr.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let label = arr[0]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?;
|
||||
if label != "auth" {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"first element must be \"auth\", got \"{label}\""
|
||||
)));
|
||||
}
|
||||
|
||||
let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| {
|
||||
SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into())
|
||||
})?;
|
||||
if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"owner pubkey must be 64 hex chars, got {:?}",
|
||||
owner_pubkey_hex
|
||||
)));
|
||||
}
|
||||
|
||||
let conditions = arr[2]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?;
|
||||
|
||||
validate_conditions(conditions)?;
|
||||
|
||||
let sig_hex = arr[3]
|
||||
.as_str()
|
||||
.ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?;
|
||||
if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) {
|
||||
return Err(SdkError::InvalidInput(format!(
|
||||
"signature must be 128 hex chars, got length {}",
|
||||
sig_hex.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Tag::parse(&["auth", owner_pubkey_hex, conditions, sig_hex])
|
||||
.map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}")))
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const OWNER_PUBKEY_HEX: &str =
|
||||
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
|
||||
const AGENT_PUBKEY_HEX: &str =
|
||||
"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
|
||||
const CONDITIONS: &str = "kind=1&created_at<1713957000";
|
||||
const SPEC_SIG_HEX: &str =
|
||||
"8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369";
|
||||
|
||||
/// Verify the spec's provided signature against the spec's known preimage.
|
||||
#[test]
|
||||
fn test_verify_spec_test_vector() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
let owner_pubkey = PublicKey::from_hex(OWNER_PUBKEY_HEX).unwrap();
|
||||
|
||||
let preimage = build_preimage(&agent_pubkey, CONDITIONS);
|
||||
assert_eq!(
|
||||
preimage,
|
||||
format!("nostr:agent-auth:{}:{}", AGENT_PUBKEY_HEX, CONDITIONS)
|
||||
);
|
||||
|
||||
let message = hash_preimage(&preimage);
|
||||
|
||||
let sig = Signature::from_str(SPEC_SIG_HEX).expect("spec sig must parse");
|
||||
let xonly: &XOnlyPublicKey = &owner_pubkey;
|
||||
SECP256K1
|
||||
.verify_schnorr(&sig, &message, xonly)
|
||||
.expect("spec test vector signature must verify");
|
||||
}
|
||||
|
||||
/// Sign with generated keys, then verify — round-trip without byte comparison.
|
||||
#[test]
|
||||
fn test_sign_then_verify_round_trip() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
|
||||
let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "kind=9")
|
||||
.expect("compute_auth_tag must succeed");
|
||||
|
||||
let recovered =
|
||||
verify_auth_tag(&tag_json, &agent_pubkey).expect("verify_auth_tag must succeed");
|
||||
|
||||
assert_eq!(recovered, owner_keys.public_key());
|
||||
}
|
||||
|
||||
/// Empty conditions string is valid.
|
||||
#[test]
|
||||
fn test_empty_conditions() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
|
||||
let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "")
|
||||
.expect("empty conditions must succeed");
|
||||
|
||||
let recovered = verify_auth_tag(&tag_json, &agent_pubkey)
|
||||
.expect("verify with empty conditions must succeed");
|
||||
|
||||
assert_eq!(recovered, owner_keys.public_key());
|
||||
}
|
||||
|
||||
/// Self-attestation (owner == agent) must be rejected at both sign and verify.
|
||||
#[test]
|
||||
fn test_reject_self_attestation() {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key();
|
||||
|
||||
let err = compute_auth_tag(&keys, &pubkey, "kind=9")
|
||||
.expect_err("self-attestation must be rejected");
|
||||
assert!(
|
||||
matches!(err, SdkError::InvalidInput(_)),
|
||||
"expected InvalidInput, got {err:?}"
|
||||
);
|
||||
|
||||
// Craft a self-attesting tag and verify it's rejected.
|
||||
let fake_json =
|
||||
serde_json::json!(["auth", pubkey.to_hex(), "kind=9", "a".repeat(128),]).to_string();
|
||||
let err = verify_auth_tag(&fake_json, &pubkey)
|
||||
.expect_err("self-attestation must be rejected at verify");
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
/// Various malformed inputs to verify_auth_tag must return errors.
|
||||
#[test]
|
||||
fn test_reject_malformed_tag() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
|
||||
assert!(verify_auth_tag("not json", &agent_pubkey).is_err());
|
||||
assert!(verify_auth_tag(r#"{"auth":"x"}"#, &agent_pubkey).is_err());
|
||||
assert!(verify_auth_tag(r#"["auth","a","b"]"#, &agent_pubkey).is_err());
|
||||
|
||||
let bad_label =
|
||||
serde_json::json!(["notauth", OWNER_PUBKEY_HEX, CONDITIONS, "a".repeat(128)])
|
||||
.to_string();
|
||||
assert!(verify_auth_tag(&bad_label, &agent_pubkey).is_err());
|
||||
|
||||
let bad_pk =
|
||||
serde_json::json!(["auth", "notahex", CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(verify_auth_tag(&bad_pk, &agent_pubkey).is_err());
|
||||
|
||||
let bad_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "notasig"]).to_string();
|
||||
assert!(verify_auth_tag(&bad_sig, &agent_pubkey).is_err());
|
||||
|
||||
// Valid structure but wrong signature (won't verify).
|
||||
let wrong_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "0".repeat(128)]).to_string();
|
||||
assert!(verify_auth_tag(&wrong_sig, &agent_pubkey).is_err());
|
||||
}
|
||||
|
||||
/// parse_auth_tag with a well-formed JSON array returns a Tag.
|
||||
#[test]
|
||||
fn test_parse_auth_tag_valid() {
|
||||
let sig_hex = "a".repeat(128);
|
||||
let json = serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, sig_hex,]).to_string();
|
||||
|
||||
let tag = parse_auth_tag(&json).expect("valid tag must parse");
|
||||
let slice = tag.as_slice();
|
||||
assert_eq!(slice[0], "auth");
|
||||
assert_eq!(slice[1], OWNER_PUBKEY_HEX);
|
||||
assert_eq!(slice[2], CONDITIONS);
|
||||
assert_eq!(slice[3], "a".repeat(128));
|
||||
}
|
||||
|
||||
/// Various malformed inputs to parse_auth_tag must return errors.
|
||||
#[test]
|
||||
fn test_parse_auth_tag_malformed() {
|
||||
assert!(parse_auth_tag("not json").is_err());
|
||||
assert!(parse_auth_tag(r#"{"auth":"x"}"#).is_err());
|
||||
assert!(parse_auth_tag(r#"["auth","a","b"]"#).is_err());
|
||||
|
||||
let bad_label =
|
||||
serde_json::json!(["notauth", OWNER_PUBKEY_HEX, CONDITIONS, "a".repeat(128)])
|
||||
.to_string();
|
||||
assert!(parse_auth_tag(&bad_label).is_err());
|
||||
|
||||
let short_pk = serde_json::json!(["auth", "abcd", CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&short_pk).is_err());
|
||||
|
||||
let non_hex_pk =
|
||||
serde_json::json!(["auth", "z".repeat(64), CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&non_hex_pk).is_err());
|
||||
|
||||
let short_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "abcd"]).to_string();
|
||||
assert!(parse_auth_tag(&short_sig).is_err());
|
||||
|
||||
let non_hex_sig =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "z".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&non_hex_sig).is_err());
|
||||
}
|
||||
|
||||
/// Uppercase hex must be rejected — Nostr convention is lowercase only.
|
||||
#[test]
|
||||
fn test_parse_auth_tag_rejects_uppercase_hex() {
|
||||
// Uppercase in owner pubkey
|
||||
let upper_pk = OWNER_PUBKEY_HEX.to_uppercase();
|
||||
let json = serde_json::json!(["auth", upper_pk, CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(
|
||||
parse_auth_tag(&json).is_err(),
|
||||
"uppercase owner pubkey must be rejected"
|
||||
);
|
||||
|
||||
// Mixed case in owner pubkey
|
||||
let mut mixed_pk = OWNER_PUBKEY_HEX.to_string();
|
||||
mixed_pk.replace_range(0..1, "A");
|
||||
let json = serde_json::json!(["auth", mixed_pk, CONDITIONS, "a".repeat(128)]).to_string();
|
||||
assert!(
|
||||
parse_auth_tag(&json).is_err(),
|
||||
"mixed-case owner pubkey must be rejected"
|
||||
);
|
||||
|
||||
// Uppercase in signature
|
||||
let json =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "A".repeat(128)]).to_string();
|
||||
assert!(
|
||||
parse_auth_tag(&json).is_err(),
|
||||
"uppercase signature must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify the spec's SHA-256 hash of the preimage matches the expected value.
|
||||
#[test]
|
||||
fn test_spec_sha256_hash() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
let preimage = build_preimage(&agent_pubkey, CONDITIONS);
|
||||
let digest = Sha256Hash::hash(preimage.as_bytes());
|
||||
let expected = "08cdecd55af4c28d3801fd69615dcf5cc04fab3bc134b38a840bf157197069a6";
|
||||
assert_eq!(format!("{digest:x}"), expected);
|
||||
}
|
||||
|
||||
// ── Conditions validation ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_valid_conditions() {
|
||||
// These should all pass through validate_conditions
|
||||
assert!(validate_conditions("").is_ok());
|
||||
assert!(validate_conditions("kind=1").is_ok());
|
||||
assert!(validate_conditions("kind=0").is_ok());
|
||||
assert!(validate_conditions("kind=65535").is_ok());
|
||||
assert!(validate_conditions("created_at<1713957000").is_ok());
|
||||
assert!(validate_conditions("created_at>0").is_ok());
|
||||
assert!(validate_conditions("created_at>4294967295").is_ok());
|
||||
assert!(validate_conditions("kind=1&created_at<1713957000").is_ok());
|
||||
assert!(validate_conditions("kind=9&created_at>100&created_at<200").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_trailing_ampersand() {
|
||||
let err = validate_conditions("kind=1&").unwrap_err();
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_leading_ampersand() {
|
||||
assert!(validate_conditions("&kind=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_double_ampersand() {
|
||||
assert!(validate_conditions("kind=1&&created_at<100").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_leading_zero() {
|
||||
assert!(validate_conditions("kind=01").is_err());
|
||||
assert!(validate_conditions("created_at<01713957000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_whitespace() {
|
||||
assert!(validate_conditions("kind=1 ").is_err());
|
||||
assert!(validate_conditions(" kind=1").is_err());
|
||||
assert!(validate_conditions("kind= 1").is_err());
|
||||
assert!(validate_conditions("kind=1\t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_unknown_clause() {
|
||||
assert!(validate_conditions("foo=1").is_err());
|
||||
assert!(validate_conditions("Kind=1").is_err()); // case-sensitive
|
||||
assert!(validate_conditions("CREATED_AT<100").is_err());
|
||||
assert!(validate_conditions("created_at=100").is_err()); // wrong operator
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_out_of_range() {
|
||||
assert!(validate_conditions("kind=65536").is_err());
|
||||
assert!(validate_conditions("created_at<4294967296").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_non_decimal() {
|
||||
assert!(validate_conditions("kind=abc").is_err());
|
||||
assert!(validate_conditions("kind=-1").is_err());
|
||||
assert!(validate_conditions("kind=1.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_empty_value() {
|
||||
assert!(validate_conditions("kind=").is_err());
|
||||
assert!(validate_conditions("created_at<").is_err());
|
||||
assert!(validate_conditions("created_at>").is_err());
|
||||
}
|
||||
|
||||
// Verify conditions validation is wired into compute and verify
|
||||
#[test]
|
||||
fn test_compute_rejects_invalid_conditions() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let err = compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=01")
|
||||
.expect_err("leading zero must be rejected");
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_rejects_invalid_conditions() {
|
||||
let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap();
|
||||
// Craft a tag with invalid conditions but valid structure
|
||||
let bad_conditions =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=01", "a".repeat(128)]).to_string();
|
||||
let err = verify_auth_tag(&bad_conditions, &agent_pubkey)
|
||||
.expect_err("leading zero must be rejected at verify");
|
||||
assert!(matches!(err, SdkError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rejects_invalid_conditions() {
|
||||
let bad =
|
||||
serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=1&", "a".repeat(128)]).to_string();
|
||||
assert!(parse_auth_tag(&bad).is_err());
|
||||
}
|
||||
}
|
||||
@@ -336,8 +336,8 @@ async fn test_mcp_initialize_and_list_tools() {
|
||||
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
44,
|
||||
"expected exactly 44 tools, got {}. Tools: {:?}",
|
||||
49,
|
||||
"expected exactly 49 tools, got {}. Tools: {:?}",
|
||||
tools.len(),
|
||||
tools
|
||||
.iter()
|
||||
|
||||
@@ -44,7 +44,7 @@ const overrides = new Map([
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission
|
||||
["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
["src-tauri/src/commands/agents.rs", 880], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field
|
||||
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
|
||||
["src-tauri/src/managed_agents/runtime.rs", 700], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation
|
||||
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
|
||||
["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh
|
||||
@@ -65,6 +65,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/huddle/preprocessing.rs", 670], // TTS text preprocessing pipeline + unified split_sentences + int_to_words 0-999999 + URL trailing punctuation preservation + 23 unit tests
|
||||
["src-tauri/src/huddle/relay_api.rs", 510], // audio relay recv task + per-peer frame counting for remote human TTS interrupt
|
||||
["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism
|
||||
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
|
||||
["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers
|
||||
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
|
||||
["src/shared/api/tauri.ts", 1125], // +14 lines for 3 NIP-AB pairing command wrappers + applyWorkspace
|
||||
|
||||
13
desktop/src-tauri/Cargo.lock
generated
13
desktop/src-tauri/Cargo.lock
generated
@@ -5302,6 +5302,7 @@ dependencies = [
|
||||
"sherpa-onnx",
|
||||
"sprout-core",
|
||||
"sprout-persona",
|
||||
"sprout-sdk",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -5352,6 +5353,18 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nostr 0.36.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sprout-core",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -49,6 +49,7 @@ reqwest = { version = "0.13", features = ["json", "query", "stream"] }
|
||||
url = "2"
|
||||
sprout-core = { path = "../../crates/sprout-core" }
|
||||
sprout-persona = { path = "../../crates/sprout-persona" }
|
||||
sprout-sdk = { path = "../../crates/sprout-sdk" }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.11"
|
||||
tar = "0.4"
|
||||
|
||||
@@ -187,7 +187,15 @@ pub async fn update_managed_agent(
|
||||
let api_token = record.api_token.clone();
|
||||
let display_name = record.name.clone();
|
||||
let avatar_url = managed_agent_avatar_url(&record.agent_command);
|
||||
Some((agent_keys, relay_url, api_token, display_name, avatar_url))
|
||||
let auth_tag = record.auth_tag.clone();
|
||||
Some((
|
||||
agent_keys,
|
||||
relay_url,
|
||||
api_token,
|
||||
display_name,
|
||||
avatar_url,
|
||||
auth_tag,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -198,7 +206,9 @@ pub async fn update_managed_agent(
|
||||
|
||||
// Phase 2: relay profile sync (async, best-effort, outside lock)
|
||||
let profile_sync_error =
|
||||
if let Some((agent_keys, relay_url, api_token, display_name, avatar_url)) = sync_params {
|
||||
if let Some((agent_keys, relay_url, api_token, display_name, avatar_url, auth_tag)) =
|
||||
sync_params
|
||||
{
|
||||
match sync_managed_agent_profile(
|
||||
&state,
|
||||
&relay_url,
|
||||
@@ -207,6 +217,7 @@ pub async fn update_managed_agent(
|
||||
&[],
|
||||
&display_name,
|
||||
avatar_url.as_deref(),
|
||||
auth_tag.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use nostr::{Keys, ToBech32};
|
||||
use nostr_compat;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
@@ -26,6 +27,7 @@ fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value {
|
||||
"name": &record.name,
|
||||
"relay_url": &record.relay_url,
|
||||
"private_key_nsec": &record.private_key_nsec,
|
||||
"auth_tag": &record.auth_tag,
|
||||
"api_token": &record.api_token,
|
||||
"agent_command": &record.agent_command,
|
||||
"agent_args": &record.agent_args,
|
||||
@@ -267,12 +269,20 @@ pub async fn create_managed_agent(
|
||||
resolve_provider_binary(id)?;
|
||||
}
|
||||
|
||||
// ── Phase 2: mint token via REST API (async, outside lock) ───────────────
|
||||
// Pass the desktop user's pubkey as the agent owner so the relay records
|
||||
// the ownership chain. Only NIP-98 bootstrap mints can set owner_pubkey.
|
||||
let user_pubkey_hex = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
keys.public_key().to_hex()
|
||||
// ── Phase 2: mint token (async, outside lock) ──────────────────────────
|
||||
// Snapshot owner identity once for both token mint and NIP-OA attestation.
|
||||
// during the async mint. Fail closed: bad auth tag → don't create agent.
|
||||
let (user_pubkey_hex, auth_tag) = {
|
||||
let owner_keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let pubkey_hex = owner_keys.public_key().to_hex();
|
||||
// Bridge nostr 0.37 → 0.36 (sprout-sdk) via hex round-trip.
|
||||
let compat_owner = nostr_compat::Keys::parse(&owner_keys.secret_key().to_secret_hex())
|
||||
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
|
||||
let compat_agent = nostr_compat::PublicKey::from_hex(&agent_keys.public_key().to_hex())
|
||||
.map_err(|e| format!("failed to bridge agent pubkey: {e}"))?;
|
||||
let tag = sprout_sdk::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "")
|
||||
.map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?;
|
||||
(pubkey_hex, Some(tag))
|
||||
};
|
||||
let api_token: Option<String> = if mint_token {
|
||||
let token = mint_token_via_api(
|
||||
@@ -289,9 +299,6 @@ pub async fn create_managed_agent(
|
||||
None
|
||||
};
|
||||
|
||||
// Agent ownership is set atomically during token mint via owner_pubkey
|
||||
// in the request body — no separate API call needed.
|
||||
|
||||
// ── Phase 3: save record and optionally spawn (sync lock) ─────────────────
|
||||
let (agent, spawn_error) = {
|
||||
let _store_guard = state
|
||||
@@ -361,6 +368,7 @@ pub async fn create_managed_agent(
|
||||
name: name.clone(),
|
||||
persona_id: requested_persona_id.clone(),
|
||||
private_key_nsec: private_key_nsec.clone(),
|
||||
auth_tag: auth_tag.clone(),
|
||||
api_token: api_token.clone(),
|
||||
relay_url: resolved_relay_url.clone(),
|
||||
acp_command: input
|
||||
@@ -408,9 +416,7 @@ pub async fn create_managed_agent(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
// Provider agents don't auto-start with the desktop — they're
|
||||
// managed externally. Force false to avoid persisting a flag the
|
||||
// app will never honor.
|
||||
// Provider agents are managed externally — force false.
|
||||
start_on_app_launch: if input.backend != BackendKind::Local {
|
||||
false
|
||||
} else {
|
||||
@@ -420,13 +426,9 @@ pub async fn create_managed_agent(
|
||||
backend: input.backend.clone(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path,
|
||||
// If the persona came from a pack, record the installed pack path
|
||||
// and the persona's internal name within the pack so the runtime
|
||||
// can resolve pack-specific config at startup via ACP.
|
||||
//
|
||||
// Important: persona_name_in_pack must be the internal `name` slug
|
||||
// (e.g., "lep"), NOT the display_name (e.g., "Lep"). ACP's
|
||||
// resolve_persona_by_name() matches on the internal name.
|
||||
// Pack-backed personas: record path + internal slug so the runtime
|
||||
// can resolve pack config at startup. Must be the slug (e.g., "lep"),
|
||||
// NOT the display_name — ACP's resolve_persona_by_name() matches slugs.
|
||||
persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()),
|
||||
persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()),
|
||||
created_at: now_iso(),
|
||||
@@ -475,6 +477,7 @@ pub async fn create_managed_agent(
|
||||
&token_scopes,
|
||||
&name,
|
||||
avatar_url.as_deref(),
|
||||
auth_tag.as_deref(),
|
||||
)
|
||||
.await)
|
||||
.err();
|
||||
|
||||
@@ -555,6 +555,12 @@ pub fn spawn_agent_child(
|
||||
command.env_remove("SPROUT_ACP_PRIVATE_KEY");
|
||||
command.env_remove("SPROUT_ACP_API_TOKEN");
|
||||
|
||||
if let Some(ref auth_tag) = record.auth_tag {
|
||||
command.env("SPROUT_AUTH_TAG", auth_tag);
|
||||
} else {
|
||||
command.env_remove("SPROUT_AUTH_TAG");
|
||||
}
|
||||
|
||||
if let Some(token) = &record.api_token {
|
||||
command.env("SPROUT_API_TOKEN", token);
|
||||
} else {
|
||||
|
||||
@@ -67,6 +67,13 @@ pub struct ManagedAgentRecord {
|
||||
#[serde(default)]
|
||||
pub persona_id: Option<String>,
|
||||
pub private_key_nsec: String,
|
||||
/// NIP-OA auth tag JSON. Computed at agent creation time.
|
||||
///
|
||||
/// Pre-existing agents created before NIP-OA will have `None` here.
|
||||
/// This is intentional — they continue to work without attestation.
|
||||
/// Re-attestation requires agent recreation (v2 migration scope).
|
||||
#[serde(default)]
|
||||
pub auth_tag: Option<String>,
|
||||
pub api_token: Option<String>,
|
||||
pub relay_url: String,
|
||||
pub acp_command: String,
|
||||
@@ -395,7 +402,7 @@ fn default_record_active() -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PersonaRecord;
|
||||
use super::{ManagedAgentRecord, PersonaRecord};
|
||||
|
||||
#[test]
|
||||
fn persona_record_defaults_active_when_field_is_missing() {
|
||||
@@ -417,4 +424,74 @@ mod tests {
|
||||
assert_eq!(record.model, None);
|
||||
assert!(record.name_pool.is_empty());
|
||||
}
|
||||
|
||||
/// Legacy agent records (created before NIP-OA) lack the `auth_tag` field.
|
||||
/// `#[serde(default)]` must ensure they deserialize with `auth_tag: None`.
|
||||
#[test]
|
||||
fn managed_agent_record_without_auth_tag_deserializes() {
|
||||
let record: ManagedAgentRecord = serde_json::from_str(
|
||||
r#"{
|
||||
"pubkey": "abcd1234",
|
||||
"name": "test-agent",
|
||||
"private_key_nsec": "nsec1fake",
|
||||
"api_token": "sprt_tok_fake",
|
||||
"relay_url": "wss://localhost:3000",
|
||||
"acp_command": "sprout-acp",
|
||||
"agent_command": "goose",
|
||||
"agent_args": [],
|
||||
"mcp_command": "sprout-mcp-server",
|
||||
"turn_timeout_seconds": 320,
|
||||
"system_prompt": null,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"last_started_at": null,
|
||||
"last_stopped_at": null,
|
||||
"last_exit_code": null,
|
||||
"last_error": null
|
||||
}"#,
|
||||
)
|
||||
.expect("legacy agent record without auth_tag should deserialize");
|
||||
|
||||
assert_eq!(record.auth_tag, None);
|
||||
assert_eq!(record.pubkey, "abcd1234");
|
||||
}
|
||||
|
||||
/// Agent records WITH an auth_tag round-trip correctly through serde.
|
||||
#[test]
|
||||
fn managed_agent_record_with_auth_tag_round_trips() {
|
||||
let json = r#"{
|
||||
"pubkey": "abcd1234",
|
||||
"name": "test-agent",
|
||||
"private_key_nsec": "nsec1fake",
|
||||
"auth_tag": "[\"auth\",\"deadbeef\",\"\",\"cafebabe\"]",
|
||||
"api_token": "sprt_tok_fake",
|
||||
"relay_url": "wss://localhost:3000",
|
||||
"acp_command": "sprout-acp",
|
||||
"agent_command": "goose",
|
||||
"agent_args": [],
|
||||
"mcp_command": "sprout-mcp-server",
|
||||
"turn_timeout_seconds": 320,
|
||||
"system_prompt": null,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"last_started_at": null,
|
||||
"last_stopped_at": null,
|
||||
"last_exit_code": null,
|
||||
"last_error": null
|
||||
}"#;
|
||||
|
||||
let record: ManagedAgentRecord =
|
||||
serde_json::from_str(json).expect("record with auth_tag should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
record.auth_tag.as_deref(),
|
||||
Some(r#"["auth","deadbeef","","cafebabe"]"#)
|
||||
);
|
||||
|
||||
// Round-trip: serialize and deserialize again.
|
||||
let serialized = serde_json::to_string(&record).expect("should serialize");
|
||||
let record2: ManagedAgentRecord =
|
||||
serde_json::from_str(&serialized).expect("round-trip should deserialize");
|
||||
assert_eq!(record.auth_tag, record2.auth_tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// nostr 0.36 alias — required for cross-version bridging with sprout-sdk.
|
||||
use nostr_compat;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::util::percent_encode;
|
||||
|
||||
@@ -135,6 +138,47 @@ fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool {
|
||||
scopes.iter().any(|scope| scope == required_scope)
|
||||
}
|
||||
|
||||
/// Build a signed kind:0 profile event, optionally injecting a verified NIP-OA auth tag.
|
||||
///
|
||||
/// This is a pure function (no I/O) extracted from `sync_managed_agent_profile` so that
|
||||
/// the event-building and auth-tag-injection logic can be unit tested without HTTP calls.
|
||||
///
|
||||
/// `sprout-sdk` uses `nostr 0.36` while the desktop crate uses `nostr 0.37`. Cross-version
|
||||
/// bridging is done via hex-encoded public keys and raw tag slices — both versions share the
|
||||
/// same wire format.
|
||||
fn build_profile_event(
|
||||
agent_keys: &nostr::Keys,
|
||||
display_name: &str,
|
||||
avatar_url: Option<&str>,
|
||||
auth_tag_json: Option<&str>,
|
||||
) -> Result<nostr::Event, String> {
|
||||
let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?;
|
||||
|
||||
let builder = if let Some(tag_json) = auth_tag_json {
|
||||
// Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding.
|
||||
let agent_pubkey_hex = agent_keys.public_key().to_hex();
|
||||
let compat_pubkey = nostr_compat::PublicKey::from_hex(&agent_pubkey_hex)
|
||||
.map_err(|e| format!("failed to convert agent pubkey for auth verification: {e}"))?;
|
||||
|
||||
// Verify Schnorr signature before injecting into profile event.
|
||||
sprout_sdk::nip_oa::verify_auth_tag(tag_json, &compat_pubkey)
|
||||
.map_err(|e| format!("auth tag verification failed for profile event: {e}"))?;
|
||||
|
||||
// parse_auth_tag returns a nostr 0.36 Tag; bridge to nostr 0.37 via raw slice.
|
||||
let compat_tag = sprout_sdk::nip_oa::parse_auth_tag(tag_json)
|
||||
.map_err(|e| format!("failed to parse verified auth tag: {e}"))?;
|
||||
let tag = nostr::Tag::parse(compat_tag.as_slice())
|
||||
.map_err(|e| format!("failed to convert auth tag to nostr 0.37: {e}"))?;
|
||||
builder.tags([tag])
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
builder
|
||||
.sign_with_keys(agent_keys)
|
||||
.map_err(|e| format!("failed to sign profile event: {e}"))
|
||||
}
|
||||
|
||||
pub async fn sync_managed_agent_profile(
|
||||
state: &AppState,
|
||||
relay_url: &str,
|
||||
@@ -143,14 +187,10 @@ pub async fn sync_managed_agent_profile(
|
||||
token_scopes: &[String],
|
||||
display_name: &str,
|
||||
avatar_url: Option<&str>,
|
||||
auth_tag: Option<&str>, // NIP-OA auth tag JSON
|
||||
) -> Result<(), String> {
|
||||
// Build a kind:0 profile event signed by the agent's keys.
|
||||
let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?;
|
||||
|
||||
// Sign with the agent's keys (not the desktop user's).
|
||||
let event = builder
|
||||
.sign_with_keys(agent_keys)
|
||||
.map_err(|e| format!("failed to sign profile event: {e}"))?;
|
||||
// Build a signed kind:0 profile event (with optional NIP-OA auth tag).
|
||||
let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?;
|
||||
let event_json = event.as_json();
|
||||
|
||||
// POST to the relay's /api/events endpoint.
|
||||
@@ -308,7 +348,7 @@ pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(),
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{api_path, validate_api_path};
|
||||
use super::{api_path, build_profile_event, validate_api_path};
|
||||
|
||||
#[test]
|
||||
fn api_path_encodes_path_segments() {
|
||||
@@ -326,6 +366,72 @@ mod tests {
|
||||
fn validate_api_path_allows_encoded_segments() {
|
||||
assert!(validate_api_path("/api/tokens/..%2Fadmin").is_ok());
|
||||
}
|
||||
|
||||
// ── build_profile_event ──────────────────────────────────────────────────
|
||||
|
||||
/// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key
|
||||
/// and addressed to `agent_keys`.
|
||||
///
|
||||
/// Uses `nostr_compat` (nostr 0.36) for the owner keys because
|
||||
/// `sprout_sdk::nip_oa::compute_auth_tag` expects nostr 0.36 types.
|
||||
/// The agent pubkey is bridged via hex encoding.
|
||||
fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String {
|
||||
let owner_keys = nostr_compat::Keys::generate();
|
||||
let agent_pubkey_hex = agent_keys.public_key().to_hex();
|
||||
let agent_compat_pubkey = nostr_compat::PublicKey::from_hex(&agent_pubkey_hex)
|
||||
.expect("valid hex pubkey should parse");
|
||||
sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "")
|
||||
.expect("compute_auth_tag should not fail with distinct keys")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_event_with_valid_auth_tag() {
|
||||
let agent_keys = nostr::Keys::generate();
|
||||
let tag_json = make_valid_auth_tag(&agent_keys);
|
||||
let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json))
|
||||
.expect("should succeed with a valid auth tag");
|
||||
|
||||
// Exactly one "auth" tag must be present.
|
||||
let auth_tags: Vec<_> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
|
||||
.collect();
|
||||
assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag");
|
||||
|
||||
// Must be a kind:0 (Metadata) event.
|
||||
assert_eq!(event.kind, nostr::Kind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_event_without_auth_tag() {
|
||||
let agent_keys = nostr::Keys::generate();
|
||||
let event = build_profile_event(&agent_keys, "TestBot", None, None)
|
||||
.expect("should succeed without an auth tag");
|
||||
|
||||
// No "auth" tags should be present.
|
||||
let auth_tags: Vec<_> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"))
|
||||
.collect();
|
||||
assert_eq!(auth_tags.len(), 0, "expected no auth tags");
|
||||
|
||||
assert_eq!(event.kind, nostr::Kind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_event_rejects_invalid_auth_tag() {
|
||||
let agent_keys = nostr::Keys::generate();
|
||||
// Structurally valid JSON array but with a bogus signature — verification must fail.
|
||||
let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128));
|
||||
let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json));
|
||||
assert!(result.is_err(), "should reject an invalid auth tag");
|
||||
assert!(
|
||||
result.unwrap_err().contains("verification failed"),
|
||||
"error message should mention verification failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signed-event submission ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user