Skip to main content

dstu_core/
crypto_sign.rs

1//! `crypto_sign` equivalent (`docs/dstu-crypto-project.md` "Mapping onto the libsodium API",
2//! `docs/TASKS.md` T-48) - a libsodium-ergonomics wrapper over `hazmat::dstu4145::signature`. The first
3//! module in the high-level layer D-09 planned but never built (`docs/release-readiness.md` step
4//! 4) - this session's shape for it: `SigningKey`/`VerifyingKey`/`Signature`, `ed25519-dalek`-style
5//! naming (`docs/DECISIONS.md` D-04's addendum cites that crate's convention).
6//!
7//! Two departures from `hazmat::dstu4145::signature`'s raw API, both documented in `docs/DECISIONS.md`
8//! D-46:
9//! - **The ephemeral nonce is derived deterministically** from `(d, message)` via
10//!   `hazmat::kupyna_kmac` (an RFC-6979-style adaptation, not a literal port - RFC 6979 is
11//!   HMAC-specific, `hazmat::kupyna_kmac`'s construction is not HMAC). No RNG dependency anywhere
12//!   in this module, unlike Bouncy Castle's `DSTU4145Signer` (which uses `SecureRandom`) - a
13//!   deliberate, user-confirmed deviation from the reference, matching Ed25519/libsodium's own
14//!   misuse-resistant signing design rather than the DSA-family default of caller-supplied
15//!   randomness (whose reuse is a real-world catastrophic key-recovery class: PS3, several Bitcoin
16//!   wallet thefts).
17//! - **`sign`/`verify` take a raw `message: &[u8]`, not a pre-computed digest** - this module
18//!   hashes it internally with Kupyna-256 (`hazmat::kupyna::Kupyna256`), matching libsodium's own
19//!   `crypto_sign(message, ...)` ergonomics. `hazmat::dstu4145::signature` itself stays
20//!   digest-agnostic (its own doc comment's stated design), unaffected by this choice.
21//!
22//! **Large/streamed messages (`docs/TASKS.md` T-113): `sign_digest`/`verify_digest`.** DSTU 4145 signs
23//! a hash of the message, not a domain-separated multi-part construction (`docs/pseudocode/
24//! dstu4145.md` §5.9/§9/§10: `h ← hash_to_field(H(T))`) - so there is no "streaming signer" to
25//! build, only a need to let the hash itself be computed incrementally. `sign`/`verify` above
26//! still take the whole message and hash it with one `Kupyna256::digest` call, which needs it all
27//! in memory at once; `sign_digest`/`verify_digest` instead take an already-computed 32-byte
28//! Kupyna-256 digest directly, so a caller with a large or streamed message can hash it themselves
29//! via `hazmat::kupyna::Kupyna256Hasher::{new, update, finalize}` (already `no_std`-compatible,
30//! bounded memory regardless of message size) and pass the result in. `sign`/`verify` are now thin
31//! wrappers over these two.
32//!
33//! **Keypair generation (`docs/TASKS.md` T-122): [`SigningKey::generate`].** `from_bytes` above only
34//! ever *validates* a caller-supplied `d` - until this method existed there was no way to obtain a
35//! valid `d` through the public API at all, without reaching into `hazmat` internals
36//! (`curve163::order()` isn't part of this module's own surface). `#[cfg(any(feature = "std",
37//! feature = "getrandom"))]`-gated (needs `crate::randombytes`, `docs/TASKS.md` T-123/`docs/DECISIONS.md`
38//! D-74), matching every other `crypto_*` module's own `Key::generate` convention
39//! (`crypto_secretbox`/`crypto_auth`/`crypto_kdf`/`crypto_stream`/`crypto_secretstream`).
40//!
41//! `VerifyingKey::to_uncompressed_bytes`/`from_uncompressed_bytes` use a plain 42-byte `x || y`
42//! encoding, **not** the DSTU 4145 standard's own compressed point encoding (official text
43//! §6.9/§6.10, `DSTU4145PointEncoder.java` in Bouncy Castle) - that encoding isn't implemented
44//! anywhere in this project yet (`docs/pseudocode/dstu4145.md`'s existing note lists it as future
45//! work, unrelated to sign/verify itself). Anyone needing interoperable, spec-compliant public-key
46//! serialization must wait for that, tracked separately in `docs/TASKS.md`.
47//!
48//! # Example
49//!
50//! A signature proves a message came from whoever holds the signing key and hasn't been altered
51//! since - unlike [`crate::crypto_secretbox`], it does not hide the message's contents, only
52//! attests to its origin and integrity. Both the success path and a rejected forgery are shown
53//! below (`docs/TASKS.md` T-120's own requirement - a signature example that only shows the happy path
54//! doesn't demonstrate the primitive actually does what it claims).
55//!
56//! ```rust
57//! use dstu_core::crypto_sign::SigningKey;
58//!
59//! # if cfg!(miri) { return; } // several Point::scalar_multiply calls - minutes each under Miri's
60//! # // interpreter (docs/TASKS.md T-100/T-156/D-113); type-checked normally, just not executed
61//! # // there. `cargo test` (not Miri) still runs this doctest for real every push.
62//! let signing_key = SigningKey::generate().expect("OS CSPRNG should not fail");
63//! let verifying_key = signing_key.verifying_key(); // safe to share/publish
64//!
65//! let message = b"a message whose origin and integrity matter";
66//! let signature = signing_key.sign(message);
67//! assert!(verifying_key.verify(message, &signature));
68//!
69//! // A different message, or a signature from a different key, must fail to verify.
70//! assert!(!verifying_key.verify(b"a different message", &signature));
71//! let other_key = SigningKey::generate().expect("OS CSPRNG should not fail");
72//! assert!(!other_key.verifying_key().verify(message, &signature));
73//! ```
74
75use crate::hazmat::dstu4145::curve163::{self, Point};
76use crate::hazmat::dstu4145::gf2m163::FieldElement;
77use crate::hazmat::dstu4145::scalar::Scalar;
78use crate::hazmat::dstu4145::signature;
79use crate::hazmat::kupyna::Kupyna256;
80use crate::hazmat::kupyna_kmac::Kupyna256Kmac;
81use zeroize::Zeroize;
82
83/// One-byte curve identifier for tagged, self-describing serialization of a
84/// [`crate::crypto_sign::VerifyingKey`] or [`crate::crypto_sign257::VerifyingKey`] - lives here
85/// (not duplicated in `crypto_sign257`, and not in `uacrypt` or any language binding) so every
86/// caller shares the same tag numbering, the D-118 lesson (`crypto_secretstream`'s wire-format
87/// validation) already learned once about not letting each binding re-hand-roll a parser
88/// (`docs/DECISIONS.md` D-186 Decision 1). Values `3` and above are reserved for the other 8
89/// `DSTU4145NamedCurves.java` curve sizes, if any are ever implemented (`docs/TASKS.md` T-199).
90///
91/// This tags *keys/signatures a caller serializes themselves* (e.g. `uacrypt sign-pubkey`'s output
92/// file) - `crypto_sign::VerifyingKey`/`crypto_sign257::VerifyingKey`'s own `to_uncompressed_bytes`
93/// stay untagged fixed-width encodings (42/66 bytes), matching how they already worked before this
94/// enum existed; a caller that wants a self-describing blob prepends the matching [`CurveId`] byte
95/// itself, exactly as `uacrypt`'s own `sign-pubkey`/`sign-pubkey257`/`verify` commands do.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97#[repr(u8)]
98pub enum CurveId {
99    M163 = 0x01,
100    M257 = 0x02,
101}
102
103impl CurveId {
104    #[must_use]
105    pub fn to_byte(self) -> u8 {
106        self as u8
107    }
108
109    /// Returns `None` for any tag this crate doesn't (yet) implement - the caller decides how to
110    /// report that (`docs/DECISIONS.md` D-186 Decision 3's "a named error, not a silent `false`"
111    /// applies at whatever layer actually surfaces this to a user, e.g. `uacrypt verify`'s own
112    /// message).
113    #[must_use]
114    pub fn from_byte(tag: u8) -> Option<Self> {
115        match tag {
116            0x01 => Some(CurveId::M163),
117            0x02 => Some(CurveId::M257),
118            _ => None,
119        }
120    }
121}
122
123/// A DSTU 4145 signature, `r || s` (21 bytes each, 42 total - `hazmat::dstu4145::signature`'s own
124/// byte convention).
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub struct Signature {
127    r: [u8; 21],
128    s: [u8; 21],
129}
130
131impl Signature {
132    #[must_use]
133    pub fn to_bytes(&self) -> [u8; 42] {
134        let mut out = [0u8; 42];
135        out[..21].copy_from_slice(&self.r);
136        out[21..].copy_from_slice(&self.s);
137        out
138    }
139
140    #[must_use]
141    pub fn from_bytes(bytes: &[u8; 42]) -> Self {
142        let mut r = [0u8; 21];
143        let mut s = [0u8; 21];
144        r.copy_from_slice(&bytes[..21]);
145        s.copy_from_slice(&bytes[21..]);
146        Signature { r, s }
147    }
148}
149
150/// A DSTU 4145 private key. Signing needs no RNG (see the module doc) - only key generation from
151/// external entropy is the caller's concern, same posture as `hazmat::kalyna_ccm`'s nonce
152/// (`docs/DECISIONS.md` D-40): this module takes `d` as given rather than generating it.
153pub struct SigningKey(Scalar);
154
155impl Drop for SigningKey {
156    fn drop(&mut self) {
157        self.0.zeroize();
158    }
159}
160
161/// A DSTU 4145 public key `Q = -d*G` (`hazmat::dstu4145::signature`'s module doc / `docs/DECISIONS.md`
162/// D-25's follow-up entry on the sign convention).
163#[derive(Clone, Copy)]
164pub struct VerifyingKey(Point);
165
166impl SigningKey {
167    /// Builds a signing key from a big-endian 21-byte scalar. Returns `None` if `d` is zero or
168    /// not less than the curve order `n` - both invalid private keys, rejected here rather than
169    /// left to silently misbehave later (`hazmat::dstu4145::scalar::Scalar::from_be_bytes` itself
170    /// does not validate, by its own documented convention).
171    #[must_use]
172    pub fn from_bytes(d: &[u8; 21]) -> Option<Self> {
173        let n = curve163::order();
174        if d.iter().all(|&b| b == 0) || d >= &n {
175            return None;
176        }
177        Some(SigningKey(Scalar::from_be_bytes(d)))
178    }
179
180    /// Generates a fresh signing key from the OS CSPRNG - libsodium's `crypto_sign_keypair()`
181    /// equivalent (its public-key half is [`Self::verifying_key`]). `d` is drawn via **rejection
182    /// sampling**, uniform over `[1, n)`, never a modulo reduction - `n` is not a power of two, so
183    /// `candidate mod n` would bias small residues (`docs/TASKS.md` T-122). `n`'s top byte is `0x04`
184    /// (`hazmat::dstu4145::curve163::order`'s own doc comment: `n` is a 163-bit value inside 21
185    /// bytes/168 bits), so masking each candidate's top byte down to its low 3 bits (`0x07`) keeps
186    /// the rejection rate near 50% instead of over 90% for an unmasked 168-bit draw. The
187    /// range/nonzero check itself goes through [`Scalar::from_candidate_bytes`]'s constant-time
188    /// comparison, not a branching `>=`, so evaluating one candidate adds no data-dependent-branch
189    /// timing signal beyond the draw count every rejection-sampling scheme inherently has.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`crate::randombytes::RandomError`] if the OS CSPRNG fails while drawing a
194    /// candidate.
195    #[cfg(any(feature = "std", feature = "getrandom"))]
196    pub fn generate() -> Result<Self, crate::randombytes::RandomError> {
197        loop {
198            let mut candidate = [0u8; 21];
199            crate::randombytes::randombytes_buf(&mut candidate)?;
200            candidate[0] &= 0x07;
201            let scalar = Scalar::from_candidate_bytes(&candidate);
202            candidate.zeroize();
203            if let Some(scalar) = scalar {
204                return Ok(SigningKey(scalar));
205            }
206        }
207    }
208
209    /// Returns `d`'s big-endian 21-byte encoding, so a generated key can be persisted (e.g.
210    /// `uacrypt sign-keygen`, `docs/TASKS.md` T-124) and later reloaded via [`Self::from_bytes`]. The
211    /// caller becomes responsible for zeroizing the returned array once done with it - the same
212    /// convention `hazmat::dstu4145::scalar::Scalar::to_be_bytes` and
213    /// `VerifyingKey::to_uncompressed_bytes` already have (this module has no wrapper type for a
214    /// bare byte array to hang a `Drop` impl off of).
215    #[must_use]
216    pub fn to_bytes(&self) -> [u8; 21] {
217        self.0.to_be_bytes()
218    }
219
220    #[must_use]
221    pub fn verifying_key(&self) -> VerifyingKey {
222        let g = Point::generator();
223        let q = g.scalar_multiply(&self.0.to_be_bytes()).negate();
224        VerifyingKey(q)
225    }
226
227    /// Signs `message`, hashing it with Kupyna-256 and deriving the ephemeral nonce
228    /// deterministically (see the module doc, `docs/DECISIONS.md` D-46). A thin wrapper over
229    /// [`Self::sign_digest`] - see that method, and the module doc's T-113 note, for signing a
230    /// message too large to hold in memory whole.
231    #[must_use]
232    pub fn sign(&self, message: &[u8]) -> Signature {
233        self.sign_digest(&Kupyna256::digest(message))
234    }
235
236    /// Signs an already-computed 32-byte Kupyna-256 digest directly - for messages hashed
237    /// incrementally via `hazmat::kupyna::Kupyna256Hasher` rather than held whole in memory (see
238    /// the module doc's T-113 note). The hazmat-level degenerate rejections (`F_e == 0`, `r == 0`,
239    /// `s == 0`, each ~`2^-163`) are retried here with the next nonce-derivation counter rather
240    /// than surfaced to the caller - safe to retry because the nonce is re-derived, not reused.
241    #[must_use]
242    pub fn sign_digest(&self, digest: &[u8; 32]) -> Signature {
243        let g = Point::generator();
244        let mut counter: u8 = 0;
245        loop {
246            let e = derive_nonce(self.0, digest, counter);
247            if let Some((r, s)) = signature::sign(digest, self.0, e, g) {
248                return Signature { r, s };
249            }
250            counter = counter.wrapping_add(1);
251        }
252    }
253}
254
255impl VerifyingKey {
256    #[must_use]
257    pub fn to_uncompressed_bytes(&self) -> [u8; 42] {
258        let mut out = [0u8; 42];
259        match self.0 {
260            Point::Affine(x, y) => {
261                out[..21].copy_from_slice(&x.to_be_bytes());
262                out[21..].copy_from_slice(&y.to_be_bytes());
263            }
264            Point::Infinity => {} // never produced by verifying_key() for a valid SigningKey
265        }
266        out
267    }
268
269    #[must_use]
270    pub fn from_uncompressed_bytes(bytes: &[u8; 42]) -> Self {
271        let x = FieldElement::from_be_bytes(&bytes[..21]);
272        let y = FieldElement::from_be_bytes(&bytes[21..]);
273        VerifyingKey(Point::Affine(x, y))
274    }
275
276    /// A thin wrapper over [`Self::verify_digest`] - see that method, and the module doc's T-113
277    /// note, for verifying a message too large to hold in memory whole.
278    #[must_use]
279    pub fn verify(&self, message: &[u8], sig: &Signature) -> bool {
280        self.verify_digest(&Kupyna256::digest(message), sig)
281    }
282
283    /// Verifies against an already-computed 32-byte Kupyna-256 digest directly - for messages
284    /// hashed incrementally via `hazmat::kupyna::Kupyna256Hasher` rather than held whole in memory
285    /// (see the module doc's T-113 note).
286    #[must_use]
287    pub fn verify_digest(&self, digest: &[u8; 32], sig: &Signature) -> bool {
288        let g = Point::generator();
289        signature::verify(digest, &sig.r, &sig.s, self.0, g)
290    }
291}
292
293/// Deterministic ephemeral-nonce derivation (`docs/DECISIONS.md` D-46): `e = reduce_mod_n(KMAC(key =
294/// zero-padded d, message = hash || counter))`, retried with an incremented `counter` on the
295/// ~`2^-163`-probability chance of a zero result or a hazmat-level degenerate rejection. `d`'s
296/// 21-byte big-endian value is left-padded with zeros to `Kupyna256Kmac`'s required 32-byte key
297/// length (`hazmat::kupyna_kmac`'s key length must equal its `mac_len`) - an embedding, not a
298/// truncation, so no information about `d` is lost.
299fn derive_nonce(d: Scalar, hash: &[u8; 32], counter: u8) -> Scalar {
300    let mut key = [0u8; 32];
301    key[11..].copy_from_slice(&d.to_be_bytes());
302    let mut message = [0u8; 33];
303    message[..32].copy_from_slice(hash);
304    message[32] = counter;
305
306    let Ok(mac) = Kupyna256Kmac::mac(&key, &message) else {
307        unreachable!("key is always exactly 32 bytes, Kupyna256Kmac's required length")
308    };
309    key.zeroize();
310    Scalar::reduce_wide_bytes(&mac)
311}