feat(nip-oa): share strict exactly-one auth-tag extraction in buzz-sdk

Lift the relay's private extract_single_auth_tag_json into buzz_sdk::nip_oa
so relay and desktop enforce identical auth-tag structure (exactly one auth
tag, exactly four elements). The relay's identity-archive consent path now
calls the shared helper; the duplicated private copy is removed.

Adds unit tests for the happy path, ignoring non-auth tags, missing tag,
multiple auth tags, and wrong-arity auth tag.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-22 16:42:34 -07:00
parent 438aa6e4eb
commit 96d2d50cfd
2 changed files with 116 additions and 23 deletions

View File

@@ -248,7 +248,8 @@ async fn verify_owner_consent(
target_hex: &str,
actor_hex: &str,
) -> Result<(), String> {
let request_auth = extract_single_auth_tag_json(event)?;
let request_auth = buzz_sdk::nip_oa::extract_single_auth_tag_json(event)
.map_err(|e| e.to_string())?;
let request_owner = verify_auth_tag_owner(&request_auth, target_hex)
.map_err(|e| format!("invalid request auth tag: {e}"))?;
if request_owner != actor_hex {
@@ -278,7 +279,8 @@ async fn verify_owner_consent(
return Err("live kind:0 author did not match target".to_string());
}
let live_auth = extract_single_auth_tag_json(&profile.event)?;
let live_auth = buzz_sdk::nip_oa::extract_single_auth_tag_json(&profile.event)
.map_err(|e| e.to_string())?;
let live_owner = verify_auth_tag_owner(&live_auth, target_hex)
.map_err(|e| format!("invalid live kind:0 auth tag: {e}"))?;
if live_owner != actor_hex {
@@ -288,26 +290,6 @@ async fn verify_owner_consent(
Ok(())
}
fn extract_single_auth_tag_json(event: &Event) -> Result<String, String> {
let mut found: Option<Vec<String>> = None;
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) != Some("auth") {
continue;
}
if parts.len() != 4 {
return Err("auth tag must have exactly four elements".to_string());
}
if found.is_some() {
return Err("multiple auth tags".to_string());
}
found = Some(parts.iter().map(|s| s.to_string()).collect());
}
let parts = found.ok_or_else(|| "missing auth tag".to_string())?;
serde_json::to_string(&parts).map_err(|e| format!("failed to encode auth tag: {e}"))
}
fn verify_auth_tag_owner(auth_tag_json: &str, target_hex: &str) -> Result<String, String> {
let target_pubkey =
PublicKey::from_hex(target_hex).map_err(|e| format!("invalid target pubkey: {e}"))?;

View File

@@ -23,7 +23,7 @@ use nostr::hashes::sha256::Hash as Sha256Hash;
use nostr::hashes::Hash;
use nostr::secp256k1::schnorr::Signature;
use nostr::secp256k1::Message;
use nostr::{Keys, PublicKey, Tag, SECP256K1};
use nostr::{Event, Keys, PublicKey, Tag, SECP256K1};
use serde_json::Value;
use crate::SdkError;
@@ -302,6 +302,48 @@ pub fn parse_auth_tag(json_str: &str) -> Result<Tag, SdkError> {
.map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}")))
}
/// Extract exactly one well-formed NIP-OA `auth` tag from an event's tag list,
/// returning it as a JSON-encoded 4-element array string.
///
/// This is the shared, strict extractor used by both the relay (event ingress,
/// observer-frame delivery gate, identity-archive consent) and desktop's
/// ownership resolution, so they enforce identical structure:
///
/// - **Exactly one** `auth` tag must be present (zero or more than one is an error).
/// - That tag must have **exactly four** elements (`["auth", owner, conditions, sig]`).
///
/// No cryptographic verification is performed here — pass the returned JSON to
/// [`verify_auth_tag`] for that. Keeping extraction and verification separate
/// lets callers fail fast on malformed input before doing Schnorr work.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] if there are zero `auth` tags, more than
/// one `auth` tag, an `auth` tag without exactly four elements, or the result
/// cannot be JSON-encoded.
pub fn extract_single_auth_tag_json(event: &Event) -> Result<String, SdkError> {
let mut found: Option<Vec<String>> = None;
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) != Some("auth") {
continue;
}
if parts.len() != 4 {
return Err(SdkError::InvalidInput(
"auth tag must have exactly four elements".into(),
));
}
if found.is_some() {
return Err(SdkError::InvalidInput("multiple auth tags".into()));
}
found = Some(parts.iter().map(|s| s.to_string()).collect());
}
let parts = found.ok_or_else(|| SdkError::InvalidInput("missing auth tag".into()))?;
serde_json::to_string(&parts)
.map_err(|e| SdkError::InvalidInput(format!("failed to encode auth tag: {e}")))
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -600,4 +642,73 @@ mod tests {
serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=1&", "a".repeat(128)]).to_string();
assert!(parse_auth_tag(&bad).is_err());
}
// ── Single auth-tag extraction ────────────────────────────────────────
use nostr::{EventBuilder, Kind};
/// Build a signed kind:0 event carrying the given raw tags.
fn event_with_tags(tags: Vec<Tag>) -> Event {
let keys = Keys::generate();
EventBuilder::new(Kind::Custom(0), "{}")
.tags(tags)
.sign_with_keys(&keys)
.expect("event must sign")
}
fn auth_tag(owner: &str, conditions: &str, sig: &str) -> Tag {
Tag::parse(["auth", owner, conditions, sig]).expect("auth tag must parse")
}
#[test]
fn test_extract_single_auth_tag_happy_path() {
let sig = "a".repeat(128);
let event = event_with_tags(vec![auth_tag(OWNER_PUBKEY_HEX, "kind=0", &sig)]);
let json = extract_single_auth_tag_json(&event).expect("exactly one auth tag");
let parts: Vec<String> = serde_json::from_str(&json).expect("valid JSON array");
assert_eq!(parts, vec!["auth", OWNER_PUBKEY_HEX, "kind=0", &sig]);
}
#[test]
fn test_extract_single_auth_tag_ignores_other_tags() {
let sig = "b".repeat(128);
let event = event_with_tags(vec![
Tag::parse(["p", AGENT_PUBKEY_HEX]).unwrap(),
auth_tag(OWNER_PUBKEY_HEX, "", &sig),
Tag::parse(["t", "hashtag"]).unwrap(),
]);
let json = extract_single_auth_tag_json(&event).expect("exactly one auth tag");
assert!(json.contains(OWNER_PUBKEY_HEX));
}
#[test]
fn test_extract_missing_auth_tag_is_error() {
let event = event_with_tags(vec![Tag::parse(["p", AGENT_PUBKEY_HEX]).unwrap()]);
let err = extract_single_auth_tag_json(&event).expect_err("no auth tag must error");
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn test_extract_multiple_auth_tags_is_error() {
let sig = "c".repeat(128);
let event = event_with_tags(vec![
auth_tag(OWNER_PUBKEY_HEX, "kind=0", &sig),
auth_tag(AGENT_PUBKEY_HEX, "kind=0", &sig),
]);
let err =
extract_single_auth_tag_json(&event).expect_err("two auth tags must error");
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn test_extract_wrong_arity_auth_tag_is_error() {
// An `auth` tag with three elements (missing signature) must be rejected.
let event =
event_with_tags(vec![Tag::parse(["auth", OWNER_PUBKEY_HEX, "kind=0"]).unwrap()]);
let err = extract_single_auth_tag_json(&event)
.expect_err("malformed auth tag arity must error");
assert!(matches!(err, SdkError::InvalidInput(_)));
}
}