syncweb_core/node/
identity.rs1use 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 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 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 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 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}