saorsa_core/attestation/entangled_id.rs
1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9
10//! Entangled Identity implementation.
11//!
12//! An Entangled Identity binds a node's cryptographic identity to:
13//! - Its public key (ML-DSA-65)
14//! - The hash of its executing binary
15//! - A unique nonce
16//!
17//! The derivation formula is:
18//! ```text
19//! N_ID = BLAKE3(PK || binary_hash || nonce)
20//! ```
21//!
22//! ## zkVM Compatibility
23//!
24//! The core derivation logic is provided by `saorsa-logic`, which is
25//! `no_std` compatible and can run inside zkVMs (SP1, RISC Zero).
26//! This allows nodes to generate zero-knowledge proofs of correct
27//! identity derivation.
28
29use crate::identity::node_identity::NodeId;
30use crate::quantum_crypto::ant_quic_integration::MlDsaPublicKey;
31use serde::{Deserialize, Serialize};
32use std::fmt;
33
34// Re-export constants from saorsa-logic for consistency and downstream use
35#[allow(unused_imports)]
36pub use saorsa_logic::attestation::{ENTANGLED_ID_SIZE, HASH_SIZE, ML_DSA_65_PUBLIC_KEY_SIZE};
37
38/// An Entangled Identity that binds a node's ID to its software.
39///
40/// This structure represents a node identity that is cryptographically
41/// entangled with the binary it is running, preventing identity spoofing
42/// while running modified software.
43#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub struct EntangledId {
45 /// The derived 32-byte identity: BLAKE3(PK || binary_hash || nonce)
46 id: [u8; 32],
47
48 /// The hash of the binary this identity is bound to
49 binary_hash: [u8; 32],
50
51 /// The nonce used in derivation
52 nonce: u64,
53}
54
55impl EntangledId {
56 /// Derive an entangled identity from its components.
57 ///
58 /// The derivation formula is:
59 /// ```text
60 /// N_ID = BLAKE3(PK || binary_hash || nonce)
61 /// ```
62 ///
63 /// # Arguments
64 ///
65 /// * `public_key` - The ML-DSA-65 public key
66 /// * `binary_hash` - The BLAKE3 hash of the binary
67 /// * `nonce` - A unique nonce (e.g., timestamp or random)
68 ///
69 /// # Returns
70 ///
71 /// A new `EntangledId` with the derived identity.
72 ///
73 /// # zkVM Compatibility
74 ///
75 /// This method uses `saorsa-logic` for the core derivation, which is
76 /// `no_std` compatible and can run inside zkVMs.
77 #[must_use]
78 pub fn derive(public_key: &MlDsaPublicKey, binary_hash: &[u8; 32], nonce: u64) -> Self {
79 // Delegate to saorsa-logic for zkVM-compatible derivation
80 let id = saorsa_logic::attestation::derive_entangled_id(
81 public_key.as_bytes(),
82 binary_hash,
83 nonce,
84 );
85 Self {
86 id,
87 binary_hash: *binary_hash,
88 nonce,
89 }
90 }
91
92 /// Verify that this entangled ID matches the given public key.
93 ///
94 /// This re-derives the ID from the public key and the stored binary hash/nonce,
95 /// then compares with the stored ID.
96 ///
97 /// # Arguments
98 ///
99 /// * `public_key` - The ML-DSA-65 public key to verify against
100 ///
101 /// # Returns
102 ///
103 /// `true` if the ID was derived from this public key and the stored binary hash/nonce.
104 ///
105 /// # zkVM Compatibility
106 ///
107 /// This method uses `saorsa-logic` for verification, enabling the same
108 /// logic to be proven inside a zkVM.
109 #[must_use]
110 pub fn verify(&self, public_key: &MlDsaPublicKey) -> bool {
111 // Delegate to saorsa-logic for zkVM-compatible verification
112 saorsa_logic::attestation::verify_entangled_id(
113 &self.id,
114 public_key.as_bytes(),
115 &self.binary_hash,
116 self.nonce,
117 )
118 }
119
120 /// Verify that this entangled ID matches the given public key and binary hash.
121 ///
122 /// This is a stricter verification that also checks the binary hash matches.
123 ///
124 /// # Arguments
125 ///
126 /// * `public_key` - The ML-DSA-65 public key to verify against
127 /// * `binary_hash` - The expected binary hash
128 ///
129 /// # Returns
130 ///
131 /// `true` if the ID was derived from this public key, this binary hash, and the stored nonce.
132 #[must_use]
133 pub fn verify_with_binary(&self, public_key: &MlDsaPublicKey, binary_hash: &[u8; 32]) -> bool {
134 // First check that the binary hash matches what we stored
135 if &self.binary_hash != binary_hash {
136 return false;
137 }
138
139 // Then verify the full derivation
140 self.verify(public_key)
141 }
142
143 /// Get the raw 32-byte identity.
144 #[must_use]
145 pub fn id(&self) -> &[u8; 32] {
146 &self.id
147 }
148
149 /// Get the binary hash this identity is bound to.
150 #[must_use]
151 pub fn binary_hash(&self) -> &[u8; 32] {
152 &self.binary_hash
153 }
154
155 /// Get the nonce used in derivation.
156 #[must_use]
157 pub fn nonce(&self) -> u64 {
158 self.nonce
159 }
160
161 /// Convert to a `NodeId` for use in DHT routing.
162 ///
163 /// The `NodeId` is simply the entangled ID bytes.
164 #[must_use]
165 pub fn to_node_id(&self) -> NodeId {
166 NodeId::from_bytes(self.id)
167 }
168
169 /// Calculate XOR distance to another entangled ID.
170 ///
171 /// This is used for Kademlia routing.
172 ///
173 /// # zkVM Compatibility
174 ///
175 /// This method uses `saorsa-logic` for the calculation, enabling
176 /// consistent distance computation across native and zkVM contexts.
177 #[must_use]
178 pub fn xor_distance(&self, other: &EntangledId) -> [u8; 32] {
179 saorsa_logic::attestation::xor_distance(&self.id, &other.id)
180 }
181
182 /// Create from raw bytes (for deserialization/testing).
183 ///
184 /// # Warning
185 ///
186 /// This bypasses the derivation process and should only be used
187 /// for deserialization or testing purposes.
188 #[must_use]
189 pub fn from_raw(id: [u8; 32], binary_hash: [u8; 32], nonce: u64) -> Self {
190 Self {
191 id,
192 binary_hash,
193 nonce,
194 }
195 }
196}
197
198impl fmt::Display for EntangledId {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 // Display first 8 bytes as hex for brevity
201 write!(f, "{}", hex::encode(&self.id[..8]))
202 }
203}
204
205impl fmt::Debug for EntangledId {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 f.debug_struct("EntangledId")
208 .field("id", &hex::encode(&self.id[..8]))
209 .field("binary_hash", &hex::encode(&self.binary_hash[..8]))
210 .field("nonce", &self.nonce)
211 .finish()
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::quantum_crypto::generate_ml_dsa_keypair;
219
220 #[test]
221 fn test_derive_deterministic() {
222 let (pk, _) = generate_ml_dsa_keypair().unwrap();
223 let binary_hash = [0x42u8; 32];
224 let nonce = 12345u64;
225
226 let id1 = EntangledId::derive(&pk, &binary_hash, nonce);
227 let id2 = EntangledId::derive(&pk, &binary_hash, nonce);
228
229 assert_eq!(id1, id2);
230 }
231
232 #[test]
233 fn test_different_keys_different_ids() {
234 let (pk1, _) = generate_ml_dsa_keypair().unwrap();
235 let (pk2, _) = generate_ml_dsa_keypair().unwrap();
236 let binary_hash = [0x42u8; 32];
237 let nonce = 12345u64;
238
239 let id1 = EntangledId::derive(&pk1, &binary_hash, nonce);
240 let id2 = EntangledId::derive(&pk2, &binary_hash, nonce);
241
242 assert_ne!(id1.id(), id2.id());
243 }
244
245 #[test]
246 fn test_different_binaries_different_ids() {
247 let (pk, _) = generate_ml_dsa_keypair().unwrap();
248 let binary_hash1 = [0x42u8; 32];
249 let binary_hash2 = [0x43u8; 32];
250 let nonce = 12345u64;
251
252 let id1 = EntangledId::derive(&pk, &binary_hash1, nonce);
253 let id2 = EntangledId::derive(&pk, &binary_hash2, nonce);
254
255 assert_ne!(id1.id(), id2.id());
256 }
257
258 #[test]
259 fn test_verification() {
260 let (pk, _) = generate_ml_dsa_keypair().unwrap();
261 let binary_hash = [0x42u8; 32];
262 let nonce = 12345u64;
263
264 let id = EntangledId::derive(&pk, &binary_hash, nonce);
265
266 assert!(id.verify(&pk));
267 }
268
269 #[test]
270 fn test_verification_wrong_key() {
271 let (pk1, _) = generate_ml_dsa_keypair().unwrap();
272 let (pk2, _) = generate_ml_dsa_keypair().unwrap();
273 let binary_hash = [0x42u8; 32];
274 let nonce = 12345u64;
275
276 let id = EntangledId::derive(&pk1, &binary_hash, nonce);
277
278 assert!(!id.verify(&pk2));
279 }
280
281 #[test]
282 fn test_xor_distance_self() {
283 let (pk, _) = generate_ml_dsa_keypair().unwrap();
284 let id = EntangledId::derive(&pk, &[0u8; 32], 0);
285
286 let distance = id.xor_distance(&id);
287 assert_eq!(distance, [0u8; 32]);
288 }
289
290 #[test]
291 fn test_to_node_id() {
292 let (pk, _) = generate_ml_dsa_keypair().unwrap();
293 let id = EntangledId::derive(&pk, &[0u8; 32], 0);
294 let node_id = id.to_node_id();
295
296 assert_eq!(node_id.to_bytes(), id.id());
297 }
298
299 #[test]
300 fn test_serialization_roundtrip() {
301 let (pk, _) = generate_ml_dsa_keypair().unwrap();
302 let id = EntangledId::derive(&pk, &[0x42u8; 32], 12345);
303
304 let json = serde_json::to_string(&id).unwrap();
305 let restored: EntangledId = serde_json::from_str(&json).unwrap();
306
307 assert_eq!(id, restored);
308 }
309}