Skip to main content

hashsigs_types/
lib.rs

1//! Profile-safe protocol types and constants shared by software and RHDL models.
2//!
3//! `HashSigsRS` implements a custom WOTS+ construction with `n = 32` and
4//! `w = 16`. It is a **one-time** signature: a private key must sign at most one
5//! message. Reusing a key can reveal enough hash-chain material to forge a
6//! signature.
7//!
8//! This crate deliberately gives the legacy Keccak and generic SHA-256 profiles
9//! different Rust types. Their serialized keys and signatures do not contain a
10//! profile tag, so applications must also bind a profile identifier in their
11//! protocol or persistent metadata.
12#![forbid(unsafe_code)]
13
14use core::{fmt, marker::PhantomData, str::FromStr};
15
16/// Hash output and WOTS security-parameter (`n`) size in bytes.
17pub const HASH_BYTES: usize = 32;
18
19/// Length of an already-hashed message accepted by WOTS, in bytes.
20pub const MESSAGE_BYTES: usize = HASH_BYTES;
21
22/// Winternitz parameter (`w`) and number of positions in each hash chain.
23pub const WINTERNITZ_W: usize = 16;
24
25/// Number of hash transitions from a secret segment to a chain endpoint.
26pub const CHAIN_STEPS: usize = WINTERNITZ_W - 1;
27
28/// Number of base-16 digits representing the 256-bit message (`len_1`).
29pub const MESSAGE_DIGITS: usize = 64;
30
31/// Number of base-16 digits representing the WOTS checksum (`len_2`).
32pub const CHECKSUM_DIGITS: usize = 3;
33
34/// Number of WOTS signature chains (`len = len_1 + len_2`).
35pub const CHAIN_COUNT: usize = MESSAGE_DIGITS + CHECKSUM_DIGITS;
36
37/// Number of randomization masks that can affect a `HashSigsRS` result.
38///
39/// Upstream allocates 67 masks, but the function key uses index zero and every
40/// chain transition uses only indices 1 through 15. Therefore indices 16
41/// through 66 are unreachable and these 16 masks are exactly sufficient.
42pub const MASK_COUNT: usize = WINTERNITZ_W;
43
44/// Number of bytes hashed by the domain-separated pseudorandom function.
45pub const PRF_INPUT_BYTES: usize = 1 + HASH_BYTES + 2;
46
47/// Number of bytes in a serialized WOTS signature.
48pub const SIGNATURE_BYTES: usize = CHAIN_COUNT * HASH_BYTES;
49
50/// Number of bytes in a serialized `HashSigsRS` public key.
51pub const PUBLIC_KEY_BYTES: usize = HASH_BYTES * 2;
52
53/// Number of bytes in a fused signature and public-key result.
54pub const FUSED_OUTPUT_BYTES: usize = SIGNATURE_BYTES + PUBLIC_KEY_BYTES;
55
56/// `Keccak-f[1600]` permutations used by constant-work fused signing from a key.
57///
58/// Derivation: one public-seed PRF, 16 mask PRFs, 67 secret PRFs, 67
59/// secret-segment hashes, `67 * 15` chain hashes, and 16 permutations to hash
60/// the 2,144-byte endpoint string.
61pub const LEGACY_FUSED_PRIVATE_KEY_KECCAK_PERMUTATIONS: usize =
62    1 + MASK_COUNT + CHAIN_COUNT + CHAIN_COUNT + CHAIN_COUNT * CHAIN_STEPS + 16;
63
64/// `Keccak-f[1600]` permutations used by constant-work fused signing from a seed.
65///
66/// This is the private-key cost plus one permutation to hash the 32-byte seed.
67pub const LEGACY_FUSED_PRIVATE_SEED_KECCAK_PERMUTATIONS: usize =
68    1 + LEGACY_FUSED_PRIVATE_KEY_KECCAK_PERMUTATIONS;
69
70/// SHA-256 compression blocks used by constant-work fused signing from a key.
71///
72/// Derivation: one block for the public-seed PRF, 16 mask PRFs, 67 secret PRFs,
73/// `67 * 2` blocks for the 64-byte secret-segment preimages (padding requires a
74/// second block), `67 * 15` chain blocks, and 34 blocks for the 2,144-byte
75/// endpoint string including SHA-256 padding.
76pub const SHA256_FUSED_PRIVATE_KEY_COMPRESSION_BLOCKS: usize =
77    1 + MASK_COUNT + CHAIN_COUNT + 2 * CHAIN_COUNT + CHAIN_COUNT * CHAIN_STEPS + 34;
78
79/// SHA-256 compression blocks used by constant-work fused signing from a seed.
80///
81/// This is the private-key cost plus one padded block for the 32-byte seed.
82pub const SHA256_FUSED_PRIVATE_SEED_COMPRESSION_BLOCKS: usize =
83    1 + SHA256_FUSED_PRIVATE_KEY_COMPRESSION_BLOCKS;
84
85/// Stable identifier for a cryptographic profile.
86///
87/// Persist or transmit this identifier alongside untagged `HashSigsRS` byte
88/// strings. Never infer a profile from a key or signature's length. Only
89/// [`ProfileId::as_str`] and its exact inverse parsers are protocol encodings;
90/// enum discriminants and derived [`Debug`](core::fmt::Debug) output are not stable
91/// encodings and must not be persisted or transmitted.
92#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
93#[non_exhaustive]
94pub enum ProfileId {
95    /// Byte-compatible profile used by pinned `HashSigsRS` vectors.
96    LegacyKeccak,
97    /// Generic `HashSigsRS` construction instantiated with SHA-256.
98    HashSigsSha256GenericV1,
99}
100
101impl ProfileId {
102    /// Returns the exact canonical protocol text for this profile.
103    ///
104    /// Parsing is deliberately case-sensitive and accepts no aliases or
105    /// surrounding whitespace. Persist this value, rather than an enum
106    /// discriminant or [`Debug`](core::fmt::Debug) representation, when a textual
107    /// profile identifier is required.
108    #[must_use]
109    pub const fn as_str(&self) -> &'static str {
110        match self {
111            Self::LegacyKeccak => "LEGACY_KECCAK",
112            Self::HashSigsSha256GenericV1 => "HASHSIGS_SHA256_GENERIC_V1",
113        }
114    }
115}
116
117impl fmt::Display for ProfileId {
118    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
119        formatter.write_str(self.as_str())
120    }
121}
122
123/// Error returned when text is not an exact canonical [`ProfileId`] encoding.
124///
125/// Profile parsing is case-sensitive and does not trim whitespace or accept
126/// aliases. The rejected input is intentionally not retained in the error.
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128#[non_exhaustive]
129pub struct ParseProfileIdError;
130
131impl fmt::Display for ParseProfileIdError {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(
134            formatter,
135            "unrecognized cryptographic profile; expected exactly {} or {}",
136            ProfileId::LegacyKeccak.as_str(),
137            ProfileId::HashSigsSha256GenericV1.as_str()
138        )
139    }
140}
141
142impl std::error::Error for ParseProfileIdError {}
143
144impl TryFrom<&str> for ProfileId {
145    type Error = ParseProfileIdError;
146
147    fn try_from(value: &str) -> Result<Self, Self::Error> {
148        if value == Self::LegacyKeccak.as_str() {
149            Ok(Self::LegacyKeccak)
150        } else if value == Self::HashSigsSha256GenericV1.as_str() {
151            Ok(Self::HashSigsSha256GenericV1)
152        } else {
153            Err(ParseProfileIdError)
154        }
155    }
156}
157
158impl FromStr for ProfileId {
159    type Err = ParseProfileIdError;
160
161    fn from_str(value: &str) -> Result<Self, Self::Err> {
162        Self::try_from(value)
163    }
164}
165
166mod sealed {
167    pub trait Sealed {}
168}
169
170/// Marker implemented by supported, compile-time-separated hash profiles.
171///
172/// The trait is sealed so downstream crates cannot accidentally assign an
173/// unsupported hash function to a recognized profile identifier.
174pub trait Profile: sealed::Sealed + 'static {
175    /// Stable profile identifier.
176    const ID: ProfileId;
177
178    /// Canonical profile text used in diagnostics, evidence, and protocols.
179    ///
180    /// Implementations derive this from [`ProfileId::as_str`] so the marker
181    /// type and runtime identifier cannot acquire different spellings.
182    const NAME: &'static str;
183}
184
185/// Legacy Keccak-256 profile, byte-compatible with pinned `HashSigsRS`.
186///
187/// This means legacy Keccak padding (`0x01` domain suffix), not standardized
188/// SHA3-256 padding (`0x06`).
189#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
190pub struct LegacyKeccak;
191
192impl sealed::Sealed for LegacyKeccak {}
193
194impl Profile for LegacyKeccak {
195    const ID: ProfileId = ProfileId::LegacyKeccak;
196    const NAME: &'static str = Self::ID.as_str();
197}
198
199/// Performance profile using `HashSigsRS`'s generic construction with SHA-256.
200///
201/// This profile is not key- or signature-compatible with [`LegacyKeccak`]. It
202/// is also not SLH-DSA, FIPS 205, XMSS, or an RFC 8391 parameter set.
203#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
204pub struct HashSigsSha256GenericV1;
205
206impl sealed::Sealed for HashSigsSha256GenericV1 {}
207
208impl Profile for HashSigsSha256GenericV1 {
209    const ID: ProfileId = ProfileId::HashSigsSha256GenericV1;
210    const NAME: &'static str = Self::ID.as_str();
211}
212
213/// Error returned when a serialized protocol value has the wrong length.
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub struct LengthError {
216    expected: usize,
217    actual: usize,
218}
219
220impl LengthError {
221    /// Constructs a length error for a value with `actual` bytes.
222    #[must_use]
223    pub const fn new(expected: usize, actual: usize) -> Self {
224        Self { expected, actual }
225    }
226
227    /// Required byte length.
228    #[must_use]
229    pub const fn expected(self) -> usize {
230        self.expected
231    }
232
233    /// Observed byte length.
234    #[must_use]
235    pub const fn actual(self) -> usize {
236        self.actual
237    }
238}
239
240impl fmt::Display for LengthError {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(
243            formatter,
244            "expected {} bytes, received {} bytes",
245            self.expected, self.actual
246        )
247    }
248}
249
250impl std::error::Error for LengthError {}
251
252/// An already-hashed 32-byte message consumed by the WOTS construction.
253///
254/// `HashSigsRS` does not hash an arbitrary-length application message before
255/// signing. The caller is responsible for a domain-separated, collision-
256/// resistant application prehash.
257#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
258#[repr(transparent)]
259pub struct MessageDigest([u8; MESSAGE_BYTES]);
260
261impl MessageDigest {
262    /// Wraps a 32-byte application message digest.
263    #[must_use]
264    pub const fn new(bytes: [u8; MESSAGE_BYTES]) -> Self {
265        Self(bytes)
266    }
267
268    /// Parses a message digest, rejecting every non-32-byte input.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`LengthError`] unless `bytes` contains exactly 32 bytes.
273    pub fn from_slice(bytes: &[u8]) -> Result<Self, LengthError> {
274        let value: [u8; MESSAGE_BYTES] = bytes
275            .try_into()
276            .map_err(|_| LengthError::new(MESSAGE_BYTES, bytes.len()))?;
277        Ok(Self(value))
278    }
279
280    /// Borrows the digest bytes.
281    #[must_use]
282    pub const fn as_bytes(&self) -> &[u8; MESSAGE_BYTES] {
283        &self.0
284    }
285
286    /// Returns the digest bytes.
287    #[must_use]
288    pub const fn into_bytes(self) -> [u8; MESSAGE_BYTES] {
289        self.0
290    }
291}
292
293/// A caller-supplied 32-byte seed used to derive one WOTS private key.
294///
295/// The type is intentionally neither `Copy` nor `Clone`. It does not zeroize
296/// memory on drop; callers needing that property must place it in a protected,
297/// zeroizing container before constructing this value.
298pub struct PrivateSeed<P: Profile> {
299    bytes: [u8; HASH_BYTES],
300    profile: PhantomData<fn() -> P>,
301}
302
303impl<P: Profile> PrivateSeed<P> {
304    /// Creates a profile-bound private seed.
305    #[must_use]
306    pub const fn new(bytes: [u8; HASH_BYTES]) -> Self {
307        Self {
308            bytes,
309            profile: PhantomData,
310        }
311    }
312
313    /// Parses a private seed, rejecting every non-32-byte input.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`LengthError`] unless `bytes` contains exactly 32 bytes.
318    pub fn from_slice(bytes: &[u8]) -> Result<Self, LengthError> {
319        let value: [u8; HASH_BYTES] = bytes
320            .try_into()
321            .map_err(|_| LengthError::new(HASH_BYTES, bytes.len()))?;
322        Ok(Self::new(value))
323    }
324
325    /// Explicitly borrows the secret bytes for a cryptographic operation.
326    #[must_use]
327    pub const fn expose_secret(&self) -> &[u8; HASH_BYTES] {
328        &self.bytes
329    }
330
331    /// Explicitly returns the secret bytes.
332    #[must_use]
333    pub const fn into_secret(self) -> [u8; HASH_BYTES] {
334        self.bytes
335    }
336}
337
338impl<P: Profile> fmt::Debug for PrivateSeed<P> {
339    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
340        formatter
341            .debug_struct("PrivateSeed")
342            .field("profile", &P::NAME)
343            .field("bytes", &"[REDACTED]")
344            .finish()
345    }
346}
347
348/// A one-time WOTS private key bound to cryptographic profile `P`.
349///
350/// Signing APIs consume this value to make accidental key reuse harder. Raw
351/// bytes can still be copied deliberately, so the surrounding key-management
352/// system must enforce one-time use durably. This type does not zeroize on
353/// drop; see [`PrivateSeed`] for the corresponding memory-handling caveat.
354pub struct PrivateKey<P: Profile> {
355    bytes: [u8; HASH_BYTES],
356    profile: PhantomData<fn() -> P>,
357}
358
359impl<P: Profile> PrivateKey<P> {
360    /// Creates a profile-bound private key from exact upstream-format bytes.
361    #[must_use]
362    pub const fn new(bytes: [u8; HASH_BYTES]) -> Self {
363        Self {
364            bytes,
365            profile: PhantomData,
366        }
367    }
368
369    /// Parses a private key, rejecting every non-32-byte input.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`LengthError`] unless `bytes` contains exactly 32 bytes.
374    pub fn from_slice(bytes: &[u8]) -> Result<Self, LengthError> {
375        let value: [u8; HASH_BYTES] = bytes
376            .try_into()
377            .map_err(|_| LengthError::new(HASH_BYTES, bytes.len()))?;
378        Ok(Self::new(value))
379    }
380
381    /// Explicitly borrows the secret bytes for a cryptographic operation.
382    #[must_use]
383    pub const fn expose_secret(&self) -> &[u8; HASH_BYTES] {
384        &self.bytes
385    }
386
387    /// Explicitly returns the secret bytes.
388    #[must_use]
389    pub const fn into_secret(self) -> [u8; HASH_BYTES] {
390        self.bytes
391    }
392}
393
394impl<P: Profile> fmt::Debug for PrivateKey<P> {
395    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
396        formatter
397            .debug_struct("PrivateKey")
398            .field("profile", &P::NAME)
399            .field("bytes", &"[REDACTED]")
400            .finish()
401    }
402}
403
404/// A `HashSigsRS` public key bound to cryptographic profile `P`.
405///
406/// Its byte encoding is the 32-byte public seed followed by the 32-byte hash of
407/// all 67 WOTS chain endpoints.
408#[derive(Clone, Copy, Eq, Hash, PartialEq)]
409pub struct PublicKey<P: Profile> {
410    public_seed: [u8; HASH_BYTES],
411    endpoint_hash: [u8; HASH_BYTES],
412    profile: PhantomData<fn() -> P>,
413}
414
415impl<P: Profile> PublicKey<P> {
416    /// Constructs a public key from its two protocol fields.
417    #[must_use]
418    pub const fn new(public_seed: [u8; HASH_BYTES], public_key_hash: [u8; HASH_BYTES]) -> Self {
419        Self {
420            public_seed,
421            endpoint_hash: public_key_hash,
422            profile: PhantomData,
423        }
424    }
425
426    /// Parses the exact 64-byte upstream public-key encoding.
427    ///
428    /// # Errors
429    ///
430    /// Returns [`LengthError`] unless `bytes` contains exactly 64 bytes.
431    pub fn from_slice(bytes: &[u8]) -> Result<Self, LengthError> {
432        if bytes.len() != PUBLIC_KEY_BYTES {
433            return Err(LengthError::new(PUBLIC_KEY_BYTES, bytes.len()));
434        }
435        let mut public_seed = [0_u8; HASH_BYTES];
436        let mut public_key_hash = [0_u8; HASH_BYTES];
437        public_seed.copy_from_slice(&bytes[..HASH_BYTES]);
438        public_key_hash.copy_from_slice(&bytes[HASH_BYTES..]);
439        Ok(Self::new(public_seed, public_key_hash))
440    }
441
442    /// Returns the public seed used to derive all chain masks.
443    #[must_use]
444    pub const fn public_seed(&self) -> &[u8; HASH_BYTES] {
445        &self.public_seed
446    }
447
448    /// Returns the hash of the concatenated 67 chain endpoints.
449    #[must_use]
450    pub const fn public_key_hash(&self) -> &[u8; HASH_BYTES] {
451        &self.endpoint_hash
452    }
453
454    /// Serializes as public seed followed by public-key hash.
455    #[must_use]
456    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_BYTES] {
457        let mut bytes = [0_u8; PUBLIC_KEY_BYTES];
458        bytes[..HASH_BYTES].copy_from_slice(&self.public_seed);
459        bytes[HASH_BYTES..].copy_from_slice(&self.endpoint_hash);
460        bytes
461    }
462}
463
464impl<P: Profile> fmt::Debug for PublicKey<P> {
465    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
466        formatter
467            .debug_struct("PublicKey")
468            .field("profile", &P::NAME)
469            .field("public_seed", &self.public_seed)
470            .field("public_key_hash", &self.endpoint_hash)
471            .finish()
472    }
473}
474
475/// A 67-segment WOTS signature bound to cryptographic profile `P`.
476#[derive(Clone, Eq, PartialEq)]
477pub struct Signature<P: Profile> {
478    segments: [[u8; HASH_BYTES]; CHAIN_COUNT],
479    profile: PhantomData<fn() -> P>,
480}
481
482impl<P: Profile> Signature<P> {
483    /// Constructs a signature from exactly 67 hash-chain segments.
484    #[must_use]
485    pub const fn new(segments: [[u8; HASH_BYTES]; CHAIN_COUNT]) -> Self {
486        Self {
487            segments,
488            profile: PhantomData,
489        }
490    }
491
492    /// Parses the exact 2,144-byte upstream signature encoding.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`LengthError`] unless `bytes` contains exactly 2,144 bytes.
497    pub fn from_slice(bytes: &[u8]) -> Result<Self, LengthError> {
498        if bytes.len() != SIGNATURE_BYTES {
499            return Err(LengthError::new(SIGNATURE_BYTES, bytes.len()));
500        }
501        let mut segments = [[0_u8; HASH_BYTES]; CHAIN_COUNT];
502        for (segment, chunk) in segments.iter_mut().zip(bytes.chunks_exact(HASH_BYTES)) {
503            segment.copy_from_slice(chunk);
504        }
505        Ok(Self::new(segments))
506    }
507
508    /// Borrows all 67 signature segments.
509    #[must_use]
510    pub const fn segments(&self) -> &[[u8; HASH_BYTES]; CHAIN_COUNT] {
511        &self.segments
512    }
513
514    /// Serializes the 67 segments without delimiters.
515    #[must_use]
516    pub fn to_bytes(&self) -> [u8; SIGNATURE_BYTES] {
517        let mut bytes = [0_u8; SIGNATURE_BYTES];
518        for (chunk, segment) in bytes.chunks_exact_mut(HASH_BYTES).zip(self.segments.iter()) {
519            chunk.copy_from_slice(segment);
520        }
521        bytes
522    }
523}
524
525impl<P: Profile> fmt::Debug for Signature<P> {
526    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
527        formatter
528            .debug_struct("Signature")
529            .field("profile", &P::NAME)
530            .field("segments", &self.segments.as_slice())
531            .finish()
532    }
533}
534
535const _: () = {
536    assert!(HASH_BYTES == 32);
537    assert!(WINTERNITZ_W == 16);
538    assert!(MESSAGE_DIGITS == 64);
539    assert!(CHECKSUM_DIGITS == 3);
540    assert!(CHAIN_COUNT == 67);
541    assert!(SIGNATURE_BYTES == 2_144);
542    assert!(PUBLIC_KEY_BYTES == 64);
543    assert!(LEGACY_FUSED_PRIVATE_KEY_KECCAK_PERMUTATIONS == 1_172);
544    assert!(LEGACY_FUSED_PRIVATE_SEED_KECCAK_PERMUTATIONS == 1_173);
545    assert!(SHA256_FUSED_PRIVATE_KEY_COMPRESSION_BLOCKS == 1_257);
546    assert!(SHA256_FUSED_PRIVATE_SEED_COMPRESSION_BLOCKS == 1_258);
547};
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn profile_ids_round_trip_only_through_canonical_text() {
555        for (profile, canonical) in [
556            (ProfileId::LegacyKeccak, "LEGACY_KECCAK"),
557            (
558                ProfileId::HashSigsSha256GenericV1,
559                "HASHSIGS_SHA256_GENERIC_V1",
560            ),
561        ] {
562            assert_eq!(profile.as_str(), canonical);
563            assert_eq!(profile.to_string(), canonical);
564            assert_eq!(canonical.parse::<ProfileId>(), Ok(profile));
565            assert_eq!(ProfileId::try_from(canonical), Ok(profile));
566        }
567        assert_eq!(LegacyKeccak::NAME, LegacyKeccak::ID.as_str());
568        assert_eq!(
569            HashSigsSha256GenericV1::NAME,
570            HashSigsSha256GenericV1::ID.as_str()
571        );
572    }
573
574    #[test]
575    fn profile_id_parser_rejects_every_near_miss() {
576        for noncanonical in [
577            "",
578            "legacy_keccak",
579            "LegacyKeccak",
580            "LEGACY-KECCAK",
581            " LEGACY_KECCAK",
582            "LEGACY_KECCAK ",
583            "LEGACY_KECCAK\n",
584            "hashsigs_sha256_generic_v1",
585            "HASHSIGS-SHA256-GENERIC-V1",
586            "HASHSIGS_SHA256_GENERIC_V1 ",
587            "HASHSIGS_SHA256_GENERIC_V2",
588        ] {
589            assert!(
590                noncanonical.parse::<ProfileId>().is_err(),
591                "{noncanonical:?}"
592            );
593            assert!(
594                ProfileId::try_from(noncanonical).is_err(),
595                "{noncanonical:?}"
596            );
597        }
598
599        let error = "SHA256".parse::<ProfileId>().expect_err("alias must fail");
600        assert_eq!(
601            error.to_string(),
602            concat!(
603                "unrecognized cryptographic profile; expected exactly ",
604                "LEGACY_KECCAK or HASHSIGS_SHA256_GENERIC_V1"
605            )
606        );
607    }
608
609    #[test]
610    fn malformed_lengths_are_rejected() {
611        let error = Signature::<LegacyKeccak>::from_slice(&[0_u8; 31])
612            .expect_err("short signatures must fail");
613        assert_eq!(error.expected(), SIGNATURE_BYTES);
614        assert_eq!(error.actual(), 31);
615        assert!(PublicKey::<LegacyKeccak>::from_slice(&[0_u8; 63]).is_err());
616        assert!(MessageDigest::from_slice(&[0_u8; 33]).is_err());
617    }
618
619    #[test]
620    fn public_key_and_signature_round_trip() {
621        let key = PublicKey::<HashSigsSha256GenericV1>::new([1_u8; 32], [2_u8; 32]);
622        assert_eq!(
623            PublicKey::from_slice(&key.to_bytes()).expect("valid key"),
624            key
625        );
626
627        let signature = Signature::<HashSigsSha256GenericV1>::new([[3_u8; 32]; CHAIN_COUNT]);
628        assert_eq!(
629            Signature::from_slice(&signature.to_bytes()).expect("valid signature"),
630            signature
631        );
632    }
633
634    #[test]
635    fn secret_debug_output_is_redacted() {
636        let key = PrivateKey::<LegacyKeccak>::new([0xA5; 32]);
637        let text = format!("{key:?}");
638        assert!(text.contains("REDACTED"));
639        assert!(!text.contains("165"));
640    }
641}