Skip to main content

hashtree_core/
nhash.rs

1//! Bech32-encoded identifiers for hashtree content
2//!
3//! Similar to nostr's nip19 (npub, nprofile, nevent, naddr),
4//! provides human-readable, copy-pasteable identifiers.
5//!
6//! Types:
7//! - nhash: Permalink (hash + optional decrypt key)
8
9use crate::encryption::ObjectKey;
10use crate::types::Hash;
11use thiserror::Error;
12
13/// TLV type constants
14mod tlv {
15    /// 32-byte hash (required for nhash)
16    pub const HASH: u8 = 0;
17    /// UTF-8 path segment (legacy; ignored by decoder)
18    pub const PATH: u8 = 4;
19    /// 32-byte decryption key (optional)
20    pub const DECRYPT_KEY: u8 = 5;
21}
22
23/// Errors for nhash encoding/decoding
24#[derive(Debug, Error)]
25pub enum NHashError {
26    #[error("Bech32 error: {0}")]
27    Bech32(String),
28    #[error("Invalid prefix: expected {expected}, got {got}")]
29    InvalidPrefix { expected: String, got: String },
30    #[error("Invalid hash length: expected 32 bytes, got {0}")]
31    InvalidHashLength(usize),
32    #[error("Invalid key length: expected 32 or 33 bytes, got {0}")]
33    InvalidKeyLength(usize),
34    #[error("Missing required field: {0}")]
35    MissingField(String),
36    #[error("TLV error: {0}")]
37    TlvError(String),
38    #[error("Hex error: {0}")]
39    HexError(#[from] hex::FromHexError),
40}
41
42/// NHash data - permalink to content by hash
43#[derive(Debug, Clone, PartialEq)]
44pub struct NHashData {
45    /// 32-byte merkle hash
46    pub hash: Hash,
47    /// 32-byte decryption key (optional)
48    pub decrypt_key: Option<ObjectKey>,
49}
50
51/// Decode result
52#[derive(Debug, Clone, PartialEq)]
53pub enum DecodeResult {
54    NHash(NHashData),
55}
56
57/// Parse TLV-encoded data into a map of type -> values
58fn parse_tlv(data: &[u8]) -> Result<std::collections::HashMap<u8, Vec<Vec<u8>>>, NHashError> {
59    let mut result: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
60    let mut offset = 0;
61
62    while offset < data.len() {
63        if offset + 2 > data.len() {
64            return Err(NHashError::TlvError("unexpected end of data".into()));
65        }
66        let t = data[offset];
67        let l = data[offset + 1] as usize;
68        offset += 2;
69
70        if offset + l > data.len() {
71            return Err(NHashError::TlvError(format!(
72                "not enough data for type {}, need {} bytes",
73                t, l
74            )));
75        }
76        let v = data[offset..offset + l].to_vec();
77        offset += l;
78
79        result.entry(t).or_default().push(v);
80    }
81
82    Ok(result)
83}
84
85/// Encode TLV data to bytes
86fn encode_tlv(tlv: &std::collections::HashMap<u8, Vec<Vec<u8>>>) -> Result<Vec<u8>, NHashError> {
87    let mut entries: Vec<u8> = Vec::new();
88
89    // Process in ascending key order for consistent encoding
90    let mut keys: Vec<u8> = tlv.keys().copied().collect();
91    keys.sort();
92
93    for t in keys {
94        if let Some(values) = tlv.get(&t) {
95            for v in values {
96                if v.len() > 255 {
97                    return Err(NHashError::TlvError(format!(
98                        "value too long for type {}: {} bytes",
99                        t,
100                        v.len()
101                    )));
102                }
103                entries.push(t);
104                entries.push(v.len() as u8);
105                entries.extend_from_slice(v);
106            }
107        }
108    }
109
110    Ok(entries)
111}
112
113/// Encode bech32 with given prefix and data
114/// Uses regular bech32 (not bech32m) for compatibility with nostr nip19
115fn encode_bech32(hrp: &str, data: &[u8]) -> Result<String, NHashError> {
116    use bech32::{Bech32, Hrp};
117
118    let hrp = Hrp::parse(hrp).map_err(|e| NHashError::Bech32(e.to_string()))?;
119    bech32::encode::<Bech32>(hrp, data).map_err(|e| NHashError::Bech32(e.to_string()))
120}
121
122/// Decode bech32 and return (hrp, data)
123fn decode_bech32(s: &str) -> Result<(String, Vec<u8>), NHashError> {
124    let (hrp, data) = bech32::decode(s).map_err(|e| NHashError::Bech32(e.to_string()))?;
125
126    Ok((hrp.to_string(), data))
127}
128
129// ============================================================================
130// nhash - Permalink (hash + optional decrypt key)
131// ============================================================================
132
133/// Encode an nhash permalink from just a hash
134pub fn nhash_encode(hash: &Hash) -> Result<String, NHashError> {
135    nhash_encode_full(&NHashData {
136        hash: *hash,
137        decrypt_key: None,
138    })
139}
140
141/// Encode an nhash permalink with optional decrypt key
142///
143/// Encoding is always TLV (canonical):
144/// - HASH tag is always present
145/// - DECRYPT_KEY tag is optional
146pub fn nhash_encode_full(data: &NHashData) -> Result<String, NHashError> {
147    let mut tlv: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
148    tlv.insert(tlv::HASH, vec![data.hash.to_vec()]);
149
150    if let Some(key) = &data.decrypt_key {
151        tlv.insert(tlv::DECRYPT_KEY, vec![key.as_bytes().to_vec()]);
152    }
153
154    encode_bech32("nhash", &encode_tlv(&tlv)?)
155}
156
157/// Decode an nhash string
158pub fn nhash_decode(code: &str) -> Result<NHashData, NHashError> {
159    // Strip optional prefix
160    let code = code.strip_prefix("hashtree:").unwrap_or(code);
161
162    let (prefix, data) = decode_bech32(code)?;
163
164    if prefix != "nhash" {
165        return Err(NHashError::InvalidPrefix {
166            expected: "nhash".into(),
167            got: prefix,
168        });
169    }
170
171    // Legacy simple 32-byte hash (no TLV)
172    if data.len() == 32 {
173        let mut hash = [0u8; 32];
174        hash.copy_from_slice(&data);
175        return Ok(NHashData {
176            hash,
177            decrypt_key: None,
178        });
179    }
180
181    // Parse TLV
182    let tlv = parse_tlv(&data)?;
183
184    let hash_bytes = tlv
185        .get(&tlv::HASH)
186        .and_then(|v| v.first())
187        .ok_or_else(|| NHashError::MissingField("hash".into()))?;
188
189    if hash_bytes.len() != 32 {
190        return Err(NHashError::InvalidHashLength(hash_bytes.len()));
191    }
192
193    let mut hash = [0u8; 32];
194    hash.copy_from_slice(hash_bytes);
195
196    // Legacy PATH tags are ignored. Path traversal uses nhash/... URL segments.
197    let _ = tlv.get(&tlv::PATH);
198
199    let decrypt_key = if let Some(keys) = tlv.get(&tlv::DECRYPT_KEY) {
200        if let Some(key_bytes) = keys.first() {
201            Some(
202                ObjectKey::from_bytes(key_bytes)
203                    .map_err(|e| NHashError::TlvError(e.to_string()))?,
204            )
205        } else {
206            None
207        }
208    } else {
209        None
210    };
211
212    Ok(NHashData { hash, decrypt_key })
213}
214
215// ============================================================================
216// Generic decode
217// ============================================================================
218
219/// Decode an nhash string, returning a tagged decode result.
220pub fn decode(code: &str) -> Result<DecodeResult, NHashError> {
221    let code = code.strip_prefix("hashtree:").unwrap_or(code);
222
223    if code.starts_with("nhash1") {
224        return Ok(DecodeResult::NHash(nhash_decode(code)?));
225    }
226
227    Err(NHashError::InvalidPrefix {
228        expected: "nhash1".into(),
229        got: code.chars().take(10).collect(),
230    })
231}
232
233// ============================================================================
234// Type guards
235// ============================================================================
236
237/// Check if a string is an nhash
238pub fn is_nhash(value: &str) -> bool {
239    value.starts_with("nhash1")
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_nhash_hash_only_uses_tlv_encoding() {
248        let hash: Hash = [
249            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
250            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
251            0x1d, 0x1e, 0x1f, 0x20,
252        ];
253
254        let encoded = nhash_encode(&hash).unwrap();
255        assert!(encoded.starts_with("nhash1"));
256
257        let (_prefix, payload) = decode_bech32(&encoded).unwrap();
258        assert_ne!(payload.len(), 32, "hash-only nhash must use TLV payload");
259
260        let decoded = nhash_decode(&encoded).unwrap();
261        assert_eq!(decoded.hash, hash);
262        assert!(decoded.decrypt_key.is_none());
263    }
264
265    #[test]
266    fn test_nhash_decode_legacy_simple_hash_payload() {
267        let hash: Hash = [0x42; 32];
268        let encoded = encode_bech32("nhash", &hash).unwrap();
269
270        let decoded = nhash_decode(&encoded).unwrap();
271        assert_eq!(decoded.hash, hash);
272        assert!(decoded.decrypt_key.is_none());
273    }
274
275    #[test]
276    fn test_nhash_with_key() {
277        let hash: Hash = [0xaa; 32];
278        let key: [u8; 32] = [0xbb; 32];
279
280        let data = NHashData {
281            hash,
282            decrypt_key: Some(key.into()),
283        };
284
285        let encoded = nhash_encode_full(&data).unwrap();
286        assert!(encoded.starts_with("nhash1"));
287
288        let decoded = nhash_decode(&encoded).unwrap();
289        assert_eq!(decoded.hash, hash);
290        assert_eq!(decoded.decrypt_key, Some(key.into()));
291    }
292
293    #[test]
294    fn test_nhash_encode_full_matches_nhash_encode_when_no_key() {
295        let hash: Hash = [0xaa; 32];
296        let encoded_a = nhash_encode(&hash).unwrap();
297        let encoded_b = nhash_encode_full(&NHashData {
298            hash,
299            decrypt_key: None,
300        })
301        .unwrap();
302        assert_eq!(encoded_a, encoded_b);
303    }
304
305    #[test]
306    fn test_nhash_decode_ignores_embedded_path_tags() {
307        let mut tlv: std::collections::HashMap<u8, Vec<Vec<u8>>> = std::collections::HashMap::new();
308        tlv.insert(tlv::HASH, vec![vec![0x11; 32]]);
309        tlv.insert(tlv::PATH, vec![b"nested".to_vec(), b"file.txt".to_vec()]);
310
311        let payload = encode_tlv(&tlv).unwrap();
312        let encoded = encode_bech32("nhash", &payload).unwrap();
313
314        let decoded = nhash_decode(&encoded).unwrap();
315        assert_eq!(decoded.hash, [0x11; 32]);
316        assert!(decoded.decrypt_key.is_none());
317    }
318
319    #[test]
320    fn test_decode_generic() {
321        let hash: Hash = [0x11; 32];
322        let nhash = nhash_encode(&hash).unwrap();
323
324        match decode(&nhash).unwrap() {
325            DecodeResult::NHash(data) => assert_eq!(data.hash, hash),
326        }
327    }
328
329    #[test]
330    fn test_decode_rejects_non_nhash_prefix() {
331        let err = decode("nref1abc").unwrap_err();
332        match err {
333            NHashError::InvalidPrefix { expected, .. } => assert_eq!(expected, "nhash1"),
334            _ => panic!("expected InvalidPrefix"),
335        }
336    }
337
338    #[test]
339    fn test_is_nhash() {
340        assert!(is_nhash("nhash1abc"));
341        assert!(!is_nhash("nref1abc"));
342        assert!(!is_nhash("npub1abc"));
343    }
344}