Skip to main content

void_crypto/
identity.rs

1//! Identity management for void collaboration.
2//!
3//! This module provides:
4//! - Ed25519 signing keys for authentication
5//! - X25519 recipient keys for ECIES encryption
6//! - Secp256k1 keys for Nostr transport (optional)
7//! - Identity string format for sharing public keys
8
9use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10use rand::RngCore;
11use x25519_dalek::{PublicKey, StaticSecret};
12
13use crate::ecies::{perform_dh_and_derive, EciesError};
14use crate::kdf::{
15    NostrSecretKey, RecipientSecretKey, SigningSecretKey,
16};
17use crate::keys::{
18    NostrPubKey, RecipientPubKey, RepoKey, SigningPubKey, WrappedKey,
19};
20use crate::seed;
21use crate::seed::SeedError;
22use crate::{decrypt, encrypt, CryptoError, CryptoResult};
23
24/// ECIES encryption overhead: ephemeral_pubkey (32) + nonce (12) + tag (16)
25const ECIES_OVERHEAD: usize = 32 + 12 + 16;
26
27/// AAD for ECIES share message encryption
28const AAD_ECIES: &[u8] = b"void:ecies:v1";
29
30/// Errors that can occur in identity operations
31#[derive(Debug, thiserror::Error)]
32pub enum IdentityError {
33    #[error("invalid ciphertext length")]
34    InvalidCiphertextLength,
35
36    #[error("decryption failed")]
37    DecryptionFailed,
38
39    #[error("encryption failed")]
40    EncryptionFailed,
41
42    #[error("invalid identity string format")]
43    InvalidIdentityString,
44
45    #[error("invalid hex encoding")]
46    InvalidHex,
47
48    #[error("invalid shared secret: possible low-order point attack")]
49    InvalidSharedSecret,
50
51    #[error("seed derivation failed: {0}")]
52    SeedDerivation(#[from] SeedError),
53}
54
55/// An identity containing signing, recipient, and optional Nostr keys
56///
57/// - `signing`: Ed25519 key for creating digital signatures
58/// - `recipient`: X25519 key for receiving encrypted messages via ECIES
59/// - `nostr`: Optional secp256k1 key for Nostr transport (Phase 2)
60pub struct Identity {
61    signing: SigningKey,
62    recipient: StaticSecret,
63    nostr: Option<NostrSecretKey>,
64}
65
66impl Identity {
67    /// Generate a new random identity (including Nostr key)
68    pub fn generate() -> Self {
69        let mut signing_bytes = [0u8; 32];
70        let mut recipient_bytes = [0u8; 32];
71        let mut nostr_bytes = [0u8; 32];
72
73        let mut rng = rand::thread_rng();
74        rng.fill_bytes(&mut signing_bytes);
75        rng.fill_bytes(&mut recipient_bytes);
76        rng.fill_bytes(&mut nostr_bytes);
77
78        Self {
79            signing: SigningKey::from_bytes(&signing_bytes),
80            recipient: StaticSecret::from(recipient_bytes),
81            nostr: Some(NostrSecretKey::from_bytes(nostr_bytes)),
82        }
83    }
84
85    /// Create from raw key bytes (backward-compatible: no Nostr key)
86    pub fn from_bytes(signing: &SigningSecretKey, recipient: &RecipientSecretKey) -> Self {
87        Self {
88            signing: SigningKey::from_bytes(signing.as_bytes()),
89            recipient: StaticSecret::from(*recipient.as_bytes()),
90            nostr: None,
91        }
92    }
93
94    /// Create from raw key bytes including a Nostr key
95    pub fn from_bytes_with_nostr(
96        signing: &SigningSecretKey,
97        recipient: &RecipientSecretKey,
98        nostr: NostrSecretKey,
99    ) -> Self {
100        Self {
101            signing: SigningKey::from_bytes(signing.as_bytes()),
102            recipient: StaticSecret::from(*recipient.as_bytes()),
103            nostr: Some(nostr),
104        }
105    }
106
107    /// Create an identity from a BIP-39 seed using deterministic key derivation.
108    ///
109    /// The same seed always produces the same signing, recipient, and Nostr keys,
110    /// enabling identity recovery from a mnemonic phrase.
111    pub fn from_seed(
112        seed: &crate::kdf::IdentitySeed,
113    ) -> Result<Self, IdentityError> {
114        let signing_secret = seed::derive_signing_key(seed)?;
115        let recipient_secret = seed::derive_recipient_key(seed)?;
116        let nostr_secret = seed::derive_nostr_key(seed)?;
117        Ok(Self::from_bytes_with_nostr(&signing_secret, &recipient_secret, nostr_secret))
118    }
119
120    /// Generate a new identity with a BIP-39 mnemonic for recovery.
121    ///
122    /// Returns the identity and the 24-word mnemonic phrase. The mnemonic
123    /// must be stored securely by the user — it's the only way to recover
124    /// the identity if the encrypted key files are lost.
125    pub fn generate_with_mnemonic() -> Result<(Self, String), IdentityError> {
126        let mnemonic = seed::generate_mnemonic();
127        let seed_val = seed::mnemonic_to_seed(&mnemonic)?;
128        let identity = Self::from_seed(&seed_val)?;
129        Ok((identity, mnemonic))
130    }
131
132    /// Get the Ed25519 signing public key
133    pub fn signing_pubkey(&self) -> SigningPubKey {
134        SigningPubKey::from_bytes(self.signing.verifying_key().to_bytes())
135    }
136
137    /// Get the X25519 recipient public key
138    pub fn recipient_pubkey(&self) -> RecipientPubKey {
139        RecipientPubKey::from_bytes(PublicKey::from(&self.recipient).to_bytes())
140    }
141
142    /// Get the raw signing key bytes (for storage)
143    pub fn signing_key_bytes(&self) -> SigningSecretKey {
144        SigningSecretKey::from_bytes(self.signing.to_bytes())
145    }
146
147    /// Get the Ed25519 signing key (for signing operations)
148    pub fn signing_key(&self) -> &SigningKey {
149        &self.signing
150    }
151
152    /// Get the raw recipient key bytes (for storage)
153    pub fn recipient_key_bytes(&self) -> RecipientSecretKey {
154        RecipientSecretKey::from_bytes(self.recipient.to_bytes())
155    }
156
157    /// Get the Nostr x-only public key (if present)
158    pub fn nostr_pubkey(&self) -> Option<NostrPubKey> {
159        self.nostr.as_ref().map(|secret| {
160            let sk = secp256k1::SecretKey::from_slice(secret.as_bytes())
161                .expect("valid 32-byte key from HKDF");
162            let kp = secp256k1::Keypair::from_secret_key(secp256k1::SECP256K1, &sk);
163            let (xonly, _parity) = kp.x_only_public_key();
164            NostrPubKey::from_bytes(xonly.serialize())
165        })
166    }
167
168    /// Get the raw Nostr secret key bytes (for storage)
169    pub fn nostr_key_bytes(&self) -> Option<NostrSecretKey> {
170        self.nostr.clone()
171    }
172
173    /// Sign a message with Ed25519
174    pub fn sign(&self, message: &[u8]) -> [u8; 64] {
175        self.signing.sign(message).to_bytes()
176    }
177
178    /// Verify a signature (static method, doesn't need identity)
179    pub fn verify(pubkey: &SigningPubKey, message: &[u8], signature: &[u8; 64]) -> bool {
180        let Ok(verifying_key) = VerifyingKey::from_bytes(pubkey.as_bytes()) else {
181            return false;
182        };
183
184        let signature = Signature::from_bytes(signature);
185        verifying_key.verify(message, &signature).is_ok()
186    }
187
188    /// ECIES encrypt for a recipient (static - anyone can encrypt to a pubkey)
189    ///
190    /// Format: ephemeral_pubkey (32) || nonce (12) || ciphertext || tag (16)
191    ///
192    /// Uses X25519 DH + HKDF-SHA256 + AES-256-GCM
193    pub fn encrypt_for(
194        recipient_pubkey: &RecipientPubKey,
195        plaintext: &[u8],
196    ) -> Result<Vec<u8>, IdentityError> {
197        // Generate ephemeral X25519 keypair
198        let mut ephemeral_bytes = [0u8; 32];
199        rand::thread_rng().fill_bytes(&mut ephemeral_bytes);
200        let ephemeral_x25519 = StaticSecret::from(ephemeral_bytes);
201        let ephemeral_secret = RecipientSecretKey::from_bytes(ephemeral_bytes);
202        let ephemeral_public = RecipientPubKey::from_bytes(
203            PublicKey::from(&ephemeral_x25519).to_bytes(),
204        );
205
206        // Perform DH and derive encryption key
207        let encryption_key = perform_dh_and_derive(&ephemeral_secret, recipient_pubkey)
208            .map_err(|e| match e {
209                EciesError::InvalidSharedSecret => IdentityError::InvalidSharedSecret,
210                EciesError::KeyDerivationFailed => IdentityError::DecryptionFailed,
211            })?;
212
213        // Encrypt with AES-256-GCM using AAD for integrity
214        let encrypted = encrypt(encryption_key.as_bytes(), plaintext, AAD_ECIES)
215            .map_err(|_| IdentityError::EncryptionFailed)?;
216
217        // Output: ephemeral_pubkey || nonce || ciphertext || tag
218        let mut output = Vec::with_capacity(32 + encrypted.len());
219        output.extend_from_slice(ephemeral_public.as_bytes());
220        output.extend_from_slice(&encrypted);
221
222        Ok(output)
223    }
224
225    /// ECIES decrypt with our recipient key
226    pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, IdentityError> {
227        // Minimum size: ephemeral_pubkey (32) + nonce (12) + tag (16)
228        if ciphertext.len() < ECIES_OVERHEAD {
229            return Err(IdentityError::InvalidCiphertextLength);
230        }
231
232        // Parse ephemeral public key
233        let ephemeral_pubkey_bytes: [u8; 32] = ciphertext[..32]
234            .try_into()
235            .map_err(|_| IdentityError::InvalidCiphertextLength)?;
236        let ephemeral_public = RecipientPubKey::from_bytes(ephemeral_pubkey_bytes);
237
238        // Perform DH and derive decryption key
239        let our_secret = RecipientSecretKey::from_bytes(self.recipient.to_bytes());
240        let decryption_key = perform_dh_and_derive(&our_secret, &ephemeral_public)
241            .map_err(|e| match e {
242                EciesError::InvalidSharedSecret => IdentityError::InvalidSharedSecret,
243                EciesError::KeyDerivationFailed => IdentityError::DecryptionFailed,
244            })?;
245
246        // Decrypt with AES-256-GCM using AAD for integrity
247        let encrypted = &ciphertext[32..];
248        decrypt(decryption_key.as_bytes(), encrypted, AAD_ECIES)
249            .map_err(|_| IdentityError::DecryptionFailed)
250    }
251
252    /// Format identity pubkeys as shareable string
253    ///
254    /// Format: `void://ed25519:<hex>/x25519:<hex>[/nostr:<hex>]`
255    pub fn to_identity_string(&self) -> String {
256        let signing_hex = self.signing_pubkey().to_hex();
257        let recipient_hex = self.recipient_pubkey().to_hex();
258        let mut s = format!("void://ed25519:{signing_hex}/x25519:{recipient_hex}");
259        if let Some(nostr) = self.nostr_pubkey() {
260            s.push_str(&format!("/nostr:{}", nostr.to_hex()));
261        }
262        s
263    }
264
265    /// Format identity pubkeys with username as shareable string
266    ///
267    /// Format: `void://alice@ed25519:<hex>/x25519:<hex>[/nostr:<hex>]`
268    pub fn to_identity_string_with_username(&self, username: &str) -> String {
269        let signing_hex = self.signing_pubkey().to_hex();
270        let recipient_hex = self.recipient_pubkey().to_hex();
271        let mut s = format!("void://{username}@ed25519:{signing_hex}/x25519:{recipient_hex}");
272        if let Some(nostr) = self.nostr_pubkey() {
273            s.push_str(&format!("/nostr:{}", nostr.to_hex()));
274        }
275        s
276    }
277
278    /// Parse identity string into a `ParsedIdentity` with optional username.
279    ///
280    /// Supports formats (all backward-compatible):
281    /// - `void://ed25519:<hex>/x25519:<hex>` (legacy, no nostr)
282    /// - `void://ed25519:<hex>/x25519:<hex>/nostr:<hex>` (with nostr)
283    /// - `void://alice@ed25519:<hex>/x25519:<hex>[/nostr:<hex>]` (with username)
284    pub fn parse_identity_string(s: &str) -> Result<ParsedIdentity, IdentityError> {
285        let s = s
286            .strip_prefix("void://")
287            .ok_or(IdentityError::InvalidIdentityString)?;
288
289        // Check for username@... prefix
290        let (username, key_part) = if let Some(at_pos) = s.find('@') {
291            let name = &s[..at_pos];
292            if name.is_empty() {
293                return Err(IdentityError::InvalidIdentityString);
294            }
295            (Some(name.to_string()), &s[at_pos + 1..])
296        } else {
297            (None, s)
298        };
299
300        let parts: Vec<&str> = key_part.split('/').collect();
301        if parts.len() < 2 || parts.len() > 3 {
302            return Err(IdentityError::InvalidIdentityString);
303        }
304
305        let signing = parse_prefixed_key(parts[0], "ed25519:")?;
306        let recipient = parse_prefixed_key(parts[1], "x25519:")?;
307
308        let nostr_pubkey = if parts.len() == 3 {
309            let nostr = parse_prefixed_key(parts[2], "nostr:")?;
310            Some(NostrPubKey::from_bytes(nostr))
311        } else {
312            None
313        };
314
315        Ok(ParsedIdentity {
316            username,
317            signing_pubkey: SigningPubKey::from_bytes(signing),
318            recipient_pubkey: RecipientPubKey::from_bytes(recipient),
319            nostr_pubkey,
320        })
321    }
322
323    /// Backward-compatible alias for `parse_identity_string()`.
324    pub fn parse_identity_string_full(s: &str) -> Result<ParsedIdentity, IdentityError> {
325        Self::parse_identity_string(s)
326    }
327}
328
329/// Derive a per-repo owner Ed25519 signing key from an identity seed and repo ID.
330///
331/// The repo owner key is used for governance actions (policy updates,
332/// contributor management, ownership transfer). It is NOT used for
333/// commit signing — that remains the identity key's job.
334///
335/// This is a convenience wrapper around `seed::derive_repo_owner_key()` that
336/// returns an `ed25519_dalek::SigningKey` ready for use.
337pub fn derive_repo_owner_signing_key(
338    seed_val: &crate::kdf::IdentitySeed,
339    repo_id: &str,
340) -> Result<SigningKey, IdentityError> {
341    let secret = seed::derive_repo_owner_key(seed_val, repo_id)?;
342    Ok(SigningKey::from_bytes(secret.as_bytes()))
343}
344
345// ============================================================================
346// ECIES Key Wrapping
347// ============================================================================
348
349/// Wrap a repository key using ECIES for a recipient's X25519 public key.
350///
351/// This encrypts the 32-byte repo key so only the holder of the corresponding
352/// X25519 private key can decrypt it. Used to distribute repo access keys to
353/// individual contributors.
354pub fn ecies_wrap_key(
355    key: &RepoKey,
356    recipient_pubkey: &RecipientPubKey,
357) -> CryptoResult<WrappedKey> {
358    let ciphertext = Identity::encrypt_for(recipient_pubkey, key.as_bytes())
359        .map_err(|e| CryptoError::Encryption(format!("failed to wrap key: {}", e)))?;
360    Ok(WrappedKey::from_bytes(ciphertext))
361}
362
363/// Unwrap a repository key using the identity's X25519 private key.
364///
365/// Decrypts an ECIES-wrapped key blob to recover the original 32-byte repo key.
366pub fn ecies_unwrap_key(
367    wrapped: &WrappedKey,
368    identity: &Identity,
369) -> CryptoResult<RepoKey> {
370    let plaintext = identity
371        .decrypt(wrapped.as_bytes())
372        .map_err(|e| CryptoError::Decryption(format!("failed to unwrap key: {}", e)))?;
373
374    let arr: [u8; 32] = plaintext
375        .try_into()
376        .map_err(|_| CryptoError::Decryption("unwrapped key is not 32 bytes".into()))?;
377    Ok(RepoKey::from_bytes(arr))
378}
379
380/// Parsed identity containing public keys and optional username.
381///
382/// Returned by `Identity::parse_identity_string_full()`.
383#[derive(Debug, Clone)]
384pub struct ParsedIdentity {
385    /// Optional username (present in `void://alice@...` format).
386    pub username: Option<String>,
387    /// Ed25519 public key for signature verification.
388    pub signing_pubkey: SigningPubKey,
389    /// X25519 public key for ECIES encryption.
390    pub recipient_pubkey: RecipientPubKey,
391    /// Optional secp256k1 x-only public key for Nostr transport.
392    pub nostr_pubkey: Option<NostrPubKey>,
393}
394
395fn parse_prefixed_key(s: &str, prefix: &str) -> Result<[u8; 32], IdentityError> {
396    let hex_str = s
397        .strip_prefix(prefix)
398        .ok_or(IdentityError::InvalidIdentityString)?;
399    let bytes = hex::decode(hex_str).map_err(|_| IdentityError::InvalidHex)?;
400    bytes
401        .try_into()
402        .map_err(|_| IdentityError::InvalidIdentityString)
403}