feat(nip-oa): Owner Attestation — cryptographic proof of agent authorization (#406)

This commit is contained in:
tlongwell-block
2026-04-28 09:29:15 -04:00
committed by GitHub
parent a9024c2c78
commit d1c84a621c
15 changed files with 1365 additions and 70 deletions

View File

@@ -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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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.

View File

@@ -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}"),
};

View File

@@ -13,6 +13,7 @@
//! No keys are held here. No network calls are made.
pub mod builders;
pub mod nip_oa;
pub use builders::*;

View 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());
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
{

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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 ──────────────────────────────────────────────────