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}