Skip to main content

crypto_vote/
types.rs

1//! Public data types exchanged across the API boundary.
2//!
3//! All four types in this module are thin wrappers around their
4//! underlying curve / scalar representation. They exist so that:
5//!
6//!  - the public API never leaks `curve25519_dalek` types directly,
7//!    which would force every caller to take a dependency on the same
8//!    version of that crate;
9//!  - every value has exactly one canonical byte and hex encoding;
10//!  - the type system stops you from accidentally passing, say, a
11//!    [`KeyImage`] where a [`PublicKey`] is expected — they are both
12//!    32-byte Ristretto points, but they mean different things in the
13//!    protocol.
14//!
15//! Everything serialises to a fixed-size byte array (or a `Vec<u8>` for
16//! signatures whose size depends on the ring). The encoding is the
17//! curve25519-dalek canonical encoding for points (compressed Ristretto)
18//! and the little-endian canonical encoding for scalars.
19
20use crate::encoding::{self, Tag};
21use crate::error::{Error, Result};
22use core::fmt;
23use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint};
24use curve25519_dalek::scalar::Scalar;
25use curve25519_dalek::traits::IsIdentity;
26use zeroize::Zeroize;
27
28/// A voter's public identity. Safe to publish.
29///
30/// Internally a Ristretto255 point; externally 32 bytes.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct PublicKey {
33    pub(crate) point: RistrettoPoint,
34}
35
36// `RistrettoPoint` does not implement `Hash`, but the compressed
37// 32-byte encoding is canonical, so hashing through it is sound and
38// agrees with `PartialEq`.
39impl core::hash::Hash for PublicKey {
40    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
41        self.to_bytes().hash(state);
42    }
43}
44
45impl PublicKey {
46    /// Encode the key as 32 canonical bytes (compressed Ristretto).
47    pub fn to_bytes(&self) -> [u8; 32] {
48        self.point.compress().to_bytes()
49    }
50
51    /// Decode 32 bytes produced by [`PublicKey::to_bytes`].
52    ///
53    /// Returns [`Error::InvalidPoint`] if the bytes are not the canonical
54    /// encoding of a point in the Ristretto255 group.
55    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
56        let compressed = CompressedRistretto::from_slice(bytes).map_err(|_| Error::InvalidPoint)?;
57        let point = compressed.decompress().ok_or(Error::InvalidPoint)?;
58        if point.is_identity() {
59            return Err(Error::InvalidIdentityPoint);
60        }
61        Ok(PublicKey { point })
62    }
63
64    /// Hex-encode using lowercase digits (64 characters).
65    pub fn to_hex(&self) -> String {
66        hex::encode(self.to_bytes())
67    }
68
69    /// Decode from a 64-character hex string.
70    pub fn from_hex(s: &str) -> Result<Self> {
71        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
72        let arr: [u8; 32] = bytes
73            .as_slice()
74            .try_into()
75            .map_err(|_| Error::InvalidLength {
76                what: "PublicKey",
77                expected: 32,
78                got: bytes.len(),
79            })?;
80        Self::from_bytes(&arr)
81    }
82
83    /// Encode in the human-friendly prefixed format: `pk_<hex>_<checksum>`.
84    ///
85    /// Same bytes as [`PublicKey::to_hex`], wrapped with a `pk_` tag and a
86    /// checksum. See [`crate::encoding`].
87    pub fn to_prefixed(&self) -> String {
88        encoding::encode_prefixed(Tag::PublicKey, &self.to_bytes())
89    }
90
91    /// Decode a `pk_<hex>_<checksum>` string produced by
92    /// [`PublicKey::to_prefixed`], verifying the tag and the checksum.
93    pub fn from_prefixed(s: &str) -> Result<Self> {
94        let bytes = encoding::decode_prefixed(Tag::PublicKey, s)?;
95        let arr: [u8; 32] = bytes
96            .as_slice()
97            .try_into()
98            .map_err(|_| Error::InvalidLength {
99                what: "PublicKey",
100                expected: 32,
101                got: bytes.len(),
102            })?;
103        Self::from_bytes(&arr)
104    }
105}
106
107/// A voter's secret key.
108///
109/// Internally a Ristretto255 scalar; externally 32 bytes. Treat the
110/// encoded form with the same care as any other private key — it should
111/// never be transmitted off the voter's device.
112///
113/// `SecretKey` is intentionally **not** `Clone`. Every copy of a secret
114/// scalar is one more memory region to keep track of and zeroise; if a
115/// caller really needs to duplicate one, they should re-decode it from
116/// the same byte representation and accept the duplication explicitly.
117pub struct SecretKey {
118    pub(crate) scalar: Scalar,
119}
120
121impl fmt::Debug for SecretKey {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.write_str("SecretKey(..)")
124    }
125}
126
127impl Drop for SecretKey {
128    fn drop(&mut self) {
129        self.scalar.zeroize();
130    }
131}
132
133impl SecretKey {
134    /// Derive the matching [`PublicKey`].
135    pub fn public_key(&self) -> PublicKey {
136        PublicKey {
137            point: self.scalar * curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT,
138        }
139    }
140
141    /// Encode the scalar as 32 little-endian bytes.
142    pub fn to_bytes(&self) -> [u8; 32] {
143        self.scalar.to_bytes()
144    }
145
146    /// Decode 32 bytes produced by [`SecretKey::to_bytes`].
147    ///
148    /// Returns [`Error::InvalidScalar`] if the bytes are not a canonical
149    /// reduction modulo the group order.
150    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
151        Ok(SecretKey {
152            scalar: parse_secret_scalar(bytes)?,
153        })
154    }
155
156    /// Hex-encode using lowercase digits (64 characters).
157    pub fn to_hex(&self) -> String {
158        hex::encode(self.to_bytes())
159    }
160
161    /// Decode from a 64-character hex string.
162    pub fn from_hex(s: &str) -> Result<Self> {
163        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
164        let arr: [u8; 32] = bytes
165            .as_slice()
166            .try_into()
167            .map_err(|_| Error::InvalidLength {
168                what: "SecretKey",
169                expected: 32,
170                got: bytes.len(),
171            })?;
172        Self::from_bytes(&arr)
173    }
174
175    /// Check whether 32 raw bytes encode a usable secret key, without
176    /// constructing one.
177    ///
178    /// Applies the same checks as [`SecretKey::from_bytes`]: the bytes
179    /// must be the canonical encoding of a scalar in `[0, ℓ)` and must
180    /// not be the zero scalar.
181    pub fn is_valid_bytes(bytes: &[u8; 32]) -> bool {
182        parse_secret_scalar(bytes).is_ok()
183    }
184
185    /// Check whether a hex string encodes a usable secret key, without
186    /// constructing one.
187    ///
188    /// Applies the same checks as [`SecretKey::from_hex`]: valid hex,
189    /// exactly 32 decoded bytes, canonical non-zero scalar.
190    pub fn is_valid_hex(s: &str) -> bool {
191        let Ok(bytes) = hex::decode(s) else {
192            return false;
193        };
194        let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) else {
195            return false;
196        };
197        Self::is_valid_bytes(&arr)
198    }
199
200    /// Encode in the human-friendly prefixed format: `sk_<hex>_<checksum>`.
201    ///
202    /// Same care applies as to [`SecretKey::to_hex`]: this is the full
203    /// secret and must never leave the voter's device.
204    pub fn to_prefixed(&self) -> String {
205        encoding::encode_prefixed(Tag::SecretKey, &self.to_bytes())
206    }
207
208    /// Decode an `sk_<hex>_<checksum>` string produced by
209    /// [`SecretKey::to_prefixed`], verifying the tag and the checksum.
210    pub fn from_prefixed(s: &str) -> Result<Self> {
211        let bytes = encoding::decode_prefixed(Tag::SecretKey, s)?;
212        let arr: [u8; 32] = bytes
213            .as_slice()
214            .try_into()
215            .map_err(|_| Error::InvalidLength {
216                what: "SecretKey",
217                expected: 32,
218                got: bytes.len(),
219            })?;
220        Self::from_bytes(&arr)
221    }
222
223    /// Check whether a prefixed string encodes a usable secret key,
224    /// without constructing one. Counterpart of [`SecretKey::is_valid_hex`]
225    /// for the `sk_<hex>_<checksum>` format: the tag and checksum must be
226    /// valid *and* the body must be a canonical non-zero scalar.
227    pub fn is_valid_prefixed(s: &str) -> bool {
228        let Ok(bytes) = encoding::decode_prefixed(Tag::SecretKey, s) else {
229            return false;
230        };
231        let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) else {
232            return false;
233        };
234        Self::is_valid_bytes(&arr)
235    }
236}
237
238/// Validate-and-parse a 32-byte secret scalar.
239///
240/// Single source of truth for the secret-key validation rules:
241/// `Scalar::from_canonical_bytes` is constant-time and returns `None`
242/// outside `[0, ℓ)` (that second check matters — any other encoding
243/// could let two different byte strings represent the same key), and
244/// the zero scalar is rejected because its public key is the identity
245/// point.
246fn parse_secret_scalar(bytes: &[u8; 32]) -> Result<Scalar> {
247    let scalar =
248        Option::<Scalar>::from(Scalar::from_canonical_bytes(*bytes)).ok_or(Error::InvalidScalar)?;
249    if scalar == Scalar::ZERO {
250        return Err(Error::InvalidSecretKey);
251    }
252    Ok(scalar)
253}
254
255/// The protocol's "linking tag" — what the host stores to prevent double
256/// voting.
257///
258/// Mathematically: `I_e = x · H_p(domain || election_id || x · G)`,
259/// where `x` is the secret key, `G` is the Ristretto255 base point and
260/// `H_p` is the Ristretto hash-to-group construction. Three properties
261/// matter:
262///
263///  - it is **deterministic** for a given secret key and election, so
264///    casting the same ballot twice in that election yields the same tag;
265///  - it is **election-scoped**: reusing the same key in a different
266///    election yields a different public tag;
267///  - it is **anonymous**: nothing about it leaks which member of the
268///    ring produced it;
269///  - it is **unforgeable**: the BLSAG proof is only valid if the tag
270///    was actually computed from a secret key that matches one of the
271///    public keys in the ring.
272///
273/// Encoded as 32 canonical bytes, identical in shape to a public key.
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub struct KeyImage {
276    pub(crate) point: RistrettoPoint,
277}
278
279// Same reasoning as for `PublicKey`: hash through the canonical bytes.
280impl core::hash::Hash for KeyImage {
281    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
282        self.to_bytes().hash(state);
283    }
284}
285
286impl KeyImage {
287    /// Encode the tag as 32 canonical bytes.
288    pub fn to_bytes(&self) -> [u8; 32] {
289        self.point.compress().to_bytes()
290    }
291
292    /// Decode 32 bytes produced by [`KeyImage::to_bytes`].
293    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
294        let compressed = CompressedRistretto::from_slice(bytes).map_err(|_| Error::InvalidPoint)?;
295        let point = compressed.decompress().ok_or(Error::InvalidPoint)?;
296        if point.is_identity() {
297            return Err(Error::InvalidIdentityPoint);
298        }
299        Ok(KeyImage { point })
300    }
301
302    /// Hex-encode using lowercase digits (64 characters).
303    pub fn to_hex(&self) -> String {
304        hex::encode(self.to_bytes())
305    }
306
307    /// Decode from a 64-character hex string.
308    pub fn from_hex(s: &str) -> Result<Self> {
309        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
310        let arr: [u8; 32] = bytes
311            .as_slice()
312            .try_into()
313            .map_err(|_| Error::InvalidLength {
314                what: "KeyImage",
315                expected: 32,
316                got: bytes.len(),
317            })?;
318        Self::from_bytes(&arr)
319    }
320
321    /// Encode in the human-friendly prefixed format: `ki_<hex>_<checksum>`.
322    pub fn to_prefixed(&self) -> String {
323        encoding::encode_prefixed(Tag::KeyImage, &self.to_bytes())
324    }
325
326    /// Decode a `ki_<hex>_<checksum>` string produced by
327    /// [`KeyImage::to_prefixed`], verifying the tag and the checksum.
328    pub fn from_prefixed(s: &str) -> Result<Self> {
329        let bytes = encoding::decode_prefixed(Tag::KeyImage, s)?;
330        let arr: [u8; 32] = bytes
331            .as_slice()
332            .try_into()
333            .map_err(|_| Error::InvalidLength {
334                what: "KeyImage",
335                expected: 32,
336                got: bytes.len(),
337            })?;
338        Self::from_bytes(&arr)
339    }
340}
341
342/// A ring signature produced by [`crate::sign_vote`].
343///
344/// The on-the-wire encoding is:
345///
346/// ```text
347///   challenge        : 32 bytes  (canonical Scalar little-endian)
348///   responses[0]     : 32 bytes
349///   responses[1]     : 32 bytes
350///   ...
351///   responses[n-1]   : 32 bytes
352/// ```
353///
354/// where `n` is the size of the authorised ring. The ring members
355/// themselves are **not** stored inside the signature: the verifier is
356/// expected to already know the canonical authorised list, and to
357/// reconstruct the ring from it in the same deterministic order used at
358/// signing time. That way the signer cannot ship a hand-picked ring of
359/// their own.
360#[derive(Clone, Debug, PartialEq, Eq)]
361pub struct Signature {
362    pub(crate) challenge: Scalar,
363    pub(crate) responses: Vec<Scalar>,
364}
365
366impl Signature {
367    /// Serialise to the byte layout described in the struct docs.
368    pub fn to_bytes(&self) -> Vec<u8> {
369        let mut out = Vec::with_capacity(32 * (1 + self.responses.len()));
370        out.extend_from_slice(&self.challenge.to_bytes());
371        for r in &self.responses {
372            out.extend_from_slice(&r.to_bytes());
373        }
374        out
375    }
376
377    /// Deserialise from the byte layout described in the struct docs.
378    ///
379    /// `ring_size` must be the size of the authorised list the verifier
380    /// is about to check against. We require it explicitly because the
381    /// signature on its own cannot tell `n` scalars from `n+1`.
382    pub fn from_bytes(bytes: &[u8], ring_size: usize) -> Result<Self> {
383        let expected = 32 * (1 + ring_size);
384        if bytes.len() != expected {
385            return Err(Error::InvalidLength {
386                what: "Signature",
387                expected,
388                got: bytes.len(),
389            });
390        }
391        let mut chunks = bytes.chunks_exact(32);
392        let challenge = scalar_from_chunk(chunks.next().expect("challenge present"))?;
393        let mut responses = Vec::with_capacity(ring_size);
394        for _ in 0..ring_size {
395            responses.push(scalar_from_chunk(chunks.next().expect("response present"))?);
396        }
397        Ok(Signature {
398            challenge,
399            responses,
400        })
401    }
402
403    /// Hex-encode using lowercase digits.
404    pub fn to_hex(&self) -> String {
405        hex::encode(self.to_bytes())
406    }
407
408    /// Decode from a hex string. See [`Signature::from_bytes`] for the
409    /// meaning of `ring_size`.
410    pub fn from_hex(s: &str, ring_size: usize) -> Result<Self> {
411        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
412        Self::from_bytes(&bytes, ring_size)
413    }
414
415    /// Encode in the human-friendly prefixed format:
416    /// `blsag_<hex>_<checksum>`. The body length grows with the ring, but
417    /// the format is otherwise identical to the fixed-size types.
418    pub fn to_prefixed(&self) -> String {
419        encoding::encode_prefixed(Tag::Signature, &self.to_bytes())
420    }
421
422    /// Decode a `blsag_<hex>_<checksum>` string produced by
423    /// [`Signature::to_prefixed`], verifying the tag and the checksum. See
424    /// [`Signature::from_bytes`] for the meaning of `ring_size`.
425    pub fn from_prefixed(s: &str, ring_size: usize) -> Result<Self> {
426        let bytes = encoding::decode_prefixed(Tag::Signature, s)?;
427        Self::from_bytes(&bytes, ring_size)
428    }
429}
430
431/// Common helper for decoding a single 32-byte scalar.
432fn scalar_from_chunk(chunk: &[u8]) -> Result<Scalar> {
433    let arr: [u8; 32] = chunk.try_into().map_err(|_| Error::InvalidLength {
434        what: "Scalar",
435        expected: 32,
436        got: chunk.len(),
437    })?;
438    Option::<Scalar>::from(Scalar::from_canonical_bytes(arr)).ok_or(Error::InvalidScalar)
439}
440
441/// The full bundle returned by [`crate::sign_vote`]: the proof and the
442/// linking tag.
443///
444/// The two fields travel together because the host needs the tag to do
445/// its "have I already seen this voter?" check before bothering the
446/// verifier with the proof.
447#[derive(Clone, Debug, PartialEq, Eq)]
448pub struct VoteProof {
449    /// The ring signature proper.
450    pub signature: Signature,
451    /// The unique-per-secret-key linking tag.
452    pub key_image: KeyImage,
453}
454
455/// A proof of ownership of a [`KeyImage`], produced by
456/// [`crate::prove_ownership`] and checked by [`crate::verify_ownership`].
457///
458/// It lets the holder of a secret key convince any third party that a
459/// given key image (and hence the ballot it sits next to in the public
460/// registry) is theirs — **without** revealing the secret key. The proof
461/// is a non-interactive Chaum–Pedersen proof of equality of discrete
462/// logarithms: it demonstrates knowledge of the scalar `x` such that, at
463/// once, `P = x·G` (the prover's public key) and `I = x·B` (the key
464/// image), where `B = H_p(election || P)` is the same election-scoped
465/// base the key image was built from.
466///
467/// Producing the proof intentionally **de-anonymises** the prover for
468/// that key image: `verify_ownership` is handed the public key, so it ties
469/// `P ↔ I` on purpose. That is the whole point — it is the opt-in inverse
470/// of the ring signature's anonymity, for use cases like proxy / mandated
471/// voting where a voter must demonstrate how they voted.
472///
473/// The on-the-wire encoding is two canonical 32-byte scalars,
474/// `challenge || response`, for 64 bytes total — independent of the ring
475/// size.
476#[derive(Clone, Debug, PartialEq, Eq)]
477pub struct OwnershipProof {
478    pub(crate) challenge: Scalar,
479    pub(crate) response: Scalar,
480}
481
482impl OwnershipProof {
483    /// Serialise to the 64-byte `challenge || response` layout.
484    pub fn to_bytes(&self) -> [u8; 64] {
485        let mut out = [0u8; 64];
486        out[..32].copy_from_slice(&self.challenge.to_bytes());
487        out[32..].copy_from_slice(&self.response.to_bytes());
488        out
489    }
490
491    /// Deserialise 64 bytes produced by [`OwnershipProof::to_bytes`].
492    pub fn from_bytes(bytes: &[u8; 64]) -> Result<Self> {
493        let challenge = scalar_from_chunk(&bytes[..32])?;
494        let response = scalar_from_chunk(&bytes[32..])?;
495        Ok(OwnershipProof {
496            challenge,
497            response,
498        })
499    }
500
501    /// Hex-encode using lowercase digits (128 characters).
502    pub fn to_hex(&self) -> String {
503        hex::encode(self.to_bytes())
504    }
505
506    /// Decode from a 128-character hex string.
507    pub fn from_hex(s: &str) -> Result<Self> {
508        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
509        let arr: [u8; 64] = bytes
510            .as_slice()
511            .try_into()
512            .map_err(|_| Error::InvalidLength {
513                what: "OwnershipProof",
514                expected: 64,
515                got: bytes.len(),
516            })?;
517        Self::from_bytes(&arr)
518    }
519
520    /// Encode in the human-friendly prefixed format: `own_<hex>_<checksum>`.
521    pub fn to_prefixed(&self) -> String {
522        encoding::encode_prefixed(Tag::Ownership, &self.to_bytes())
523    }
524
525    /// Decode an `own_<hex>_<checksum>` string produced by
526    /// [`OwnershipProof::to_prefixed`], verifying the tag and the checksum.
527    pub fn from_prefixed(s: &str) -> Result<Self> {
528        let bytes = encoding::decode_prefixed(Tag::Ownership, s)?;
529        let arr: [u8; 64] = bytes
530            .as_slice()
531            .try_into()
532            .map_err(|_| Error::InvalidLength {
533                what: "OwnershipProof",
534                expected: 64,
535                got: bytes.len(),
536            })?;
537        Self::from_bytes(&arr)
538    }
539}
540
541/// A verifier-chosen nonce for an ownership proof (Operation D).
542///
543/// 32 opaque bytes the verifier sends to the prover so the resulting
544/// [`OwnershipProof`] is bound to a fresh, single-use challenge and cannot
545/// be replayed. Generate one with [`crate::generate_nonce`].
546///
547/// Unlike the key types, a nonce has **no validity constraint** — any 32
548/// bytes are a valid nonce — so [`Nonce::from_bytes`] is infallible. The
549/// prefixed form (`nonce_<hex>_<checksum>`) is, exactly like the other
550/// types, a pure transport wrapper: the bytes that get hashed into a proof
551/// are the raw [`Nonce::as_bytes`], never the prefixed string. Both prover
552/// and verifier must use the same nonce.
553#[derive(Clone, Copy, Debug, PartialEq, Eq)]
554pub struct Nonce {
555    pub(crate) bytes: [u8; 32],
556}
557
558impl Nonce {
559    /// Wrap 32 raw bytes as a nonce. Infallible — any value is valid.
560    pub fn from_bytes(bytes: [u8; 32]) -> Self {
561        Nonce { bytes }
562    }
563
564    /// The raw 32 bytes. These are what an ownership proof binds to (pass
565    /// them as the `context` argument of [`crate::prove_ownership`] /
566    /// [`crate::verify_ownership`]).
567    pub fn to_bytes(&self) -> [u8; 32] {
568        self.bytes
569    }
570
571    /// Borrow the raw bytes, e.g. to pass straight as `context`.
572    pub fn as_bytes(&self) -> &[u8] {
573        &self.bytes
574    }
575
576    /// Hex-encode using lowercase digits (64 characters).
577    pub fn to_hex(&self) -> String {
578        hex::encode(self.bytes)
579    }
580
581    /// Decode from a 64-character hex string.
582    pub fn from_hex(s: &str) -> Result<Self> {
583        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
584        let arr: [u8; 32] = bytes
585            .as_slice()
586            .try_into()
587            .map_err(|_| Error::InvalidLength {
588                what: "Nonce",
589                expected: 32,
590                got: bytes.len(),
591            })?;
592        Ok(Nonce::from_bytes(arr))
593    }
594
595    /// Encode in the human-friendly prefixed format: `nonce_<hex>_<checksum>`.
596    pub fn to_prefixed(&self) -> String {
597        encoding::encode_prefixed(Tag::Nonce, &self.bytes)
598    }
599
600    /// Decode a `nonce_<hex>_<checksum>` string produced by
601    /// [`Nonce::to_prefixed`], verifying the tag and the checksum.
602    pub fn from_prefixed(s: &str) -> Result<Self> {
603        let bytes = encoding::decode_prefixed(Tag::Nonce, s)?;
604        let arr: [u8; 32] = bytes
605            .as_slice()
606            .try_into()
607            .map_err(|_| Error::InvalidLength {
608                what: "Nonce",
609                expected: 32,
610                got: bytes.len(),
611            })?;
612        Ok(Nonce::from_bytes(arr))
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    const IDENTITY_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000";
621
622    #[test]
623    fn rejects_zero_secret_key() {
624        let zero = [0u8; 32];
625        assert_eq!(
626            SecretKey::from_bytes(&zero).unwrap_err(),
627            Error::InvalidSecretKey
628        );
629        assert_eq!(
630            SecretKey::from_hex(IDENTITY_HEX).unwrap_err(),
631            Error::InvalidSecretKey
632        );
633    }
634
635    #[test]
636    fn rejects_identity_points_at_api_boundary() {
637        assert_eq!(
638            PublicKey::from_hex(IDENTITY_HEX).unwrap_err(),
639            Error::InvalidIdentityPoint
640        );
641        assert_eq!(
642            KeyImage::from_hex(IDENTITY_HEX).unwrap_err(),
643            Error::InvalidIdentityPoint
644        );
645    }
646
647    #[test]
648    fn is_valid_secret_key_matches_from_bytes() {
649        // Valid: any non-zero canonical scalar.
650        let mut valid = [0u8; 32];
651        valid[0] = 1;
652        assert!(SecretKey::is_valid_bytes(&valid));
653        assert!(SecretKey::is_valid_hex(&hex::encode(valid)));
654
655        // Zero scalar — rejected.
656        let zero = [0u8; 32];
657        assert!(!SecretKey::is_valid_bytes(&zero));
658        assert!(!SecretKey::is_valid_hex(IDENTITY_HEX));
659
660        // Non-canonical: all-0xff is well above ℓ.
661        let non_canonical = [0xffu8; 32];
662        assert!(!SecretKey::is_valid_bytes(&non_canonical));
663        assert!(!SecretKey::is_valid_hex(&hex::encode(non_canonical)));
664
665        // Bad hex / wrong length / non-hex chars — rejected.
666        assert!(!SecretKey::is_valid_hex("not hex at all!!"));
667        assert!(!SecretKey::is_valid_hex("aa")); // too short
668        assert!(!SecretKey::is_valid_hex(&"aa".repeat(33))); // too long
669    }
670
671    #[test]
672    fn is_valid_agrees_with_from_bytes_on_generated_keys() {
673        // A freshly generated identity must always pass validation, and
674        // a corrupted copy must always fail one of the checks.
675        let id = crate::identity::generate_identity();
676        let bytes = id.secret_key.to_bytes();
677        assert!(SecretKey::is_valid_bytes(&bytes));
678        assert!(SecretKey::is_valid_hex(&hex::encode(bytes)));
679    }
680
681    #[test]
682    fn secret_key_debug_is_redacted() {
683        let sk =
684            SecretKey::from_hex("0100000000000000000000000000000000000000000000000000000000000000")
685                .unwrap();
686        let debug = format!("{sk:?}");
687        assert_eq!(debug, "SecretKey(..)");
688        assert!(!debug.contains("1"));
689    }
690}