use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::PodError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NostrPubkey(pub [u8; 32]);
impl NostrPubkey {
pub fn from_hex(s: &str) -> Result<Self, PodError> {
if s.len() != 64 {
return Err(PodError::BadRequest(format!(
"expected 64 hex chars, got {}",
s.len()
)));
}
let bytes = hex::decode(s).map_err(|e| PodError::BadRequest(e.to_string()))?;
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(Self(arr))
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
}
pub fn did_nostr_uri(pk: &NostrPubkey) -> String {
format!("did:nostr:{}", pk.to_hex())
}
pub fn well_known_path(pk: &NostrPubkey) -> String {
format!("/.well-known/did/nostr/{}.json", pk.to_hex())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceEntry {
pub id: String,
#[serde(rename = "type")]
pub service_type: String,
pub service_endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra: Option<Value>,
}
const KEY_FRAGMENT: &str = "#key1";
pub fn render_did_document(pk: &NostrPubkey) -> Value {
let did = did_nostr_uri(pk);
json!({
"@context": [
"https://w3id.org/did",
"https://w3id.org/nostr/context"
],
"id": did,
"type": "DIDNostr",
"verificationMethod": [{
"id": format!("{did}{KEY_FRAGMENT}"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": format_multibase_schnorr(&pk.0),
}],
"authentication": [KEY_FRAGMENT],
"assertionMethod": [KEY_FRAGMENT],
"service": []
})
}
pub fn render_did_document_tier1(pk: &NostrPubkey) -> Value {
render_did_document(pk)
}
pub fn render_did_document_tier3(
pk: &NostrPubkey,
webid: Option<&str>,
services: &[ServiceEntry],
) -> Value {
let mut doc = render_did_document(pk);
if let Some(w) = webid {
doc["alsoKnownAs"] = json!([w]);
}
if !services.is_empty() {
let service_values: Vec<Value> = services
.iter()
.map(|s| {
let mut obj = serde_json::Map::new();
if let Some(Value::Object(extra)) = s.extra.clone() {
for (k, v) in extra {
obj.insert(k, v);
}
}
obj.insert("id".to_string(), Value::String(s.id.clone()));
obj.insert("type".to_string(), Value::String(s.service_type.clone()));
obj.insert(
"serviceEndpoint".to_string(),
Value::String(s.service_endpoint.clone()),
);
Value::Object(obj)
})
.collect();
doc["service"] = Value::Array(service_values);
}
doc
}
pub const MULTIKEY_PREFIX: &str = "fe70102";
pub const MULTIKEY_LEN: usize = 71;
pub fn format_multibase_schnorr(pk: &[u8; 32]) -> String {
format!("{MULTIKEY_PREFIX}{}", hex::encode(pk))
}
pub fn parse_multibase_schnorr(s: &str) -> Result<NostrPubkey, PodError> {
if s.len() != MULTIKEY_LEN {
return Err(PodError::BadRequest(format!(
"publicKeyMultibase: expected {MULTIKEY_LEN} chars, got {}",
s.len()
)));
}
let Some(body) = s.strip_prefix(MULTIKEY_PREFIX) else {
return Err(PodError::BadRequest(format!(
"publicKeyMultibase: expected `{MULTIKEY_PREFIX}` prefix (got `{}`)",
&s[..s.len().min(7)]
)));
};
if body.chars().any(|c| c.is_ascii_uppercase()) {
return Err(PodError::BadRequest(
"publicKeyMultibase: uppercase hex under `f` indicator is malformed".into(),
));
}
NostrPubkey::from_hex(body)
}
pub fn is_valid_hex_pubkey(s: &str) -> bool {
s.len() == 64
&& s.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
pub fn verify_webid_tag(tag_value: &str, pubkey: &str) -> bool {
if !is_valid_hex_pubkey(pubkey) {
return false;
}
let expected_did = format!("did:nostr:{pubkey}");
if tag_value == expected_did {
return true;
}
tag_value.contains(pubkey)
}
#[cfg(test)]
mod tests {
use super::*;
const PK_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001";
#[test]
fn pubkey_roundtrip_hex() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
assert_eq!(pk.to_hex(), PK_HEX);
}
#[test]
fn pubkey_rejects_short_hex() {
assert!(NostrPubkey::from_hex("abcd").is_err());
}
#[test]
fn pubkey_rejects_non_hex() {
assert!(NostrPubkey::from_hex(&"z".repeat(64)).is_err());
}
#[test]
fn did_uri_format() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
assert_eq!(did_nostr_uri(&pk), format!("did:nostr:{PK_HEX}"));
}
#[test]
fn well_known_path_matches_spec() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let path = well_known_path(&pk);
assert_eq!(path, format!("/.well-known/did/nostr/{PK_HEX}.json"));
assert!(path.starts_with("/.well-known/did/nostr/"));
assert!(path.ends_with(".json"));
}
#[test]
fn canonical_document_has_required_fields() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let did = format!("did:nostr:{PK_HEX}");
let doc = render_did_document(&pk);
assert_eq!(doc["id"], did);
assert_eq!(doc["@context"][0], "https://w3id.org/did");
assert_eq!(doc["@context"][1], "https://w3id.org/nostr/context");
assert_eq!(doc["type"], "DIDNostr");
assert!(doc["service"].is_array());
assert_eq!(doc["service"].as_array().unwrap().len(), 0);
assert!(doc.get("alsoKnownAs").is_none());
let vm = &doc["verificationMethod"][0];
assert_eq!(vm["id"], format!("{did}#key1"));
assert_eq!(vm["type"], "Multikey");
assert_eq!(vm["controller"], did);
assert!(vm.get("publicKeyHex").is_none(), "publicKeyHex must be dropped (I2)");
assert_eq!(
vm["publicKeyMultibase"],
format!("fe70102{PK_HEX}"),
"publicKeyMultibase == fe70102 + same x-only hex (I2)"
);
assert_eq!(doc["authentication"][0], "#key1");
assert_eq!(doc["assertionMethod"][0], "#key1");
}
#[test]
fn tier1_alias_emits_canonical_document() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
assert_eq!(render_did_document_tier1(&pk), render_did_document(&pk));
}
#[test]
fn tier3_document_carries_webid_and_services_as_extensions() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let webid = "https://alice.example/profile/card#me";
let service = ServiceEntry {
id: format!("did:nostr:{PK_HEX}#solid"),
service_type: "SolidWebID".to_string(),
service_endpoint: webid.to_string(),
extra: None,
};
let doc = render_did_document_tier3(&pk, Some(webid), &[service]);
assert_eq!(doc["type"], "DIDNostr");
assert_eq!(doc["verificationMethod"][0]["type"], "Multikey");
assert_eq!(doc["authentication"][0], "#key1");
assert_eq!(doc["alsoKnownAs"][0], webid);
assert_eq!(doc["service"][0]["type"], "SolidWebID");
assert_eq!(doc["service"][0]["serviceEndpoint"], webid);
}
#[test]
fn tier3_extras_do_not_override_core_fields() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let extra = json!({"id": "malicious", "type": "evil", "custom": "ok"});
let service = ServiceEntry {
id: "real-id".to_string(),
service_type: "NostrRelay".to_string(),
service_endpoint: "wss://relay.example".to_string(),
extra: Some(extra),
};
let doc = render_did_document_tier3(&pk, None, &[service]);
assert_eq!(doc["service"][0]["id"], "real-id");
assert_eq!(doc["service"][0]["type"], "NostrRelay");
assert_eq!(doc["service"][0]["custom"], "ok");
}
#[test]
fn tier3_without_webid_or_services_is_canonical_empty_service() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let doc = render_did_document_tier3(&pk, None, &[]);
assert_eq!(doc, render_did_document(&pk));
assert!(doc.get("alsoKnownAs").is_none());
assert!(doc["service"].as_array().unwrap().is_empty());
}
#[test]
fn multibase_schnorr_is_canonical_form() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let a = format_multibase_schnorr(&pk.0);
let b = format_multibase_schnorr(&pk.0);
assert_eq!(a, b, "deterministic");
assert!(a.starts_with("fe70102"), "must be fe70102 prefix, not z-base58");
assert_eq!(a.len(), MULTIKEY_LEN);
assert_eq!(a, format!("fe70102{PK_HEX}"));
assert_eq!(a, a.to_lowercase(), "lowercase hex throughout");
assert!(a.chars().all(|c| !c.is_ascii_uppercase()));
assert_eq!(&a[7..], PK_HEX);
}
#[test]
fn multibase_schnorr_round_trips_identical_key() {
let pk = NostrPubkey::from_hex(PK_HEX).unwrap();
let mb = format_multibase_schnorr(&pk.0);
let decoded = parse_multibase_schnorr(&mb).unwrap();
assert_eq!(decoded, pk);
assert_eq!(decoded.to_hex(), PK_HEX);
}
#[test]
fn parse_multibase_rejects_missing_parity_form() {
let bad = format!("fe701{PK_HEX}"); assert!(parse_multibase_schnorr(&bad).is_err());
}
#[test]
fn parse_multibase_rejects_base58btc() {
assert!(parse_multibase_schnorr("zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme").is_err());
}
#[test]
fn parse_multibase_rejects_uppercase_hex() {
let lettered = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
assert_eq!(lettered.len(), 64, "fixture guard: 64-char x-only hex");
assert!(
lettered.chars().any(|c| c.is_ascii_alphabetic()),
"fixture guard: the negative vector must contain hex letters",
);
let upper = format!("fe70102{}", lettered.to_uppercase());
assert!(parse_multibase_schnorr(&upper).is_err());
}
#[test]
fn parse_multibase_rejects_wrong_length() {
assert!(parse_multibase_schnorr("fe70102").is_err());
assert!(parse_multibase_schnorr(&format!("fe70102{PK_HEX}ab")).is_err());
}
#[test]
fn is_valid_hex_pubkey_accepts_valid() {
assert!(is_valid_hex_pubkey(PK_HEX));
assert!(is_valid_hex_pubkey(&"ab".repeat(32)));
}
#[test]
fn is_valid_hex_pubkey_rejects_invalid() {
assert!(!is_valid_hex_pubkey("short"));
assert!(!is_valid_hex_pubkey(&"Z".repeat(64)));
assert!(!is_valid_hex_pubkey(&"g".repeat(64)));
}
#[test]
fn verify_webid_tag_did_uri() {
assert!(verify_webid_tag(&format!("did:nostr:{PK_HEX}"), PK_HEX));
}
#[test]
fn verify_webid_tag_url_containing_pubkey() {
let url = format!("https://pod.example/.well-known/did/nostr/{PK_HEX}.json");
assert!(verify_webid_tag(&url, PK_HEX));
}
#[test]
fn verify_webid_tag_rejects_mismatch() {
assert!(!verify_webid_tag("https://other.example/foo", PK_HEX));
}
#[test]
fn verify_webid_tag_rejects_invalid_pubkey() {
assert!(!verify_webid_tag("did:nostr:abc", "abc"));
}
}