Skip to content

Commit b615cd0

Browse files
authored
x.crypto.mldsa: add ML-DSA signature algorithm (#26711)
* sha3: add xof support (streaming) * x.mldsa: initial port of the golang implementation * x.mldsa: add NIST's ACVP tests for both keygen and sigver * x.mldsa: add roundtrip + other tests * x.mldsa: add README * x.mldsa: add benchmarks for sig + verif (44, 65, 87) * x.mldsa: refactor public api to avoid orphan functions, new Kind enum * x.mldsa: pinpoint each component to the FIPS spec algo/section/appendix * sha3: comment on the usage of unsafe * x.mldsa: update README with correct test file paths * x.mldsa: format all the stuff * x.mldsa: update README with new api * x.mldsa: support importing raw key material * x.mldsa: refactor benchmarks, allocate in bulk + add direct_array_access to relevant funcs * x.mldsa: add NIST ACVP signing testing * x.mldsa: add prehash support * x.mldsa: refactor tests * x.mldsa: workaround the false positive when copying fixed arrays * x.mldsa: document panics * x.mldsa: reformat files * x.mldsa: move tests to vlang/slower_tests * x.mldsa: fix markdown too long * crypto.sha3: add documentation to all public fns * x.mldsa: make elements type aliases public * x.mldsa: do not use nested types * crypto.sha3: prohibit constructing Shake directly * x.mldsa: add tests against go's impl + roundtrip * x.mldsa: bound rejection sampling loop in sign * x.mldsa: add necessary functions to the public api (for testing)
1 parent 1a6277d commit b615cd0

15 files changed

Lines changed: 2581 additions & 0 deletions

File tree

vlib/crypto/sha3/xof.v

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2023 Kim Shrier. All rights reserved.
2+
// Use of this source code is governed by an MIT license
3+
// that can be found in the LICENSE file.
4+
5+
// streaming shake-128/256 xof per FIPS 202
6+
// https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.202.pdf
7+
8+
module sha3
9+
10+
@[noinit]
11+
pub struct Shake {
12+
rate int // bytes per permutation (168 for shake-128, 136 for shake-256)
13+
mut:
14+
s State
15+
input_buffer []u8
16+
finalized bool
17+
squeeze_buf []u8
18+
}
19+
20+
// new_shake128 returns a new Shake instance for SHAKE-128 extended output function.
21+
pub fn new_shake128() &Shake {
22+
return &Shake{
23+
rate: xof_rate_128
24+
}
25+
}
26+
27+
// new_shake256 returns a new Shake instance for SHAKE-256 extended output function.
28+
pub fn new_shake256() &Shake {
29+
return &Shake{
30+
rate: xof_rate_256
31+
}
32+
}
33+
34+
// write absorbs more data into the sponge state.
35+
// Panics if called after `read`.
36+
@[direct_array_access]
37+
pub fn (mut s Shake) write(data []u8) {
38+
if s.finalized {
39+
panic('sha3: write after read on Shake')
40+
}
41+
if data.len == 0 {
42+
return
43+
}
44+
45+
// avoid cloning on each iteration
46+
mut remaining := unsafe { data[..] }
47+
48+
if s.input_buffer.len != 0 {
49+
empty_space := s.rate - s.input_buffer.len
50+
51+
if remaining.len < empty_space {
52+
s.input_buffer << remaining
53+
return
54+
} else {
55+
s.input_buffer << remaining[..empty_space]
56+
remaining = unsafe { remaining[empty_space..] }
57+
58+
s.s.xor_bytes(s.input_buffer[..s.rate], s.rate)
59+
s.s.kaccak_p_1600_24()
60+
61+
s.input_buffer = []u8{}
62+
}
63+
}
64+
65+
for remaining.len >= s.rate {
66+
s.s.xor_bytes(remaining[..s.rate], s.rate)
67+
s.s.kaccak_p_1600_24()
68+
remaining = unsafe { remaining[s.rate..] }
69+
}
70+
71+
if remaining.len > 0 {
72+
s.input_buffer = remaining.clone()
73+
}
74+
}
75+
76+
fn (mut s Shake) finalize() {
77+
if s.finalized {
78+
return
79+
}
80+
s.finalized = true
81+
82+
// pad10*1 with xof domain separator 0x1f (FIPS 202 sec B.2)
83+
mut padded := s.input_buffer.clone()
84+
if padded.len == s.rate - 1 {
85+
padded << u8(0x80 | 0x1f)
86+
} else {
87+
padded << u8(0x1f)
88+
for padded.len < s.rate - 1 {
89+
padded << u8(0x00)
90+
}
91+
padded << u8(0x80)
92+
}
93+
94+
s.s.xor_bytes(padded[..s.rate], s.rate)
95+
s.s.kaccak_p_1600_24()
96+
97+
state_bytes := s.s.to_bytes()
98+
s.squeeze_buf = state_bytes[..s.rate].clone()
99+
s.input_buffer = []u8{}
100+
}
101+
102+
// read squeezes `out_len` bytes from the sponge state.
103+
// Finalizes the sponge on first call; further calls to `write` will panic.
104+
@[direct_array_access]
105+
pub fn (mut s Shake) read(out_len int) []u8 {
106+
if !s.finalized {
107+
s.finalize()
108+
}
109+
110+
mut result := []u8{cap: out_len}
111+
mut remaining := out_len
112+
113+
for remaining > 0 {
114+
if s.squeeze_buf.len == 0 {
115+
s.s.kaccak_p_1600_24()
116+
state_bytes := s.s.to_bytes()
117+
s.squeeze_buf = state_bytes[..s.rate].clone()
118+
}
119+
120+
take := if remaining < s.squeeze_buf.len { remaining } else { s.squeeze_buf.len }
121+
result << s.squeeze_buf[..take]
122+
s.squeeze_buf = s.squeeze_buf[take..].clone()
123+
remaining -= take
124+
}
125+
126+
return result
127+
}
128+
129+
// reset clears the sponge state, allowing the Shake instance to be reused.
130+
pub fn (mut s Shake) reset() {
131+
s.s = State{}
132+
s.input_buffer = []u8{}
133+
s.finalized = false
134+
s.squeeze_buf = []u8{}
135+
}

vlib/crypto/sha3/xof_test.v

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
module sha3
2+
3+
fn test_shake256_streaming_matches_oneshot() {
4+
data := 'hello world'.bytes()
5+
// oneshot
6+
expected := shake256(data, 64)
7+
8+
// streaming
9+
mut s := new_shake256()
10+
s.write(data)
11+
result := s.read(64)
12+
13+
assert result == expected, 'streaming SHAKE-256 output differs from one-shot'
14+
}
15+
16+
fn test_shake128_streaming_matches_oneshot() {
17+
data := 'hello world'.bytes()
18+
expected := shake128(data, 64)
19+
20+
mut s := new_shake128()
21+
s.write(data)
22+
result := s.read(64)
23+
24+
assert result == expected, 'streaming SHAKE-128 output differs from one-shot'
25+
}
26+
27+
fn test_shake256_incremental_write() {
28+
data := 'the quick brown fox jumps over the lazy dog'.bytes()
29+
expected := shake256(data, 128)
30+
31+
mut s := new_shake256()
32+
s.write(data[..10])
33+
s.write(data[10..25])
34+
s.write(data[25..])
35+
result := s.read(128)
36+
37+
assert result == expected, 'incremental write produced different output'
38+
}
39+
40+
fn test_shake256_incremental_read() {
41+
data := 'test data for incremental reads'.bytes()
42+
43+
// all at once
44+
mut s1 := new_shake256()
45+
s1.write(data)
46+
all_at_once := s1.read(200)
47+
48+
// in chunks
49+
mut s2 := new_shake256()
50+
s2.write(data)
51+
mut chunked := []u8{}
52+
chunked << s2.read(50)
53+
chunked << s2.read(80)
54+
chunked << s2.read(70)
55+
56+
assert chunked == all_at_once, 'incremental read produced different output'
57+
}
58+
59+
fn test_shake128_large_output() {
60+
data := 'large output test'.bytes()
61+
mut s := new_shake128()
62+
s.write(data)
63+
// more than one block (168 bytes in shake128)
64+
result := s.read(500)
65+
assert result.len == 500
66+
}
67+
68+
fn test_shake_reset() {
69+
data := 'reset test'.bytes()
70+
71+
mut s := new_shake256()
72+
s.write(data)
73+
first := s.read(32)
74+
75+
s.reset()
76+
s.write(data)
77+
second := s.read(32)
78+
79+
assert first == second, 'reset did not restore initial state'
80+
}

vlib/x/crypto/mldsa/LICENSE

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Copyright 2025 The Go Authors.
2+
3+
Redistribution and use in source and binary forms, with or without
4+
modification, are permitted provided that the following conditions are
5+
met:
6+
7+
* Redistributions of source code must retain the above copyright
8+
notice, this list of conditions and the following disclaimer.
9+
* Redistributions in binary form must reproduce the above
10+
copyright notice, this list of conditions and the following disclaimer
11+
in the documentation and/or other materials provided with the
12+
distribution.
13+
* Neither the name of Google LLC nor the names of its
14+
contributors may be used to endorse or promote products derived from
15+
this software without specific prior written permission.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21+
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

vlib/x/crypto/mldsa/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# mldsa
2+
3+
Pure V implementation of [ML-DSA](https://csrc.nist.gov/pubs/fips/204/final) (FIPS 204), a post-quantum digital signature algorithm. Supports all three parameter sets (ML-DSA-44, ML-DSA-65, ML-DSA-87).
4+
5+
> **This is still experimental**
6+
> It is verified against NIST ACVP test vectors for [keygen](./nist_keygen_test.v),
7+
> [signing](./nist_siggen_test.v), and [verification](./nist_sigver_test.v),
8+
> but not yet production-ready.
9+
10+
## Example
11+
12+
```v
13+
import x.crypto.mldsa
14+
15+
fn main() {
16+
// generate a new ML-DSA-65 key pair
17+
sk := mldsa.PrivateKey.generate(.ml_dsa_65)!
18+
pk := sk.public_key()
19+
20+
// sign a message (with an optional context string)
21+
msg := 'Hello ML-DSA'.bytes()
22+
sig := sk.sign(msg, context: 'not-a-drill')!
23+
24+
// verify the signature with the same context
25+
verified := pk.verify(msg, sig, context: 'not-a-drill')!
26+
assert verified // true
27+
28+
// deterministic signing is also available
29+
sig2 := sk.sign(msg, context: 'not-a-drill', deterministic: true)!
30+
verified2 := pk.verify(msg, sig2, context: 'not-a-drill')!
31+
assert verified2 // true
32+
}
33+
```

0 commit comments

Comments
 (0)