Skip to main content

syncweb_core/node/
identity.rs

1use hkdf::Hkdf;
2use iroh::{PublicKey, SecretKey};
3use iroh_docs::NamespaceId;
4use sha2::Sha256;
5use std::path::{Path, PathBuf};
6
7use crate::error::{Result, SyncwebError};
8
9const SYNCTHING_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
10
11#[derive(Clone)]
12pub struct IdentityManager {
13    secret_key: SecretKey,
14    path: PathBuf,
15}
16
17impl IdentityManager {
18    /// # Errors
19    ///
20    /// Returns an error if the identity file cannot be read, parsed, or if a new one cannot be written.
21    pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
22        let path_buf = path.into();
23        let secret_key = if path_buf.exists() {
24            let content =
25                std::fs::read_to_string(&path_buf).map_err(|error| SyncwebError::identity(&path_buf, error))?;
26            let decoded = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, content.trim())
27                .ok_or_else(|| SyncwebError::InvalidIdentity("invalid base32 encoding".to_string()))?;
28            let arr: [u8; 32] = decoded.try_into().map_err(|error: Vec<u8>| {
29                SyncwebError::InvalidIdentity(format!("expected 32 bytes, got {}", error.len()))
30            })?;
31            SecretKey::from_bytes(&arr)
32        } else {
33            let secret_key = SecretKey::generate();
34            if let Some(parent) = path_buf.parent()
35                && !parent.as_os_str().is_empty()
36            {
37                std::fs::create_dir_all(parent).map_err(|error| SyncwebError::identity(&path_buf, error))?;
38            }
39            write_secret_key(&path_buf, &secret_key)?;
40            secret_key
41        };
42
43        Ok(Self {
44            secret_key,
45            path: path_buf,
46        })
47    }
48
49    #[must_use]
50    pub fn node_id(&self) -> PublicKey {
51        self.secret_key.public()
52    }
53
54    #[must_use]
55    pub const fn secret_key(&self) -> &SecretKey {
56        &self.secret_key
57    }
58
59    #[must_use]
60    pub fn path(&self) -> &Path {
61        &self.path
62    }
63
64    /// Derive a per-folder author secret key from the master key and namespace ID.
65    ///
66    /// Uses HKDF-SHA256 with the namespace ID as salt and the master secret key
67    /// as input key material, producing a deterministic 32-byte key unique to each folder.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if HKDF expansion fails (should never happen with SHA-256 and 32-byte output).
72    pub fn derive_folder_key(&self, namespace_id: NamespaceId) -> Result<SecretKey> {
73        let hk = Hkdf::<Sha256>::new(Some(namespace_id.as_bytes()), &self.secret_key.to_bytes());
74        let mut key_bytes = [0_u8; 32];
75        hk.expand(b"syncweb-folder-author", &mut key_bytes)
76            .map_err(|e| SyncwebError::KeyDerivation(e.to_string()))?;
77        Ok(SecretKey::from_bytes(&key_bytes))
78    }
79
80    /// Derive a per-folder [`iroh_docs::Author`] from the master key.
81    ///
82    /// The author's signing key is deterministically derived from the master key
83    /// and the folder's namespace ID via HKDF, ensuring each folder has a unique
84    /// author identity without storing additional key material.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if key derivation fails.
89    pub fn derive_folder_author(&self, namespace_id: NamespaceId) -> Result<iroh_docs::Author> {
90        let folder_key = self.derive_folder_key(namespace_id)?;
91        Ok(iroh_docs::Author::from_bytes(&folder_key.to_bytes()))
92    }
93}
94
95fn write_secret_key(path: &Path, secret_key: &SecretKey) -> Result<()> {
96    let encoded = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret_key.to_bytes());
97    crate::fs::atomic::atomic_write(path, encoded.as_bytes()).map_err(|error| SyncwebError::identity(path, error))
98}
99
100#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
101pub struct DeviceId(PublicKey);
102
103impl DeviceId {
104    #[must_use]
105    pub const fn from_node_id(node_id: PublicKey) -> Self {
106        Self(node_id)
107    }
108
109    /// # Errors
110    ///
111    /// Returns an error if the provided value is not a valid Syncthing device ID.
112    pub fn from_syncthing(value: &str) -> Result<Self> {
113        let compact: String = value
114            .chars()
115            .filter(|character| *character != '-')
116            .map(|character| character.to_ascii_uppercase())
117            .collect();
118        if compact.len() != 56 || !compact.is_ascii() {
119            return Err(SyncwebError::InvalidDeviceId(
120                "expected 56 base32 characters".to_owned(),
121            ));
122        }
123
124        let mut encoded = String::with_capacity(52);
125        for chunk in compact.as_bytes().chunks_exact(14) {
126            let (&checksum, payload) = chunk
127                .split_last()
128                .ok_or_else(|| SyncwebError::InvalidDeviceId("invalid chunk length".to_owned()))?;
129            if luhn32(payload)? != checksum {
130                return Err(SyncwebError::InvalidDeviceId("invalid checksum".to_owned()));
131            }
132            encoded.push_str(
133                std::str::from_utf8(payload)
134                    .map_err(|error| SyncwebError::InvalidDeviceId(format!("invalid base32 encoding: {error}")))?,
135            );
136        }
137
138        let decoded = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &encoded)
139            .ok_or_else(|| SyncwebError::InvalidDeviceId("invalid base32 encoding".to_owned()))?;
140        let bytes: [u8; 32] = decoded.try_into().map_err(|error: Vec<u8>| {
141            SyncwebError::InvalidDeviceId(format!("invalid public key length: {} bytes", error.len()))
142        })?;
143        let node_id =
144            PublicKey::from_bytes(&bytes).map_err(|error| SyncwebError::InvalidDeviceId(error.to_string()))?;
145        Ok(Self(node_id))
146    }
147
148    #[must_use]
149    pub fn to_syncthing(&self) -> String {
150        let encoded = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, self.0.as_bytes());
151        let mut checked = String::with_capacity(56);
152        for chunk in encoded.as_bytes().chunks(13) {
153            checked.push_str(std::str::from_utf8(chunk).unwrap_or(""));
154            checked.push(char::from(luhn32(chunk).unwrap_or(0)));
155        }
156
157        checked
158            .as_bytes()
159            .chunks(7)
160            .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
161            .collect::<Vec<_>>()
162            .join("-")
163    }
164
165    #[must_use]
166    pub const fn node_id(&self) -> PublicKey {
167        self.0
168    }
169}
170
171fn luhn32(input: &[u8]) -> Result<u8> {
172    let mut factor = 2_usize;
173    let mut sum = 0_usize;
174    let alpha_len = SYNCTHING_ALPHABET.len();
175    for character in input.iter().rev() {
176        let codepoint = SYNCTHING_ALPHABET
177            .iter()
178            .position(|candidate| candidate == character)
179            .ok_or_else(|| SyncwebError::InvalidDeviceId("invalid base32 character".to_owned()))?;
180
181        let addend = factor
182            .checked_mul(codepoint)
183            .ok_or_else(|| SyncwebError::InvalidDeviceId("math overflow".to_owned()))?;
184        let addend_div = addend
185            .checked_div(alpha_len)
186            .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
187        let addend_rem = addend
188            .checked_rem(alpha_len)
189            .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
190        let addend_sum = addend_div
191            .checked_add(addend_rem)
192            .ok_or_else(|| SyncwebError::InvalidDeviceId("math overflow".to_owned()))?;
193        sum = sum
194            .checked_add(addend_sum)
195            .ok_or_else(|| SyncwebError::InvalidDeviceId("math overflow".to_owned()))?;
196
197        factor = if factor == 2 { 1 } else { 2 };
198    }
199    let remainder = sum
200        .checked_rem(alpha_len)
201        .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
202    let index_sub = alpha_len
203        .checked_sub(remainder)
204        .ok_or_else(|| SyncwebError::InvalidDeviceId("math underflow".to_owned()))?;
205    let index = index_sub
206        .checked_rem(alpha_len)
207        .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
208
209    SYNCTHING_ALPHABET
210        .get(index)
211        .copied()
212        .ok_or_else(|| SyncwebError::InvalidDeviceId("index out of bounds".to_owned()))
213}