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