mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
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:
parent
438aa6e4eb
commit
96d2d50cfd
@@ -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}"))?;
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user