1use ed25519_dalek::{Signer, SigningKey};
4use rand::Rng;
5use wowlab_common::{EnvLoader, NodePublicKey, ProjectIdentity, config_dir, time};
6use wowlab_fs::path::{Path, PathBuf};
7use wowlab_parsers::{AccessControl, build_sign_message};
8use wowlab_types::sensitive::Sensitive;
9
10use crate::sentinel::{RequestSigner, SignedHeaders};
11
12const ED25519_KEY_BYTES: usize = 32;
13
14const DEFAULT_SENTINEL_URL: &str = "https://sentinel.wowlab.gg";
15const DEFAULT_BEACON_URL: &str = "wss://beacon.wowlab.gg";
16const DEFAULT_SUPABASE_URL: &str = "https://api.wowlab.gg";
18const DEFAULT_ANON_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFtbHp6aWZzanNuanJxb3FyZ2x5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjIzOTUyMTYsImV4cCI6MjA3Nzk3MTIxNn0.I8sbS5AgEzLzD2h5FXcIBZCCchHnbnVn3EufN61WMoM";
19
20#[derive(Clone, Debug)]
21pub(crate) struct NodeKeyStore {
22 path: Option<PathBuf>,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub(crate) enum IdentityCleanupOutcome {
27 Removed,
28 AlreadyAbsent,
29}
30
31#[derive(Debug, thiserror::Error)]
33#[error("{kind}")]
34pub struct NodeConfigError {
35 #[source]
36 kind: NodeConfigErrorKind,
37}
38
39#[derive(Debug, thiserror::Error)]
40enum NodeConfigErrorKind {
41 #[error("node identity directory is unavailable; set NODE_CONFIG_DIR")]
42 IdentityDirectoryUnavailable,
43 #[error("failed to establish node identity at {path}: {source}")]
44 IdentityIo {
45 path: PathBuf,
46 #[source]
47 source: wowlab_fs::error::Error,
48 },
49 #[error("node identity at {path} has {actual} bytes; expected {expected}")]
50 InvalidIdentityLength {
51 path: PathBuf,
52 actual: usize,
53 expected: usize,
54 },
55}
56
57impl NodeConfigError {
58 fn identity_directory_unavailable() -> Self {
59 Self {
60 kind: NodeConfigErrorKind::IdentityDirectoryUnavailable,
61 }
62 }
63
64 fn identity_io(path: &Path, source: wowlab_fs::error::Error) -> Self {
65 Self {
66 kind: NodeConfigErrorKind::IdentityIo {
67 path: path.to_path_buf(),
68 source,
69 },
70 }
71 }
72
73 fn invalid_identity_length(path: &Path, actual: usize) -> Self {
74 Self {
75 kind: NodeConfigErrorKind::InvalidIdentityLength {
76 path: path.to_path_buf(),
77 actual,
78 expected: ED25519_KEY_BYTES,
79 },
80 }
81 }
82}
83
84impl NodeKeyStore {
85 fn discover() -> Self {
86 Self {
87 path: config_dir(
88 "NODE",
89 ProjectIdentity {
90 qualifier: "gg",
91 organization: "wowlab",
92 application: "wowlab-node",
93 },
94 )
95 .map(|directory| directory.join("keypair")),
96 }
97 }
98
99 #[cfg(test)]
100 pub(crate) fn at(path: PathBuf) -> Self {
101 Self { path: Some(path) }
102 }
103
104 pub(crate) fn delete(&self) -> Result<IdentityCleanupOutcome, wowlab_fs::error::Error> {
105 let Some(path) = self.path.as_ref() else {
106 return Ok(IdentityCleanupOutcome::AlreadyAbsent);
107 };
108
109 let removed = wowlab_fs::directory::remove_file_if_exists(path)?;
110
111 Ok(if removed {
112 IdentityCleanupOutcome::Removed
113 } else {
114 IdentityCleanupOutcome::AlreadyAbsent
115 })
116 }
117
118 fn load_or_create(&self) -> Result<(SigningKey, NodePublicKey), NodeConfigError> {
119 let path = self
120 .path
121 .as_deref()
122 .ok_or_else(NodeConfigError::identity_directory_unavailable)?;
123 let contents = wowlab_fs::private_file::load_or_create_with(path, || {
124 let mut key_bytes = [0_u8; ED25519_KEY_BYTES];
125
126 rand::rng().fill_bytes(&mut key_bytes);
127
128 key_bytes.to_vec()
129 })
130 .map_err(|source| NodeConfigError::identity_io(path, source))?;
131 let key_bytes: [u8; ED25519_KEY_BYTES] =
132 contents.try_into().map_err(|contents: Vec<u8>| {
133 NodeConfigError::invalid_identity_length(path, contents.len())
134 })?;
135 let signing_key = SigningKey::from_bytes(&key_bytes);
136 let public_key = NodePublicKey::from(signing_key.verifying_key());
137
138 Ok((signing_key, public_key))
139 }
140}
141
142#[derive(Clone)]
144pub struct NodeConfig {
145 key_store: NodeKeyStore,
146 signing_key: SigningKey,
147 pub public_key: NodePublicKey,
148
149 pub sentinel_url: String,
150 pub beacon_url: String,
151 pub supabase_url: String,
152 pub supabase_anon_key: String,
153 pub game_data_cache_dir: Option<PathBuf>,
154
155 pub name: String,
156 pub total_cores: i32,
157 pub enabled_cores: i32,
158 pub token_claim: Option<Sensitive<String>>,
159 pub access: AccessControl,
160}
161
162impl NodeConfig {
163 pub fn load() -> Result<Self, NodeConfigError> {
169 let env = EnvLoader::new("NODE");
170 let key_store = NodeKeyStore::discover();
171 let (signing_key, public_key) = key_store.load_or_create()?;
172
173 Ok(Self {
174 key_store,
175 signing_key,
176 public_key,
177 sentinel_url: env.get_or("SENTINEL_URL", DEFAULT_SENTINEL_URL),
178 beacon_url: env.get_or("BEACON_URL", DEFAULT_BEACON_URL),
179 supabase_url: env.get_or("SUPABASE_URL", DEFAULT_SUPABASE_URL),
180 supabase_anon_key: env.get_or("SUPABASE_ANON_KEY", DEFAULT_ANON_KEY),
181 game_data_cache_dir: env.get("CACHE_DIR").map(PathBuf::from).or_else(|| {
182 config_dir(
183 "NODE",
184 ProjectIdentity {
185 qualifier: "gg",
186 organization: "wowlab",
187 application: "wowlab-node",
188 },
189 )
190 .map(|directory| directory.join("game-data"))
191 }),
192 name: env.get("NAME").unwrap_or_else(crate::claim::default_name),
193 total_cores: env
194 .get("TOTAL_CORES")
195 .and_then(|s| s.parse().ok())
196 .unwrap_or_else(crate::claim::total_cores),
197 enabled_cores: env
198 .get("ENABLED_CORES")
199 .and_then(|s| s.parse().ok())
200 .unwrap_or_else(crate::claim::default_enabled_cores),
201 token_claim: env.get("CLAIM_TOKEN").map(Sensitive::new),
202 access: env
203 .get("ACCESS")
204 .and_then(|s| s.parse().ok())
205 .unwrap_or_default(),
206 })
207 }
208
209 pub(crate) fn key_store(&self) -> NodeKeyStore {
210 self.key_store.clone()
211 }
212}
213
214impl std::fmt::Debug for NodeConfig {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 f.debug_struct("NodeConfig")
217 .field("signing_key", &"[redacted]")
218 .field("public_key", &self.public_key)
219 .field("sentinel_url", &self.sentinel_url)
220 .field("beacon_url", &self.beacon_url)
221 .field("supabase_url", &self.supabase_url)
222 .field("game_data_cache_dir", &self.game_data_cache_dir)
223 .field("name", &self.name)
224 .field("total_cores", &self.total_cores)
225 .field("enabled_cores", &self.enabled_cores)
226 .field("token_claim", &self.token_claim)
227 .field("access", &self.access)
228 .finish_non_exhaustive()
229 }
230}
231
232impl RequestSigner for NodeConfig {
233 fn sign_request(&self, method: &str, host: &str, path: &str, body: &[u8]) -> SignedHeaders {
234 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as BASE64};
235
236 let timestamp = time::unix_timestamp_secs();
237
238 let message = build_sign_message(timestamp, method, host, path, body);
239 let signature = self.signing_key.sign(message.as_bytes());
240
241 SignedHeaders {
242 key: self.public_key.to_string(),
243 signature: BASE64.encode(signature.to_bytes()),
244 timestamp: timestamp.to_string(),
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as BASE64};
252 use ed25519_dalek::{Signature, Verifier};
253 use googletest::prelude::*;
254
255 use super::*;
256
257 fn test_config() -> NodeConfig {
258 let signing_key = SigningKey::from_bytes(&[7; ED25519_KEY_BYTES]);
259 let public_key = NodePublicKey::from(signing_key.verifying_key());
260
261 NodeConfig {
262 key_store: NodeKeyStore { path: None },
263 signing_key,
264 public_key,
265 sentinel_url: "https://sentinel.example".to_string(),
266 beacon_url: "wss://beacon.example".to_string(),
267 supabase_url: "https://database.example".to_string(),
268 supabase_anon_key: "private-test-anon-value".to_string(),
269 game_data_cache_dir: None,
270 name: "test-node".to_string(),
271 total_cores: 8,
272 enabled_cores: 4,
273 token_claim: Some(Sensitive::new("private-test-claim".to_string())),
274 access: AccessControl::default(),
275 }
276 }
277
278 #[gtest]
279 fn request_signature_verifies_exact_canonical_message() -> Result<()> {
280 let config = test_config();
281 let body = [0_u8, 1, 255];
282
283 let headers = config.sign_request("POST", "sentinel.example", "/chunks/complete", &body);
284
285 let timestamp = headers.timestamp.parse().or_fail()?;
286 let message = build_sign_message(
287 timestamp,
288 "POST",
289 "sentinel.example",
290 "/chunks/complete",
291 &body,
292 );
293 let signature_bytes = BASE64.decode(&headers.signature).or_fail()?;
294 let signature = Signature::from_slice(&signature_bytes).or_fail()?;
295
296 config
297 .signing_key
298 .verifying_key()
299 .verify(message.as_bytes(), &signature)
300 .or_fail()?;
301 verify_that!(headers.signature, not(contains_substring("=")))?;
302
303 verify_that!(headers.key, eq(&config.public_key.to_string()))
304 }
305
306 #[gtest]
307 fn debug_output_redacts_all_credentials() -> Result<()> {
308 let config = test_config();
309
310 let debug = format!("{config:?}");
311
312 verify_that!(debug, contains_substring("[redacted]"))?;
313 verify_that!(debug, not(contains_substring("private-test-claim")))?;
314 verify_that!(debug, not(contains_substring("private-test-anon-value")))?;
315
316 verify_that!(
317 debug,
318 not(contains_substring(BASE64.encode([7_u8; ED25519_KEY_BYTES])))
319 )
320 }
321
322 #[gtest]
323 fn persisted_identity_roundtrips_without_temporary_files() -> Result<()> {
324 let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
325 let path = directory.path().join("keypair");
326 let store = NodeKeyStore::at(path.clone());
327
328 let (created, created_public) = store.load_or_create().or_fail()?;
329 let (loaded, loaded_public) = store.load_or_create().or_fail()?;
330 let entries = wowlab_fs::directory::entries(directory.path()).or_fail()?;
331
332 verify_that!(loaded.to_bytes(), eq(created.to_bytes()))?;
333 verify_that!(loaded_public, eq(&created_public))?;
334 verify_that!(entries.len(), eq(1))?;
335
336 verify_that!(entries[0].path(), eq(&*path))
337 }
338
339 #[gtest]
340 fn malformed_identity_is_rejected_without_being_replaced() -> Result<()> {
341 let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
342 let path = directory.path().join("keypair");
343 let malformed = b"not-an-ed25519-key";
344
345 wowlab_fs::file::write_bytes(&path, malformed).or_fail()?;
346 let store = NodeKeyStore::at(path.clone());
347
348 verify_that!(store.load_or_create(), err(anything()))?;
349
350 verify_that!(
351 wowlab_fs::file::read_bytes(&path).or_fail()?.as_slice(),
352 eq(malformed)
353 )
354 }
355}