crypto-vote 0.4.2

Sign and verify anonymous, double-vote-resistant ballots with linkable BLSAG ring signatures over Ristretto255 + Blake2b-512. Native CLI and WebAssembly (browser + WASI) builds.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
//! Public data types exchanged across the API boundary.
//!
//! All four types in this module are thin wrappers around their
//! underlying curve / scalar representation. They exist so that:
//!
//!  - the public API never leaks `curve25519_dalek` types directly,
//!    which would force every caller to take a dependency on the same
//!    version of that crate;
//!  - every value has exactly one canonical byte and hex encoding;
//!  - the type system stops you from accidentally passing, say, a
//!    [`KeyImage`] where a [`PublicKey`] is expected — they are both
//!    32-byte Ristretto points, but they mean different things in the
//!    protocol.
//!
//! Everything serialises to a fixed-size byte array (or a `Vec<u8>` for
//! signatures whose size depends on the ring). The encoding is the
//! curve25519-dalek canonical encoding for points (compressed Ristretto)
//! and the little-endian canonical encoding for scalars.

use crate::encoding::{self, Tag};
use crate::error::{Error, Result};
use core::fmt;
use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint};
use curve25519_dalek::scalar::Scalar;
use curve25519_dalek::traits::IsIdentity;
use zeroize::Zeroize;

/// A voter's public identity. Safe to publish.
///
/// Internally a Ristretto255 point; externally 32 bytes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PublicKey {
    pub(crate) point: RistrettoPoint,
}

// `RistrettoPoint` does not implement `Hash`, but the compressed
// 32-byte encoding is canonical, so hashing through it is sound and
// agrees with `PartialEq`.
impl core::hash::Hash for PublicKey {
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
        self.to_bytes().hash(state);
    }
}

impl PublicKey {
    /// Encode the key as 32 canonical bytes (compressed Ristretto).
    pub fn to_bytes(&self) -> [u8; 32] {
        self.point.compress().to_bytes()
    }

    /// Decode 32 bytes produced by [`PublicKey::to_bytes`].
    ///
    /// Returns [`Error::InvalidPoint`] if the bytes are not the canonical
    /// encoding of a point in the Ristretto255 group.
    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
        let compressed = CompressedRistretto::from_slice(bytes).map_err(|_| Error::InvalidPoint)?;
        let point = compressed.decompress().ok_or(Error::InvalidPoint)?;
        if point.is_identity() {
            return Err(Error::InvalidIdentityPoint);
        }
        Ok(PublicKey { point })
    }

    /// Hex-encode using lowercase digits (64 characters).
    pub fn to_hex(&self) -> String {
        hex::encode(self.to_bytes())
    }

    /// Decode from a 64-character hex string.
    pub fn from_hex(s: &str) -> Result<Self> {
        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "PublicKey",
                expected: 32,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }

    /// Encode in the human-friendly prefixed format: `pk_<hex>_<checksum>`.
    ///
    /// Same bytes as [`PublicKey::to_hex`], wrapped with a `pk_` tag and a
    /// checksum. See [`crate::encoding`].
    pub fn to_prefixed(&self) -> String {
        encoding::encode_prefixed(Tag::PublicKey, &self.to_bytes())
    }

    /// Decode a `pk_<hex>_<checksum>` string produced by
    /// [`PublicKey::to_prefixed`], verifying the tag and the checksum.
    pub fn from_prefixed(s: &str) -> Result<Self> {
        let bytes = encoding::decode_prefixed(Tag::PublicKey, s)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "PublicKey",
                expected: 32,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }
}

/// A voter's secret key.
///
/// Internally a Ristretto255 scalar; externally 32 bytes. Treat the
/// encoded form with the same care as any other private key — it should
/// never be transmitted off the voter's device.
///
/// `SecretKey` is intentionally **not** `Clone`. Every copy of a secret
/// scalar is one more memory region to keep track of and zeroise; if a
/// caller really needs to duplicate one, they should re-decode it from
/// the same byte representation and accept the duplication explicitly.
pub struct SecretKey {
    pub(crate) scalar: Scalar,
}

impl fmt::Debug for SecretKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("SecretKey(..)")
    }
}

impl Drop for SecretKey {
    fn drop(&mut self) {
        self.scalar.zeroize();
    }
}

impl SecretKey {
    /// Derive the matching [`PublicKey`].
    pub fn public_key(&self) -> PublicKey {
        PublicKey {
            point: self.scalar * curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT,
        }
    }

    /// Encode the scalar as 32 little-endian bytes.
    pub fn to_bytes(&self) -> [u8; 32] {
        self.scalar.to_bytes()
    }

    /// Decode 32 bytes produced by [`SecretKey::to_bytes`].
    ///
    /// Returns [`Error::InvalidScalar`] if the bytes are not a canonical
    /// reduction modulo the group order.
    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
        Ok(SecretKey {
            scalar: parse_secret_scalar(bytes)?,
        })
    }

    /// Hex-encode using lowercase digits (64 characters).
    pub fn to_hex(&self) -> String {
        hex::encode(self.to_bytes())
    }

    /// Decode from a 64-character hex string.
    pub fn from_hex(s: &str) -> Result<Self> {
        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "SecretKey",
                expected: 32,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }

    /// Check whether 32 raw bytes encode a usable secret key, without
    /// constructing one.
    ///
    /// Applies the same checks as [`SecretKey::from_bytes`]: the bytes
    /// must be the canonical encoding of a scalar in `[0, ℓ)` and must
    /// not be the zero scalar.
    pub fn is_valid_bytes(bytes: &[u8; 32]) -> bool {
        parse_secret_scalar(bytes).is_ok()
    }

    /// Check whether a hex string encodes a usable secret key, without
    /// constructing one.
    ///
    /// Applies the same checks as [`SecretKey::from_hex`]: valid hex,
    /// exactly 32 decoded bytes, canonical non-zero scalar.
    pub fn is_valid_hex(s: &str) -> bool {
        let Ok(bytes) = hex::decode(s) else {
            return false;
        };
        let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) else {
            return false;
        };
        Self::is_valid_bytes(&arr)
    }

    /// Encode in the human-friendly prefixed format: `sk_<hex>_<checksum>`.
    ///
    /// Same care applies as to [`SecretKey::to_hex`]: this is the full
    /// secret and must never leave the voter's device.
    pub fn to_prefixed(&self) -> String {
        encoding::encode_prefixed(Tag::SecretKey, &self.to_bytes())
    }

    /// Decode an `sk_<hex>_<checksum>` string produced by
    /// [`SecretKey::to_prefixed`], verifying the tag and the checksum.
    pub fn from_prefixed(s: &str) -> Result<Self> {
        let bytes = encoding::decode_prefixed(Tag::SecretKey, s)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "SecretKey",
                expected: 32,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }

    /// Check whether a prefixed string encodes a usable secret key,
    /// without constructing one. Counterpart of [`SecretKey::is_valid_hex`]
    /// for the `sk_<hex>_<checksum>` format: the tag and checksum must be
    /// valid *and* the body must be a canonical non-zero scalar.
    pub fn is_valid_prefixed(s: &str) -> bool {
        let Ok(bytes) = encoding::decode_prefixed(Tag::SecretKey, s) else {
            return false;
        };
        let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) else {
            return false;
        };
        Self::is_valid_bytes(&arr)
    }
}

/// Validate-and-parse a 32-byte secret scalar.
///
/// Single source of truth for the secret-key validation rules:
/// `Scalar::from_canonical_bytes` is constant-time and returns `None`
/// outside `[0, ℓ)` (that second check matters — any other encoding
/// could let two different byte strings represent the same key), and
/// the zero scalar is rejected because its public key is the identity
/// point.
fn parse_secret_scalar(bytes: &[u8; 32]) -> Result<Scalar> {
    let scalar =
        Option::<Scalar>::from(Scalar::from_canonical_bytes(*bytes)).ok_or(Error::InvalidScalar)?;
    if scalar == Scalar::ZERO {
        return Err(Error::InvalidSecretKey);
    }
    Ok(scalar)
}

/// The protocol's "linking tag" — what the host stores to prevent double
/// voting.
///
/// Mathematically: `I_e = x · H_p(domain || election_id || x · G)`,
/// where `x` is the secret key, `G` is the Ristretto255 base point and
/// `H_p` is the Ristretto hash-to-group construction. Three properties
/// matter:
///
///  - it is **deterministic** for a given secret key and election, so
///    casting the same ballot twice in that election yields the same tag;
///  - it is **election-scoped**: reusing the same key in a different
///    election yields a different public tag;
///  - it is **anonymous**: nothing about it leaks which member of the
///    ring produced it;
///  - it is **unforgeable**: the BLSAG proof is only valid if the tag
///    was actually computed from a secret key that matches one of the
///    public keys in the ring.
///
/// Encoded as 32 canonical bytes, identical in shape to a public key.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyImage {
    pub(crate) point: RistrettoPoint,
}

// Same reasoning as for `PublicKey`: hash through the canonical bytes.
impl core::hash::Hash for KeyImage {
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
        self.to_bytes().hash(state);
    }
}

impl KeyImage {
    /// Encode the tag as 32 canonical bytes.
    pub fn to_bytes(&self) -> [u8; 32] {
        self.point.compress().to_bytes()
    }

    /// Decode 32 bytes produced by [`KeyImage::to_bytes`].
    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
        let compressed = CompressedRistretto::from_slice(bytes).map_err(|_| Error::InvalidPoint)?;
        let point = compressed.decompress().ok_or(Error::InvalidPoint)?;
        if point.is_identity() {
            return Err(Error::InvalidIdentityPoint);
        }
        Ok(KeyImage { point })
    }

    /// Hex-encode using lowercase digits (64 characters).
    pub fn to_hex(&self) -> String {
        hex::encode(self.to_bytes())
    }

    /// Decode from a 64-character hex string.
    pub fn from_hex(s: &str) -> Result<Self> {
        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "KeyImage",
                expected: 32,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }

    /// Encode in the human-friendly prefixed format: `ki_<hex>_<checksum>`.
    pub fn to_prefixed(&self) -> String {
        encoding::encode_prefixed(Tag::KeyImage, &self.to_bytes())
    }

    /// Decode a `ki_<hex>_<checksum>` string produced by
    /// [`KeyImage::to_prefixed`], verifying the tag and the checksum.
    pub fn from_prefixed(s: &str) -> Result<Self> {
        let bytes = encoding::decode_prefixed(Tag::KeyImage, s)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "KeyImage",
                expected: 32,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }
}

/// A ring signature produced by [`crate::sign_vote`].
///
/// The on-the-wire encoding is:
///
/// ```text
///   challenge        : 32 bytes  (canonical Scalar little-endian)
///   responses[0]     : 32 bytes
///   responses[1]     : 32 bytes
///   ...
///   responses[n-1]   : 32 bytes
/// ```
///
/// where `n` is the size of the authorised ring. The ring members
/// themselves are **not** stored inside the signature: the verifier is
/// expected to already know the canonical authorised list, and to
/// reconstruct the ring from it in the same deterministic order used at
/// signing time. That way the signer cannot ship a hand-picked ring of
/// their own.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Signature {
    pub(crate) challenge: Scalar,
    pub(crate) responses: Vec<Scalar>,
}

impl Signature {
    /// Serialise to the byte layout described in the struct docs.
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(32 * (1 + self.responses.len()));
        out.extend_from_slice(&self.challenge.to_bytes());
        for r in &self.responses {
            out.extend_from_slice(&r.to_bytes());
        }
        out
    }

    /// Deserialise from the byte layout described in the struct docs.
    ///
    /// `ring_size` must be the size of the authorised list the verifier
    /// is about to check against. We require it explicitly because the
    /// signature on its own cannot tell `n` scalars from `n+1`.
    pub fn from_bytes(bytes: &[u8], ring_size: usize) -> Result<Self> {
        let expected = 32 * (1 + ring_size);
        if bytes.len() != expected {
            return Err(Error::InvalidLength {
                what: "Signature",
                expected,
                got: bytes.len(),
            });
        }
        let mut chunks = bytes.chunks_exact(32);
        let challenge = scalar_from_chunk(chunks.next().expect("challenge present"))?;
        let mut responses = Vec::with_capacity(ring_size);
        for _ in 0..ring_size {
            responses.push(scalar_from_chunk(chunks.next().expect("response present"))?);
        }
        Ok(Signature {
            challenge,
            responses,
        })
    }

    /// Hex-encode using lowercase digits.
    pub fn to_hex(&self) -> String {
        hex::encode(self.to_bytes())
    }

    /// Decode from a hex string. See [`Signature::from_bytes`] for the
    /// meaning of `ring_size`.
    pub fn from_hex(s: &str, ring_size: usize) -> Result<Self> {
        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
        Self::from_bytes(&bytes, ring_size)
    }

    /// Encode in the human-friendly prefixed format:
    /// `blsag_<hex>_<checksum>`. The body length grows with the ring, but
    /// the format is otherwise identical to the fixed-size types.
    pub fn to_prefixed(&self) -> String {
        encoding::encode_prefixed(Tag::Signature, &self.to_bytes())
    }

    /// Decode a `blsag_<hex>_<checksum>` string produced by
    /// [`Signature::to_prefixed`], verifying the tag and the checksum. See
    /// [`Signature::from_bytes`] for the meaning of `ring_size`.
    pub fn from_prefixed(s: &str, ring_size: usize) -> Result<Self> {
        let bytes = encoding::decode_prefixed(Tag::Signature, s)?;
        Self::from_bytes(&bytes, ring_size)
    }
}

/// Common helper for decoding a single 32-byte scalar.
fn scalar_from_chunk(chunk: &[u8]) -> Result<Scalar> {
    let arr: [u8; 32] = chunk.try_into().map_err(|_| Error::InvalidLength {
        what: "Scalar",
        expected: 32,
        got: chunk.len(),
    })?;
    Option::<Scalar>::from(Scalar::from_canonical_bytes(arr)).ok_or(Error::InvalidScalar)
}

/// The full bundle returned by [`crate::sign_vote`]: the proof and the
/// linking tag.
///
/// The two fields travel together because the host needs the tag to do
/// its "have I already seen this voter?" check before bothering the
/// verifier with the proof.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VoteProof {
    /// The ring signature proper.
    pub signature: Signature,
    /// The unique-per-secret-key linking tag.
    pub key_image: KeyImage,
}

/// A proof of ownership of a [`KeyImage`], produced by
/// [`crate::prove_ownership`] and checked by [`crate::verify_ownership`].
///
/// It lets the holder of a secret key convince any third party that a
/// given key image (and hence the ballot it sits next to in the public
/// registry) is theirs — **without** revealing the secret key. The proof
/// is a non-interactive Chaum–Pedersen proof of equality of discrete
/// logarithms: it demonstrates knowledge of the scalar `x` such that, at
/// once, `P = x·G` (the prover's public key) and `I = x·B` (the key
/// image), where `B = H_p(election || P)` is the same election-scoped
/// base the key image was built from.
///
/// Producing the proof intentionally **de-anonymises** the prover for
/// that key image: `verify_ownership` is handed the public key, so it ties
/// `P ↔ I` on purpose. That is the whole point — it is the opt-in inverse
/// of the ring signature's anonymity, for use cases like proxy / mandated
/// voting where a voter must demonstrate how they voted.
///
/// The on-the-wire encoding is two canonical 32-byte scalars,
/// `challenge || response`, for 64 bytes total — independent of the ring
/// size.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OwnershipProof {
    pub(crate) challenge: Scalar,
    pub(crate) response: Scalar,
}

impl OwnershipProof {
    /// Serialise to the 64-byte `challenge || response` layout.
    pub fn to_bytes(&self) -> [u8; 64] {
        let mut out = [0u8; 64];
        out[..32].copy_from_slice(&self.challenge.to_bytes());
        out[32..].copy_from_slice(&self.response.to_bytes());
        out
    }

    /// Deserialise 64 bytes produced by [`OwnershipProof::to_bytes`].
    pub fn from_bytes(bytes: &[u8; 64]) -> Result<Self> {
        let challenge = scalar_from_chunk(&bytes[..32])?;
        let response = scalar_from_chunk(&bytes[32..])?;
        Ok(OwnershipProof {
            challenge,
            response,
        })
    }

    /// Hex-encode using lowercase digits (128 characters).
    pub fn to_hex(&self) -> String {
        hex::encode(self.to_bytes())
    }

    /// Decode from a 128-character hex string.
    pub fn from_hex(s: &str) -> Result<Self> {
        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
        let arr: [u8; 64] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "OwnershipProof",
                expected: 64,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }

    /// Encode in the human-friendly prefixed format: `own_<hex>_<checksum>`.
    pub fn to_prefixed(&self) -> String {
        encoding::encode_prefixed(Tag::Ownership, &self.to_bytes())
    }

    /// Decode an `own_<hex>_<checksum>` string produced by
    /// [`OwnershipProof::to_prefixed`], verifying the tag and the checksum.
    pub fn from_prefixed(s: &str) -> Result<Self> {
        let bytes = encoding::decode_prefixed(Tag::Ownership, s)?;
        let arr: [u8; 64] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "OwnershipProof",
                expected: 64,
                got: bytes.len(),
            })?;
        Self::from_bytes(&arr)
    }
}

/// A verifier-chosen nonce for an ownership proof (Operation D).
///
/// 32 opaque bytes the verifier sends to the prover so the resulting
/// [`OwnershipProof`] is bound to a fresh, single-use challenge and cannot
/// be replayed. Generate one with [`crate::generate_nonce`].
///
/// Unlike the key types, a nonce has **no validity constraint** — any 32
/// bytes are a valid nonce — so [`Nonce::from_bytes`] is infallible. The
/// prefixed form (`nonce_<hex>_<checksum>`) is, exactly like the other
/// types, a pure transport wrapper: the bytes that get hashed into a proof
/// are the raw [`Nonce::as_bytes`], never the prefixed string. Both prover
/// and verifier must use the same nonce.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Nonce {
    pub(crate) bytes: [u8; 32],
}

impl Nonce {
    /// Wrap 32 raw bytes as a nonce. Infallible — any value is valid.
    pub fn from_bytes(bytes: [u8; 32]) -> Self {
        Nonce { bytes }
    }

    /// The raw 32 bytes. These are what an ownership proof binds to (pass
    /// them as the `context` argument of [`crate::prove_ownership`] /
    /// [`crate::verify_ownership`]).
    pub fn to_bytes(&self) -> [u8; 32] {
        self.bytes
    }

    /// Borrow the raw bytes, e.g. to pass straight as `context`.
    pub fn as_bytes(&self) -> &[u8] {
        &self.bytes
    }

    /// Hex-encode using lowercase digits (64 characters).
    pub fn to_hex(&self) -> String {
        hex::encode(self.bytes)
    }

    /// Decode from a 64-character hex string.
    pub fn from_hex(s: &str) -> Result<Self> {
        let bytes = hex::decode(s).map_err(|_| Error::InvalidHex)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "Nonce",
                expected: 32,
                got: bytes.len(),
            })?;
        Ok(Nonce::from_bytes(arr))
    }

    /// Encode in the human-friendly prefixed format: `nonce_<hex>_<checksum>`.
    pub fn to_prefixed(&self) -> String {
        encoding::encode_prefixed(Tag::Nonce, &self.bytes)
    }

    /// Decode a `nonce_<hex>_<checksum>` string produced by
    /// [`Nonce::to_prefixed`], verifying the tag and the checksum.
    pub fn from_prefixed(s: &str) -> Result<Self> {
        let bytes = encoding::decode_prefixed(Tag::Nonce, s)?;
        let arr: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| Error::InvalidLength {
                what: "Nonce",
                expected: 32,
                got: bytes.len(),
            })?;
        Ok(Nonce::from_bytes(arr))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const IDENTITY_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000";

    #[test]
    fn rejects_zero_secret_key() {
        let zero = [0u8; 32];
        assert_eq!(
            SecretKey::from_bytes(&zero).unwrap_err(),
            Error::InvalidSecretKey
        );
        assert_eq!(
            SecretKey::from_hex(IDENTITY_HEX).unwrap_err(),
            Error::InvalidSecretKey
        );
    }

    #[test]
    fn rejects_identity_points_at_api_boundary() {
        assert_eq!(
            PublicKey::from_hex(IDENTITY_HEX).unwrap_err(),
            Error::InvalidIdentityPoint
        );
        assert_eq!(
            KeyImage::from_hex(IDENTITY_HEX).unwrap_err(),
            Error::InvalidIdentityPoint
        );
    }

    #[test]
    fn is_valid_secret_key_matches_from_bytes() {
        // Valid: any non-zero canonical scalar.
        let mut valid = [0u8; 32];
        valid[0] = 1;
        assert!(SecretKey::is_valid_bytes(&valid));
        assert!(SecretKey::is_valid_hex(&hex::encode(valid)));

        // Zero scalar — rejected.
        let zero = [0u8; 32];
        assert!(!SecretKey::is_valid_bytes(&zero));
        assert!(!SecretKey::is_valid_hex(IDENTITY_HEX));

        // Non-canonical: all-0xff is well above ℓ.
        let non_canonical = [0xffu8; 32];
        assert!(!SecretKey::is_valid_bytes(&non_canonical));
        assert!(!SecretKey::is_valid_hex(&hex::encode(non_canonical)));

        // Bad hex / wrong length / non-hex chars — rejected.
        assert!(!SecretKey::is_valid_hex("not hex at all!!"));
        assert!(!SecretKey::is_valid_hex("aa")); // too short
        assert!(!SecretKey::is_valid_hex(&"aa".repeat(33))); // too long
    }

    #[test]
    fn is_valid_agrees_with_from_bytes_on_generated_keys() {
        // A freshly generated identity must always pass validation, and
        // a corrupted copy must always fail one of the checks.
        let id = crate::identity::generate_identity();
        let bytes = id.secret_key.to_bytes();
        assert!(SecretKey::is_valid_bytes(&bytes));
        assert!(SecretKey::is_valid_hex(&hex::encode(bytes)));
    }

    #[test]
    fn secret_key_debug_is_redacted() {
        let sk =
            SecretKey::from_hex("0100000000000000000000000000000000000000000000000000000000000000")
                .unwrap();
        let debug = format!("{sk:?}");
        assert_eq!(debug, "SecretKey(..)");
        assert!(!debug.contains("1"));
    }
}