Skip to main content

qualia_client_core/
node_identity.rs

1//! Node cryptographic identity — the node's persisted signing + WireGuard keys.
2//!
3//! A [`NodeIdentity`] holds two 32-byte secrets:
4//! - `ed25519_secret` — the ed25519 signing key used to sign connection
5//!   identifiers and challenge responses (its public half is the node's
6//!   stable identity pubkey).
7//! - `wg_secret` — the Curve25519 secret for this node's WireGuard keypair,
8//!   from which the overlay address is derived.
9//!
10//! The identity is persisted as pretty-printed JSON at
11//! `app_meta_dir()/node_identity.json` and loaded (or freshly generated) via
12//! [`NodeIdentity::load_or_create`]. Generation uses the OS CSPRNG.
13
14use ed25519_dalek::{SigningKey, VerifyingKey};
15
16#[cfg(not(target_arch = "wasm32"))]
17use qualia_core_db::p2p::wireguard_userspace::WgKeypair;
18
19/// The node's persisted cryptographic identity.
20///
21/// Two independent 32-byte secrets: an ed25519 signing key (identity /
22/// challenge signing) and a Curve25519 WireGuard secret (transport / overlay
23/// addressing). Serialized to JSON for on-disk persistence.
24#[derive(serde::Serialize, serde::Deserialize, Clone)]
25pub struct NodeIdentity {
26    /// Raw 32-byte ed25519 signing (secret) key.
27    pub ed25519_secret: [u8; 32],
28    /// Raw 32-byte Curve25519 WireGuard secret key.
29    pub wg_secret: [u8; 32],
30}
31
32impl NodeIdentity {
33    /// Reconstruct the ed25519 [`SigningKey`] from the stored secret bytes.
34    pub fn signing_key(&self) -> SigningKey {
35        SigningKey::from_bytes(&self.ed25519_secret)
36    }
37
38    /// The node's stable identity public key, lowercase hex (64 chars).
39    ///
40    /// This is the ed25519 verifying key that peers use to check signatures on
41    /// connection identifiers and challenge responses.
42    pub fn identity_pubkey_hex(&self) -> String {
43        hex::encode(VerifyingKey::from(&self.signing_key()).to_bytes())
44    }
45
46    /// This node's WireGuard public key, lowercase hex (64 chars).
47    #[cfg(not(target_arch = "wasm32"))]
48    pub fn wireguard_pubkey_hex(&self) -> String {
49        WgKeypair::from_secret_bytes(self.wg_secret).public_hex()
50    }
51
52    /// The node's overlay address, derived from its WireGuard public key.
53    ///
54    /// This is an `fd…` ULA IPv6 string produced by
55    /// [`crate::connection_identifier::derive_overlay_addr`].
56    #[cfg(not(target_arch = "wasm32"))]
57    pub fn overlay_addr(&self) -> String {
58        crate::connection_identifier::derive_overlay_addr(
59            &WgKeypair::from_secret_bytes(self.wg_secret).public_bytes(),
60        )
61    }
62
63    /// Load the persisted node identity, or generate and persist a fresh one.
64    ///
65    /// Reads `app_meta_dir()/node_identity.json`. If the file exists and parses,
66    /// it is returned as-is. Otherwise a new identity is generated from the OS
67    /// CSPRNG, written to that path (creating the parent directory as needed) as
68    /// pretty-printed JSON, and returned. All errors are mapped to `String`.
69    ///
70    /// Available on all targets: mesh WG helpers remain native-only, but the
71    /// Ed25519 apparatus identity is needed for multi-device person/fleet wiring.
72    pub fn load_or_create() -> Result<NodeIdentity, String> {
73        let path = crate::state::app_meta_dir().join("node_identity.json");
74
75        if path.exists() {
76            let bytes = std::fs::read(&path)
77                .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
78            if let Ok(identity) = serde_json::from_slice::<NodeIdentity>(&bytes) {
79                return Ok(identity);
80            }
81            // Fall through to regeneration if the existing file does not parse.
82        }
83
84        let mut e = [0u8; 32];
85        rand::fill(&mut e);
86        let mut w = [0u8; 32];
87        rand::fill(&mut w);
88        let identity = NodeIdentity {
89            ed25519_secret: e,
90            wg_secret: w,
91        };
92
93        if let Some(parent) = path.parent() {
94            std::fs::create_dir_all(parent)
95                .map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
96        }
97        let json = serde_json::to_string_pretty(&identity)
98            .map_err(|err| format!("failed to serialize node identity: {err}"))?;
99        std::fs::write(&path, json)
100            .map_err(|err| format!("failed to write {}: {err}", path.display()))?;
101
102        Ok(identity)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use ed25519_dalek::{Signer, Verifier};
110
111    fn sample() -> NodeIdentity {
112        NodeIdentity {
113            ed25519_secret: [7u8; 32],
114            wg_secret: [9u8; 32],
115        }
116    }
117
118    #[test]
119    fn identity_pubkey_hex_is_64_chars_and_deterministic() {
120        let id = sample();
121        let a = id.identity_pubkey_hex();
122        let b = id.identity_pubkey_hex();
123        assert_eq!(a.len(), 64, "identity pubkey hex must be 64 chars");
124        assert!(
125            a.chars().all(|c| c.is_ascii_hexdigit()),
126            "identity pubkey hex must be all hex digits: {a}"
127        );
128        assert_eq!(a, b, "identity pubkey hex must be deterministic");
129    }
130
131    #[test]
132    fn signing_key_produces_verifiable_signatures() {
133        let id = sample();
134        let sk = id.signing_key();
135        let msg = b"node challenge response";
136        let sig = sk.sign(msg);
137        let vk = VerifyingKey::from(&sk);
138        assert!(
139            vk.verify(msg, &sig).is_ok(),
140            "signature must verify under the derived verifying key"
141        );
142    }
143
144    #[cfg(not(target_arch = "wasm32"))]
145    #[test]
146    fn wireguard_pubkey_hex_is_64_chars() {
147        let id = sample();
148        let wg = id.wireguard_pubkey_hex();
149        assert_eq!(wg.len(), 64, "wireguard pubkey hex must be 64 chars");
150        assert!(
151            wg.chars().all(|c| c.is_ascii_hexdigit()),
152            "wireguard pubkey hex must be all hex digits: {wg}"
153        );
154    }
155
156    #[cfg(not(target_arch = "wasm32"))]
157    #[test]
158    fn overlay_addr_is_ula() {
159        let id = sample();
160        let addr = id.overlay_addr();
161        assert!(
162            addr.starts_with("fd"),
163            "overlay address must be an fd… ULA: {addr}"
164        );
165    }
166}