Skip to main content

void_crypto/
keys.rs

1//! Typed key newtypes for compile-time key safety.
2//!
3//! Public key types with hex/serde serialization:
4//! - `SigningPubKey` — Ed25519 public key for verifying signatures
5//! - `RecipientPubKey` — X25519 public key for ECIES encryption
6//! - `NostrPubKey` — Secp256k1 x-only public key for Nostr transport
7//!
8//! Composite types:
9//! - `WrappedKey` — ECIES-wrapped key blob (base64 serialized)
10//! - `ContributorId` — Combined identity (signing + recipient pubkeys)
11//! - `RepoKey` — Repository encryption key (zeroed on drop, redacted debug)
12//!
13//! These types prevent mixing up different key types at compile time.
14//! Secret key types live in `kdf.rs` (`define_secret_key_newtype!` macro).
15
16use std::fmt;
17
18use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
19
20// ============================================================================
21// Macro for 32-byte hex-encoded key newtypes
22// ============================================================================
23
24/// Defines a 32-byte key newtype with hex encoding/decoding and serde support.
25///
26/// Generated types include:
27/// - `from_bytes(bytes: [u8; 32]) -> Self`
28/// - `as_bytes(&self) -> &[u8; 32]`
29/// - `to_hex(&self) -> String`
30/// - `from_hex(s: &str) -> Result<Self, ParseError>`
31/// - `Debug` impl showing abbreviated hex (first 8 chars)
32/// - `Display` impl showing full hex
33/// - `Serialize`/`Deserialize` as hex string
34macro_rules! define_hex_key_newtype {
35    (
36        $(#[$meta:meta])*
37        $name:ident
38    ) => {
39        $(#[$meta])*
40        #[derive(Clone, Copy, PartialEq, Eq, Hash)]
41        pub struct $name([u8; 32]);
42
43        impl $name {
44            /// Create from raw bytes.
45            pub fn from_bytes(bytes: [u8; 32]) -> Self {
46                Self(bytes)
47            }
48
49            /// Access the underlying bytes.
50            pub fn as_bytes(&self) -> &[u8; 32] {
51                &self.0
52            }
53
54            /// Encode as hex string.
55            pub fn to_hex(&self) -> String {
56                hex::encode(self.0)
57            }
58
59            /// Parse from hex string.
60            pub fn from_hex(s: &str) -> Result<Self, ParseError> {
61                let bytes = hex::decode(s).map_err(|_| ParseError::InvalidHex)?;
62                let arr: [u8; 32] = bytes
63                    .try_into()
64                    .map_err(|_| ParseError::InvalidLength { expected: 32 })?;
65                Ok(Self(arr))
66            }
67        }
68
69        impl fmt::Debug for $name {
70            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71                write!(f, "{}({}…)", stringify!($name), &self.to_hex()[..8])
72            }
73        }
74
75        impl fmt::Display for $name {
76            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77                write!(f, "{}", self.to_hex())
78            }
79        }
80
81        impl Serialize for $name {
82            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83            where
84                S: Serializer,
85            {
86                serializer.serialize_str(&self.to_hex())
87            }
88        }
89
90        impl<'de> Deserialize<'de> for $name {
91            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92            where
93                D: Deserializer<'de>,
94            {
95                let s = String::deserialize(deserializer)?;
96                Self::from_hex(&s).map_err(de::Error::custom)
97            }
98        }
99    };
100}
101
102// ============================================================================
103// NostrPubKey — Secp256k1 x-only public key for Nostr transport
104// ============================================================================
105
106define_hex_key_newtype!(
107    /// Secp256k1 x-only (schnorr) public key for Nostr transport (32 bytes).
108    ///
109    /// Serializes to/from hex string in JSON.
110    NostrPubKey
111);
112
113// ============================================================================
114// SigningPubKey — Ed25519 public key for signature verification
115// ============================================================================
116
117define_hex_key_newtype!(
118    /// Ed25519 public key for verifying signatures (32 bytes).
119    ///
120    /// Serializes to/from hex string in JSON.
121    SigningPubKey
122);
123
124// ============================================================================
125// RecipientPubKey — X25519 public key for ECIES encryption
126// ============================================================================
127
128define_hex_key_newtype!(
129    /// X25519 public key for ECIES encryption (32 bytes).
130    ///
131    /// Serializes to/from hex string in JSON.
132    RecipientPubKey
133);
134
135// ============================================================================
136// CommitSignature — Ed25519 signature over commit signable bytes
137// ============================================================================
138
139/// Ed25519 signature over commit signable bytes (64 bytes).
140///
141/// Prevents accidentally mixing raw byte arrays with signature data.
142#[derive(Clone, Copy, PartialEq, Eq)]
143pub struct CommitSignature([u8; 64]);
144
145impl CommitSignature {
146    /// Create from raw bytes.
147    pub fn from_bytes(bytes: [u8; 64]) -> Self {
148        Self(bytes)
149    }
150
151    /// Access the underlying bytes.
152    pub fn as_bytes(&self) -> &[u8; 64] {
153        &self.0
154    }
155
156    /// Encode as hex string.
157    pub fn to_hex(&self) -> String {
158        hex::encode(self.0)
159    }
160}
161
162impl fmt::Debug for CommitSignature {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(f, "CommitSignature({}...)", &hex::encode(&self.0[..8]))
165    }
166}
167
168impl Serialize for CommitSignature {
169    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170    where
171        S: Serializer,
172    {
173        serializer.serialize_str(&self.to_hex())
174    }
175}
176
177impl<'de> Deserialize<'de> for CommitSignature {
178    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179    where
180        D: Deserializer<'de>,
181    {
182        let s = String::deserialize(deserializer)?;
183        let bytes = hex::decode(&s).map_err(de::Error::custom)?;
184        let arr: [u8; 64] = bytes
185            .try_into()
186            .map_err(|_| de::Error::custom("signature must be 64 bytes"))?;
187        Ok(Self(arr))
188    }
189}
190
191// ============================================================================
192// WrappedKey — ECIES-wrapped key blob
193// ============================================================================
194
195/// ECIES-wrapped key blob (variable length).
196///
197/// Serializes to/from base64 string in JSON/CBOR.
198#[derive(Clone, PartialEq, Eq)]
199pub struct WrappedKey(Vec<u8>);
200
201impl WrappedKey {
202    /// Create from raw bytes.
203    pub fn from_bytes(bytes: Vec<u8>) -> Self {
204        Self(bytes)
205    }
206
207    /// Access the underlying bytes.
208    pub fn as_bytes(&self) -> &[u8] {
209        &self.0
210    }
211
212    /// Consume and return the underlying bytes.
213    pub fn into_bytes(self) -> Vec<u8> {
214        self.0
215    }
216
217    /// Encode as base64 string.
218    pub fn to_base64(&self) -> String {
219        use base64::{engine::general_purpose::STANDARD, Engine};
220        STANDARD.encode(&self.0)
221    }
222
223    /// Parse from base64 string.
224    pub fn from_base64(s: &str) -> Result<Self, ParseError> {
225        use base64::{engine::general_purpose::STANDARD, Engine};
226        let bytes = STANDARD.decode(s).map_err(|_| ParseError::InvalidBase64)?;
227        Ok(Self(bytes))
228    }
229}
230
231impl fmt::Debug for WrappedKey {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(f, "WrappedKey({} bytes)", self.0.len())
234    }
235}
236
237impl Serialize for WrappedKey {
238    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
239    where
240        S: Serializer,
241    {
242        serializer.serialize_str(&self.to_base64())
243    }
244}
245
246impl<'de> Deserialize<'de> for WrappedKey {
247    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248    where
249        D: Deserializer<'de>,
250    {
251        let s = String::deserialize(deserializer)?;
252        Self::from_base64(&s).map_err(de::Error::custom)
253    }
254}
255
256// ============================================================================
257// ContributorId — Combined signing + recipient public keys
258// ============================================================================
259
260/// Full contributor identity combining signing and recipient public keys.
261///
262/// Format: `void://ed25519:<hex>/x25519:<hex>`
263#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
264pub struct ContributorId {
265    /// Ed25519 public key for signature verification.
266    pub signing: SigningPubKey,
267    /// X25519 public key for ECIES encryption.
268    pub recipient: RecipientPubKey,
269}
270
271impl ContributorId {
272    /// Create a new contributor identity.
273    pub fn new(signing: SigningPubKey, recipient: RecipientPubKey) -> Self {
274        Self { signing, recipient }
275    }
276
277    /// Parse from URI format: `void://ed25519:<hex>/x25519:<hex>`
278    pub fn from_uri(s: &str) -> Result<Self, ParseError> {
279        let s = s
280            .strip_prefix("void://")
281            .ok_or(ParseError::InvalidFormat("missing void:// prefix"))?;
282
283        let parts: Vec<&str> = s.split('/').collect();
284        if parts.len() != 2 {
285            return Err(ParseError::InvalidFormat(
286                "expected format: ed25519:<hex>/x25519:<hex>",
287            ));
288        }
289
290        let signing_hex = parts[0]
291            .strip_prefix("ed25519:")
292            .ok_or(ParseError::InvalidFormat("missing ed25519: prefix"))?;
293        let recipient_hex = parts[1]
294            .strip_prefix("x25519:")
295            .ok_or(ParseError::InvalidFormat("missing x25519: prefix"))?;
296
297        Ok(Self {
298            signing: SigningPubKey::from_hex(signing_hex)?,
299            recipient: RecipientPubKey::from_hex(recipient_hex)?,
300        })
301    }
302
303    /// Format as URI string.
304    pub fn to_uri(&self) -> String {
305        format!(
306            "void://ed25519:{}/x25519:{}",
307            self.signing.to_hex(),
308            self.recipient.to_hex()
309        )
310    }
311}
312
313impl fmt::Display for ContributorId {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "{}", self.to_uri())
316    }
317}
318
319// ============================================================================
320// RepoKey — Repository encryption key (AES-256)
321// ============================================================================
322
323/// Thin wrapper for repository encryption keys (AES-256, 32 bytes).
324///
325/// Distinct from signing keys to prevent accidental misuse.
326/// Debug output is redacted for security. Does NOT serialize to avoid
327/// accidental key exposure — use explicit methods if serialization is needed.
328///
329/// Note: This type is NOT `Copy` because it implements `Drop` to zero out
330/// memory when the key goes out of scope.
331#[derive(Clone, PartialEq, Eq)]
332pub struct RepoKey([u8; 32]);
333
334impl RepoKey {
335    /// Create from raw bytes.
336    pub fn from_bytes(bytes: [u8; 32]) -> Self {
337        Self(bytes)
338    }
339
340    /// Access the underlying bytes.
341    pub fn as_bytes(&self) -> &[u8; 32] {
342        &self.0
343    }
344}
345
346impl fmt::Debug for RepoKey {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        write!(f, "RepoKey([REDACTED])")
349    }
350}
351
352impl Drop for RepoKey {
353    fn drop(&mut self) {
354        // Zero out memory on drop for security
355        self.0.iter_mut().for_each(|b| *b = 0);
356    }
357}
358
359// ============================================================================
360// ParseError — Errors during key parsing
361// ============================================================================
362
363/// Error parsing key types from strings.
364#[derive(Debug, Clone, thiserror::Error)]
365pub enum ParseError {
366    #[error("invalid hex encoding")]
367    InvalidHex,
368
369    #[error("invalid base64 encoding")]
370    InvalidBase64,
371
372    #[error("invalid length: expected {expected} bytes")]
373    InvalidLength { expected: usize },
374
375    #[error("invalid format: {0}")]
376    InvalidFormat(&'static str),
377}
378
379// ============================================================================
380// Tests
381// ============================================================================
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn signing_pubkey_hex_roundtrip() {
389        let bytes = [0x42u8; 32];
390        let key = SigningPubKey::from_bytes(bytes);
391
392        let hex = key.to_hex();
393        let parsed = SigningPubKey::from_hex(&hex).unwrap();
394
395        assert_eq!(key, parsed);
396        assert_eq!(key.as_bytes(), &bytes);
397    }
398
399    #[test]
400    fn signing_pubkey_serde_roundtrip() {
401        let key = SigningPubKey::from_bytes([0xaa; 32]);
402
403        let json = serde_json::to_string(&key).unwrap();
404        assert!(json.contains(&"a".repeat(64)));
405
406        let parsed: SigningPubKey = serde_json::from_str(&json).unwrap();
407        assert_eq!(key, parsed);
408    }
409
410    #[test]
411    fn signing_pubkey_invalid_hex() {
412        assert!(SigningPubKey::from_hex("not-hex").is_err());
413        assert!(SigningPubKey::from_hex("aabb").is_err()); // too short
414    }
415
416    #[test]
417    fn recipient_pubkey_hex_roundtrip() {
418        let bytes = [0x99u8; 32];
419        let key = RecipientPubKey::from_bytes(bytes);
420
421        let hex = key.to_hex();
422        let parsed = RecipientPubKey::from_hex(&hex).unwrap();
423
424        assert_eq!(key, parsed);
425    }
426
427    #[test]
428    fn wrapped_key_base64_roundtrip() {
429        let bytes = vec![1, 2, 3, 4, 5, 6, 7, 8];
430        let key = WrappedKey::from_bytes(bytes.clone());
431
432        let b64 = key.to_base64();
433        let parsed = WrappedKey::from_base64(&b64).unwrap();
434
435        assert_eq!(parsed.as_bytes(), &bytes);
436    }
437
438    #[test]
439    fn wrapped_key_serde_roundtrip() {
440        let key = WrappedKey::from_bytes(vec![0xde, 0xad, 0xbe, 0xef]);
441
442        let json = serde_json::to_string(&key).unwrap();
443        let parsed: WrappedKey = serde_json::from_str(&json).unwrap();
444
445        assert_eq!(key, parsed);
446    }
447
448    #[test]
449    fn contributor_id_uri_roundtrip() {
450        let signing = SigningPubKey::from_bytes([0xaa; 32]);
451        let recipient = RecipientPubKey::from_bytes([0xbb; 32]);
452        let id = ContributorId::new(signing, recipient);
453
454        let uri = id.to_uri();
455        assert!(uri.starts_with("void://ed25519:"));
456        assert!(uri.contains("/x25519:"));
457
458        let parsed = ContributorId::from_uri(&uri).unwrap();
459        assert_eq!(id, parsed);
460    }
461
462    #[test]
463    fn contributor_id_parse_errors() {
464        assert!(ContributorId::from_uri("ed25519:aa/x25519:bb").is_err());
465        assert!(ContributorId::from_uri("void://ed25519:aa").is_err());
466        assert!(ContributorId::from_uri(&format!(
467            "void://{}/x25519:{}",
468            "a".repeat(64),
469            "b".repeat(64)
470        ))
471        .is_err());
472    }
473
474    #[test]
475    fn contributor_id_serde_roundtrip() {
476        let id = ContributorId::new(
477            SigningPubKey::from_bytes([0x11; 32]),
478            RecipientPubKey::from_bytes([0x22; 32]),
479        );
480
481        let json = serde_json::to_string(&id).unwrap();
482        let parsed: ContributorId = serde_json::from_str(&json).unwrap();
483
484        assert_eq!(id, parsed);
485    }
486
487    #[test]
488    fn debug_formats_are_concise() {
489        let signing = SigningPubKey::from_bytes([0xab; 32]);
490        let debug = format!("{:?}", signing);
491        assert!(debug.contains("abababab"));
492        assert!(debug.len() < 50);
493
494        let wrapped = WrappedKey::from_bytes(vec![0; 100]);
495        let debug = format!("{:?}", wrapped);
496        assert!(debug.contains("100 bytes"));
497    }
498
499    #[test]
500    fn repo_key_creation_and_access() {
501        let bytes = [0x42u8; 32];
502        let repo_key = RepoKey::from_bytes(bytes);
503        assert_eq!(repo_key.as_bytes(), &bytes);
504    }
505
506    #[test]
507    fn repo_key_debug_is_redacted() {
508        let repo_key = RepoKey::from_bytes([0x42u8; 32]);
509        let debug = format!("{:?}", repo_key);
510        assert!(!debug.contains("42"));
511        assert!(debug.contains("REDACTED"));
512    }
513
514    #[test]
515    fn repo_key_equality() {
516        let key1 = RepoKey::from_bytes([0x42u8; 32]);
517        let key2 = RepoKey::from_bytes([0x42u8; 32]);
518        let key3 = RepoKey::from_bytes([0x43u8; 32]);
519
520        assert_eq!(key1, key2);
521        assert_ne!(key1, key3);
522    }
523}