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}