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    use std::io::Write;
97
98    let temporary_path = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
99    let mut options = std::fs::OpenOptions::new();
100    options.write(true).create_new(true);
101    #[cfg(unix)]
102    {
103        use std::os::unix::fs::OpenOptionsExt;
104        options.mode(0o600);
105    }
106
107    let result = (|| -> Result<()> {
108        let mut file = options
109            .open(&temporary_path)
110            .map_err(|error| SyncwebError::identity(path, error))?;
111        let encoded = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret_key.to_bytes());
112        file.write_all(encoded.as_bytes())?;
113        file.sync_all()?;
114        std::fs::rename(&temporary_path, path).map_err(|error| SyncwebError::identity(path, error))
115    })();
116
117    if result.is_err()
118        && let Err(error) = std::fs::remove_file(&temporary_path)
119    {
120        tracing::warn!(
121            path = %temporary_path.display(),
122            ?error,
123            "failed to clean up temporary identity file"
124        );
125    }
126    result
127}
128
129#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
130pub struct DeviceId(PublicKey);
131
132impl DeviceId {
133    #[must_use]
134    pub const fn from_node_id(node_id: PublicKey) -> Self {
135        Self(node_id)
136    }
137
138    /// # Errors
139    ///
140    /// Returns an error if the provided value is not a valid Syncthing device ID.
141    pub fn from_syncthing(value: &str) -> Result<Self> {
142        let compact: String = value
143            .chars()
144            .filter(|character| *character != '-')
145            .map(|character| character.to_ascii_uppercase())
146            .collect();
147        if compact.len() != 56 || !compact.is_ascii() {
148            return Err(SyncwebError::InvalidDeviceId(
149                "expected 56 base32 characters".to_owned(),
150            ));
151        }
152
153        let mut encoded = String::with_capacity(52);
154        for chunk in compact.as_bytes().chunks_exact(14) {
155            let (&checksum, payload) = chunk
156                .split_last()
157                .ok_or_else(|| SyncwebError::InvalidDeviceId("invalid chunk length".to_owned()))?;
158            if luhn32(payload)? != checksum {
159                return Err(SyncwebError::InvalidDeviceId("invalid checksum".to_owned()));
160            }
161            encoded.push_str(
162                std::str::from_utf8(payload)
163                    .map_err(|error| SyncwebError::InvalidDeviceId(format!("invalid base32 encoding: {error}")))?,
164            );
165        }
166
167        let decoded = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &encoded)
168            .ok_or_else(|| SyncwebError::InvalidDeviceId("invalid base32 encoding".to_owned()))?;
169        let bytes: [u8; 32] = decoded.try_into().map_err(|error: Vec<u8>| {
170            SyncwebError::InvalidDeviceId(format!("invalid public key length: {} bytes", error.len()))
171        })?;
172        let node_id =
173            PublicKey::from_bytes(&bytes).map_err(|error| SyncwebError::InvalidDeviceId(error.to_string()))?;
174        Ok(Self(node_id))
175    }
176
177    #[must_use]
178    pub fn to_syncthing(&self) -> String {
179        let encoded = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, self.0.as_bytes());
180        let mut checked = String::with_capacity(56);
181        for chunk in encoded.as_bytes().chunks(13) {
182            checked.push_str(std::str::from_utf8(chunk).unwrap_or(""));
183            checked.push(char::from(luhn32(chunk).unwrap_or(0)));
184        }
185
186        checked
187            .as_bytes()
188            .chunks(7)
189            .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
190            .collect::<Vec<_>>()
191            .join("-")
192    }
193
194    #[must_use]
195    pub const fn node_id(&self) -> PublicKey {
196        self.0
197    }
198}
199
200fn luhn32(input: &[u8]) -> Result<u8> {
201    let mut factor = 2_usize;
202    let mut sum = 0_usize;
203    let alpha_len = SYNCTHING_ALPHABET.len();
204    for character in input.iter().rev() {
205        let codepoint = SYNCTHING_ALPHABET
206            .iter()
207            .position(|candidate| candidate == character)
208            .ok_or_else(|| SyncwebError::InvalidDeviceId("invalid base32 character".to_owned()))?;
209
210        let addend = factor
211            .checked_mul(codepoint)
212            .ok_or_else(|| SyncwebError::InvalidDeviceId("math overflow".to_owned()))?;
213        let addend_div = addend
214            .checked_div(alpha_len)
215            .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
216        let addend_rem = addend
217            .checked_rem(alpha_len)
218            .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
219        let addend_sum = addend_div
220            .checked_add(addend_rem)
221            .ok_or_else(|| SyncwebError::InvalidDeviceId("math overflow".to_owned()))?;
222        sum = sum
223            .checked_add(addend_sum)
224            .ok_or_else(|| SyncwebError::InvalidDeviceId("math overflow".to_owned()))?;
225
226        factor = if factor == 2 { 1 } else { 2 };
227    }
228    let remainder = sum
229        .checked_rem(alpha_len)
230        .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
231    let index_sub = alpha_len
232        .checked_sub(remainder)
233        .ok_or_else(|| SyncwebError::InvalidDeviceId("math underflow".to_owned()))?;
234    let index = index_sub
235        .checked_rem(alpha_len)
236        .ok_or_else(|| SyncwebError::InvalidDeviceId("division by zero".to_owned()))?;
237
238    SYNCTHING_ALPHABET
239        .get(index)
240        .copied()
241        .ok_or_else(|| SyncwebError::InvalidDeviceId("index out of bounds".to_owned()))
242}