From b615cd08d134956354a72dcc42a6a6ad4e39cb64 Mon Sep 17 00:00:00 2001 From: cstef <53212129+cestef@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:37:25 +0100 Subject: [PATCH] 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) --- vlib/crypto/sha3/xof.v | 135 +++++ vlib/crypto/sha3/xof_test.v | 80 +++ vlib/x/crypto/mldsa/LICENSE | 27 + vlib/x/crypto/mldsa/README.md | 33 ++ vlib/x/crypto/mldsa/encoding.v | 590 ++++++++++++++++++++++ vlib/x/crypto/mldsa/field.v | 159 ++++++ vlib/x/crypto/mldsa/mldsa.v | 518 +++++++++++++++++++ vlib/x/crypto/mldsa/mldsa_test.v | 221 ++++++++ vlib/x/crypto/mldsa/ntt.v | 190 +++++++ vlib/x/crypto/mldsa/params.v | 129 +++++ vlib/x/crypto/mldsa/prehash.v | 65 +++ vlib/x/crypto/mldsa/sampling.v | 146 ++++++ vlib/x/crypto/mldsa/testdata/gen.go | 120 +++++ vlib/x/crypto/mldsa/testdata/gen.vsh | 79 +++ vlib/x/crypto/mldsa/testdata/vectors.json | 89 ++++ 15 files changed, 2581 insertions(+) create mode 100644 vlib/crypto/sha3/xof.v create mode 100644 vlib/crypto/sha3/xof_test.v create mode 100644 vlib/x/crypto/mldsa/LICENSE create mode 100644 vlib/x/crypto/mldsa/README.md create mode 100644 vlib/x/crypto/mldsa/encoding.v create mode 100644 vlib/x/crypto/mldsa/field.v create mode 100644 vlib/x/crypto/mldsa/mldsa.v create mode 100644 vlib/x/crypto/mldsa/mldsa_test.v create mode 100644 vlib/x/crypto/mldsa/ntt.v create mode 100644 vlib/x/crypto/mldsa/params.v create mode 100644 vlib/x/crypto/mldsa/prehash.v create mode 100644 vlib/x/crypto/mldsa/sampling.v create mode 100644 vlib/x/crypto/mldsa/testdata/gen.go create mode 100644 vlib/x/crypto/mldsa/testdata/gen.vsh create mode 100644 vlib/x/crypto/mldsa/testdata/vectors.json diff --git a/vlib/crypto/sha3/xof.v b/vlib/crypto/sha3/xof.v new file mode 100644 index 000000000..0b7986c45 --- /dev/null +++ b/vlib/crypto/sha3/xof.v @@ -0,0 +1,135 @@ +// Copyright (c) 2023 Kim Shrier. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. + +// streaming shake-128/256 xof per FIPS 202 +// https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.202.pdf + +module sha3 + +@[noinit] +pub struct Shake { + rate int // bytes per permutation (168 for shake-128, 136 for shake-256) +mut: + s State + input_buffer []u8 + finalized bool + squeeze_buf []u8 +} + +// new_shake128 returns a new Shake instance for SHAKE-128 extended output function. +pub fn new_shake128() &Shake { + return &Shake{ + rate: xof_rate_128 + } +} + +// new_shake256 returns a new Shake instance for SHAKE-256 extended output function. +pub fn new_shake256() &Shake { + return &Shake{ + rate: xof_rate_256 + } +} + +// write absorbs more data into the sponge state. +// Panics if called after `read`. +@[direct_array_access] +pub fn (mut s Shake) write(data []u8) { + if s.finalized { + panic('sha3: write after read on Shake') + } + if data.len == 0 { + return + } + + // avoid cloning on each iteration + mut remaining := unsafe { data[..] } + + if s.input_buffer.len != 0 { + empty_space := s.rate - s.input_buffer.len + + if remaining.len < empty_space { + s.input_buffer << remaining + return + } else { + s.input_buffer << remaining[..empty_space] + remaining = unsafe { remaining[empty_space..] } + + s.s.xor_bytes(s.input_buffer[..s.rate], s.rate) + s.s.kaccak_p_1600_24() + + s.input_buffer = []u8{} + } + } + + for remaining.len >= s.rate { + s.s.xor_bytes(remaining[..s.rate], s.rate) + s.s.kaccak_p_1600_24() + remaining = unsafe { remaining[s.rate..] } + } + + if remaining.len > 0 { + s.input_buffer = remaining.clone() + } +} + +fn (mut s Shake) finalize() { + if s.finalized { + return + } + s.finalized = true + + // pad10*1 with xof domain separator 0x1f (FIPS 202 sec B.2) + mut padded := s.input_buffer.clone() + if padded.len == s.rate - 1 { + padded << u8(0x80 | 0x1f) + } else { + padded << u8(0x1f) + for padded.len < s.rate - 1 { + padded << u8(0x00) + } + padded << u8(0x80) + } + + s.s.xor_bytes(padded[..s.rate], s.rate) + s.s.kaccak_p_1600_24() + + state_bytes := s.s.to_bytes() + s.squeeze_buf = state_bytes[..s.rate].clone() + s.input_buffer = []u8{} +} + +// read squeezes `out_len` bytes from the sponge state. +// Finalizes the sponge on first call; further calls to `write` will panic. +@[direct_array_access] +pub fn (mut s Shake) read(out_len int) []u8 { + if !s.finalized { + s.finalize() + } + + mut result := []u8{cap: out_len} + mut remaining := out_len + + for remaining > 0 { + if s.squeeze_buf.len == 0 { + s.s.kaccak_p_1600_24() + state_bytes := s.s.to_bytes() + s.squeeze_buf = state_bytes[..s.rate].clone() + } + + take := if remaining < s.squeeze_buf.len { remaining } else { s.squeeze_buf.len } + result << s.squeeze_buf[..take] + s.squeeze_buf = s.squeeze_buf[take..].clone() + remaining -= take + } + + return result +} + +// reset clears the sponge state, allowing the Shake instance to be reused. +pub fn (mut s Shake) reset() { + s.s = State{} + s.input_buffer = []u8{} + s.finalized = false + s.squeeze_buf = []u8{} +} diff --git a/vlib/crypto/sha3/xof_test.v b/vlib/crypto/sha3/xof_test.v new file mode 100644 index 000000000..1e4e97f91 --- /dev/null +++ b/vlib/crypto/sha3/xof_test.v @@ -0,0 +1,80 @@ +module sha3 + +fn test_shake256_streaming_matches_oneshot() { + data := 'hello world'.bytes() + // oneshot + expected := shake256(data, 64) + + // streaming + mut s := new_shake256() + s.write(data) + result := s.read(64) + + assert result == expected, 'streaming SHAKE-256 output differs from one-shot' +} + +fn test_shake128_streaming_matches_oneshot() { + data := 'hello world'.bytes() + expected := shake128(data, 64) + + mut s := new_shake128() + s.write(data) + result := s.read(64) + + assert result == expected, 'streaming SHAKE-128 output differs from one-shot' +} + +fn test_shake256_incremental_write() { + data := 'the quick brown fox jumps over the lazy dog'.bytes() + expected := shake256(data, 128) + + mut s := new_shake256() + s.write(data[..10]) + s.write(data[10..25]) + s.write(data[25..]) + result := s.read(128) + + assert result == expected, 'incremental write produced different output' +} + +fn test_shake256_incremental_read() { + data := 'test data for incremental reads'.bytes() + + // all at once + mut s1 := new_shake256() + s1.write(data) + all_at_once := s1.read(200) + + // in chunks + mut s2 := new_shake256() + s2.write(data) + mut chunked := []u8{} + chunked << s2.read(50) + chunked << s2.read(80) + chunked << s2.read(70) + + assert chunked == all_at_once, 'incremental read produced different output' +} + +fn test_shake128_large_output() { + data := 'large output test'.bytes() + mut s := new_shake128() + s.write(data) + // more than one block (168 bytes in shake128) + result := s.read(500) + assert result.len == 500 +} + +fn test_shake_reset() { + data := 'reset test'.bytes() + + mut s := new_shake256() + s.write(data) + first := s.read(32) + + s.reset() + s.write(data) + second := s.read(32) + + assert first == second, 'reset did not restore initial state' +} diff --git a/vlib/x/crypto/mldsa/LICENSE b/vlib/x/crypto/mldsa/LICENSE new file mode 100644 index 000000000..e18b7ff11 --- /dev/null +++ b/vlib/x/crypto/mldsa/LICENSE @@ -0,0 +1,27 @@ +Copyright 2025 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vlib/x/crypto/mldsa/README.md b/vlib/x/crypto/mldsa/README.md new file mode 100644 index 000000000..7cc5de0c4 --- /dev/null +++ b/vlib/x/crypto/mldsa/README.md @@ -0,0 +1,33 @@ +# mldsa + +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). + +> **This is still experimental** +> It is verified against NIST ACVP test vectors for [keygen](./nist_keygen_test.v), +> [signing](./nist_siggen_test.v), and [verification](./nist_sigver_test.v), +> but not yet production-ready. + +## Example + +```v +import x.crypto.mldsa + +fn main() { + // generate a new ML-DSA-65 key pair + sk := mldsa.PrivateKey.generate(.ml_dsa_65)! + pk := sk.public_key() + + // sign a message (with an optional context string) + msg := 'Hello ML-DSA'.bytes() + sig := sk.sign(msg, context: 'not-a-drill')! + + // verify the signature with the same context + verified := pk.verify(msg, sig, context: 'not-a-drill')! + assert verified // true + + // deterministic signing is also available + sig2 := sk.sign(msg, context: 'not-a-drill', deterministic: true)! + verified2 := pk.verify(msg, sig2, context: 'not-a-drill')! + assert verified2 // true +} +``` diff --git a/vlib/x/crypto/mldsa/encoding.v b/vlib/x/crypto/mldsa/encoding.v new file mode 100644 index 000000000..5239cdc43 --- /dev/null +++ b/vlib/x/crypto/mldsa/encoding.v @@ -0,0 +1,590 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Ported to V from Go's crypto/internal/fips140/mldsa. +module mldsa + +import crypto.internal.subtle +import crypto.sha3 + +// algo. 22: pkEncode (s. 7.2) +@[direct_array_access] +fn pk_encode(rho []u8, t1 [][]u16, p Params) []u8 { + mut pk := rho.clone() + for i in 0 .. p.k { + w := t1[i] + mut j := 0 + for j < n { + c0 := w[j] + c1 := w[j + 1] + c2 := w[j + 2] + c3 := w[j + 3] + pk << u8(c0) + pk << u8((c0 >> 8) | (c1 << 2)) + pk << u8((c1 >> 6) | (c2 << 4)) + pk << u8((c2 >> 4) | (c3 << 6)) + pk << u8(c3 >> 2) + j += 4 + } + } + return pk +} + +// algo. 23: pkDecode (s. 7.2) +@[direct_array_access] +fn pk_decode(pk []u8, p Params) !([]u8, [][]u16) { + expected := pub_key_size(p) + if pk.len != expected { + return error('invalid public key length') + } + rho := pk[..32].clone() + // avoid cloning on each iteration + mut data := unsafe { pk[32..] } + mut t1 := [][]u16{len: p.k, init: []u16{len: n}} + for r in 0 .. p.k { + mut j := 0 + for j < n { + b0 := data[0] + b1 := data[1] + b2 := data[2] + b3 := data[3] + b4 := data[4] + t1[r][j] = u16(b0) | (u16(b1 & 0x03) << 8) + t1[r][j + 1] = u16(b1 >> 2) | (u16(b2 & 0x0f) << 6) + t1[r][j + 2] = u16(b2 >> 4) | (u16(b3 & 0x3f) << 4) + t1[r][j + 3] = u16(b3 >> 6) | (u16(b4) << 2) + data = unsafe { data[5..] } + j += 4 + } + } + return rho, t1 +} + +fn compute_pk_hash(pk []u8) [64]u8 { + return slice_to_64(sha3.shake256(pk, 64)) +} + +@[direct_array_access] +fn compute_t1_hat(t1 [][]u16) []NttElement { + mut result := []NttElement{len: t1.len} + for i in 0 .. t1.len { + mut w := RingElement{} + for j in 0 .. n { + // panics if (t1[i][j] << d) >= q, c.f field.v + // only called from pk_decode, which produces 10bit vals (<= 1023) + // so 1023 << 13 = 8_380_416 = q - 1 + z := field_to_montgomery(u32(t1[i][j]) << d) or { panic(err) } + w[j] = z + } + result[i] = ntt(w) + } + return result +} + +// algo. 35: Power2Round (s. 7.4) +fn power2_round(r FieldElement) (u16, FieldElement) { + rr_ := field_from_montgomery(r) + r1 := (rr_ + (1 << 12) - 1) >> d + r0 := field_sub_to_montgomery(rr_, r1 << d) + return u16(r1), r0 +} + +// algo. 37: HighBits (s. 7.4) +@[direct_array_access] +fn high_bits(r RingElement, p Params) [256]u8 { + mut w := [256]u8{} + match p.gamma2 { + 32 { + for i in 0 .. n { + w[i] = high_bits_32(field_from_montgomery(r[i])) + } + } + 88 { + for i in 0 .. n { + w[i] = high_bits_88(field_from_montgomery(r[i])) + } + } + else { + panic('mldsa: unsupported gamma2') // unreachable + } + } + return w +} + +fn high_bits_32(x u32) u8 { + mut r1 := (x + 127) >> 7 // approx div by 2*gamma2 + r1 = (r1 * 1025 + (1 << 21)) >> 22 + r1 &= 0xf + return u8(r1) +} + +fn high_bits_88(x u32) u8 { + mut r1 := (x + 127) >> 7 // approx div by 2*gamma2 + r1 = (r1 * 11275 + (1 << 23)) >> 24 + r1 = ct_select_eq(r1, 44, 0, r1) + return u8(r1) +} + +// algo. 36: Decompose, gamma2 = (q-1)/32 (s. 7.4) +fn decompose_32(r FieldElement) (u8, i32) { + x := field_from_montgomery(r) + r1 := high_bits_32(x) + r0 := i32(x) - i32(r1) * 2 * i32((q - 1) / 32) + r0_adj := ct_select_leq(i32(q / 2 + 1), r0, r0 - i32(q), r0) + return r1, r0_adj +} + +// algo. 36: Decompose, gamma2 = (q-1)/88 (s. 7.4) +fn decompose_88(r FieldElement) (u8, i32) { + x := field_from_montgomery(r) + r1 := high_bits_88(x) + r0 := i32(x) - i32(r1) * 2 * i32((q - 1) / 88) + r0_adj := ct_select_leq(i32(q / 2 + 1), r0, r0 - i32(q), r0) + return r1, r0_adj +} + +@[direct_array_access] +fn low_bits_exceed_bound(w RingElement, bound u32, p Params) bool { + match p.gamma2 { + 32 { + for i in 0 .. n { + _, r0 := decompose_32(w[i]) + if ct_abs(r0) >= bound { + return true + } + } + } + 88 { + for i in 0 .. n { + _, r0 := decompose_88(w[i]) + if ct_abs(r0) >= bound { + return true + } + } + } + else { + panic('mldsa: unsupported gamma2') // unreachable + } + } + return false +} + +// algo. 40: UseHint (s. 7.4) +@[direct_array_access] +fn use_hint(r RingElement, h [256]u8, p Params) [256]u8 { + mut w := [256]u8{} + match p.gamma2 { + 32 { + for i in 0 .. n { + w[i] = use_hint_32(r[i], h[i]) + } + } + 88 { + for i in 0 .. n { + w[i] = use_hint_88(r[i], h[i]) + } + } + else { + panic('mldsa: unsupported gamma2') // unreachable + } + } + return w +} + +fn use_hint_32(r FieldElement, hint u8) u8 { + mut r1, r0 := decompose_32(r) + if hint == 1 { + if r0 > 0 { + r1 = (r1 + 1) % 16 + } else { + r1 = (r1 - 1) % 16 + } + } + return r1 +} + +fn use_hint_88(r FieldElement, hint u8) u8 { + mut r1, r0 := decompose_88(r) + if hint == 1 { + if r0 > 0 { + if r1 == 43 { + r1 = 0 + } else { + r1++ + } + } else { + if r1 == 0 { + r1 = 43 + } else { + r1-- + } + } + } + return r1 +} + +// algo. 39: MakeHint (s. 7.4) +@[direct_array_access] +fn make_hint(ct0 RingElement, w RingElement, cs2 RingElement, p Params) ([256]u8, int) { + mut h := [256]u8{} + mut count := 0 + match p.gamma2 { + 32 { + for i in 0 .. n { + h[i] = make_hint_32(ct0[i], w[i], cs2[i]) + count += int(h[i]) + } + } + 88 { + for i in 0 .. n { + h[i] = make_hint_88(ct0[i], w[i], cs2[i]) + count += int(h[i]) + } + } + else { + panic('mldsa: unsupported gamma2') // unreachable + } + } + return h, count +} + +fn make_hint_32(ct0 FieldElement, w FieldElement, cs2 FieldElement) u8 { + r_plus_z := field_sub(w, cs2) + v1 := high_bits_32(field_from_montgomery(r_plus_z)) + r1 := high_bits_32(field_from_montgomery(field_add(r_plus_z, ct0))) + return u8(1 - subtle.constant_time_byte_eq(v1, r1)) +} + +fn make_hint_88(ct0 FieldElement, w FieldElement, cs2 FieldElement) u8 { + r_plus_z := field_sub(w, cs2) + v1 := high_bits_88(field_from_montgomery(r_plus_z)) + r1 := high_bits_88(field_from_montgomery(field_add(r_plus_z, ct0))) + return u8(1 - subtle.constant_time_byte_eq(v1, r1)) +} + +fn w1_encode_len(p Params) int { + return match p.gamma2 { + 32 { 4 * n / 8 } + 88 { 6 * n / 8 } + else { panic('mldsa: unsupported gamma2') } // unreachable + } +} + +// algo. 28: w1Encode (s. 7.2) +@[direct_array_access] +fn w1_encode(w [256]u8, p Params, mut buf []u8) { + match p.gamma2 { + 32 { + for i := 0; i < n; i += 2 { + buf[i / 2] = w[i] | (w[i + 1] << 4) + } + } + 88 { + for i := 0; i < n; i += 4 { + buf[3 * i / 4] = w[i] | (w[i + 1] << 6) + buf[3 * i / 4 + 1] = (w[i + 1] >> 2) | (w[i + 2] << 4) + buf[3 * i / 4 + 2] = (w[i + 2] >> 4) | (w[i + 3] << 2) + } + } + else { + panic('mldsa: unsupported gamma2') // unreachable + } + } +} + +// algo. 26: sigEncode (s. 7.2) +fn sig_encode(ch []u8, z []RingElement, h [][256]u8, p Params) []u8 { + mut sig := ch.clone() + for i in 0 .. z.len { + sig << bit_pack(z[i], p) + } + sig << hint_encode(h, p) + return sig +} + +// algo. 27: sigDecode (s. 7.2) +@[direct_array_access] +fn sig_decode(sig []u8, p Params) !([]u8, []RingElement, [][256]u8) { + expected := sig_size(p) + if sig.len != expected { + return error('invalid signature length') + } + ch_len := p.lambda / 4 + ch := sig[..ch_len].clone() + mut offset := ch_len + mut z := []RingElement{len: p.l} + for i in 0 .. p.l { + length := (p.gamma1 + 1) * n / 8 + z[i] = bit_unpack(sig[offset..offset + length], p) + offset += length + } + h := hint_decode(sig[offset..], p)! + return ch, z, h +} + +// algo. 17: BitPack (s. 7.1) +fn bit_pack(r RingElement, p Params) []u8 { + match p.gamma1 { + 17 { return bit_pack_18(r) } + 19 { return bit_pack_20(r) } + else { panic('mldsa: unsupported gamma1') } // unreachable + } +} + +@[direct_array_access] +fn bit_pack_18(r RingElement) []u8 { + mut v := []u8{len: 18 * n / 8} + mut pos := 0 + for i := 0; i < n; i += 4 { + w0 := u32(1 << 17) - u32(field_centered_mod(r[i])) + w1 := u32(1 << 17) - u32(field_centered_mod(r[i + 1])) + w2 := u32(1 << 17) - u32(field_centered_mod(r[i + 2])) + w3 := u32(1 << 17) - u32(field_centered_mod(r[i + 3])) + v[pos] = u8(w0) + v[pos + 1] = u8(w0 >> 8) + v[pos + 2] = u8(w0 >> 16) + v[pos + 2] |= u8(w1 << 2) + v[pos + 3] = u8(w1 >> 6) + v[pos + 4] = u8(w1 >> 14) + v[pos + 4] |= u8(w2 << 4) + v[pos + 5] = u8(w2 >> 4) + v[pos + 6] = u8(w2 >> 12) + v[pos + 6] |= u8(w3 << 6) + v[pos + 7] = u8(w3 >> 2) + v[pos + 8] = u8(w3 >> 10) + pos += 9 + } + return v +} + +@[direct_array_access] +fn bit_pack_20(r RingElement) []u8 { + mut v := []u8{len: 20 * n / 8} + mut pos := 0 + for i := 0; i < n; i += 2 { + w0 := u32(1 << 19) - u32(field_centered_mod(r[i])) + w1 := u32(1 << 19) - u32(field_centered_mod(r[i + 1])) + v[pos] = u8(w0) + v[pos + 1] = u8(w0 >> 8) + v[pos + 2] = u8(w0 >> 16) + v[pos + 2] |= u8(w1 << 4) + v[pos + 3] = u8(w1 >> 4) + v[pos + 4] = u8(w1 >> 12) + pos += 5 + } + return v +} + +// algo. 19: BitUnpack (s. 7.1) +fn bit_unpack(v []u8, p Params) RingElement { + match p.gamma1 { + 17 { return bit_unpack_18(v) } + 19 { return bit_unpack_20(v) } + else { panic('mldsa: unsupported gamma1') } // unreachable + } +} + +@[direct_array_access] +fn bit_unpack_18(v []u8) RingElement { + mut r := RingElement{} + mut pos := 0 + for i := 0; i < n; i += 4 { + w0 := u32(v[pos]) | (u32(v[pos + 1]) << 8) | (u32(v[pos + 2]) << 16) + r[i] = field_sub_to_montgomery(u32(1 << 17), w0 & 0x3ffff) + w1 := (u32(v[pos + 2]) >> 2) | (u32(v[pos + 3]) << 6) | (u32(v[pos + 4]) << 14) + r[i + 1] = field_sub_to_montgomery(u32(1 << 17), w1 & 0x3ffff) + w2 := (u32(v[pos + 4]) >> 4) | (u32(v[pos + 5]) << 4) | (u32(v[pos + 6]) << 12) + r[i + 2] = field_sub_to_montgomery(u32(1 << 17), w2 & 0x3ffff) + w3 := (u32(v[pos + 6]) >> 6) | (u32(v[pos + 7]) << 2) | (u32(v[pos + 8]) << 10) + r[i + 3] = field_sub_to_montgomery(u32(1 << 17), w3 & 0x3ffff) + pos += 9 + } + return r +} + +@[direct_array_access] +fn bit_unpack_20(v []u8) RingElement { + mut r := RingElement{} + mut pos := 0 + for i := 0; i < n; i += 2 { + w0 := u32(v[pos]) | (u32(v[pos + 1]) << 8) | (u32(v[pos + 2]) << 16) + r[i] = field_sub_to_montgomery(u32(1 << 19), w0 & 0xfffff) + w1 := (u32(v[pos + 2]) >> 4) | (u32(v[pos + 3]) << 4) | (u32(v[pos + 4]) << 12) + r[i + 1] = field_sub_to_montgomery(u32(1 << 19), w1 & 0xfffff) + pos += 5 + } + return r +} + +// algo. 20: HintBitPack (s. 7.2) +@[direct_array_access] +fn hint_encode(h [][256]u8, p Params) []u8 { + mut out := []u8{len: p.omega + p.k} + mut idx := 0 + for i in 0 .. p.k { + for j in 0 .. n { + if h[i][j] != 0 { + out[idx] = u8(j) + idx++ + } + } + out[p.omega + i] = u8(idx) + } + return out +} + +// algo. 21: HintBitUnpack (s. 7.2) +@[direct_array_access] +fn hint_decode(y []u8, p Params) ![][256]u8 { + if y.len != p.omega + p.k { + return error('invalid hint length') + } + mut h := [][256]u8{len: p.k, init: [256]u8{}} + mut idx := u8(0) + for i in 0 .. p.k { + limit := y[p.omega + i] + if limit < idx || limit > u8(p.omega) { + return error('invalid hint limits') + } + first := idx + for idx < limit { + if idx > first && y[idx - 1] >= y[idx] { + return error('invalid hint index order') + } + h[i][y[idx]] = 1 + idx++ + } + } + for i := int(idx); i < p.omega; i++ { + if y[i] != 0 { + return error('invalid hint padding') + } + } + return h +} + +@[direct_array_access] +fn bit_pack_slow(r RingElement, a int, b int) []u8 { + bitlen := bits_len(u32(a + b)) + mut out := []u8{len: n * bitlen / 8} + mut acc := u32(0) + mut acc_bits := u32(0) + mut vi := 0 + for i in 0 .. n { + w := u32(int(b) - int(field_centered_mod(r[i]))) + acc |= w << acc_bits + acc_bits += u32(bitlen) + for acc_bits >= 8 { + out[vi] = u8(acc) + vi++ + acc >>= 8 + acc_bits -= 8 + } + } + if acc_bits > 0 { + out[vi] = u8(acc) + } + return out +} + +@[direct_array_access] +fn bit_unpack_slow(v []u8, a int, b int) !RingElement { + bitlen := bits_len(u32(a + b)) + if v.len != n * bitlen / 8 { + return error('mldsa: invalid input length for bit_unpack_slow') + } + + mask := u32((1 << bitlen) - 1) + max_value := u32(a + b) + + mut r := RingElement{} + mut acc := u32(0) + mut acc_bits := u32(0) + mut vi := 0 + + for i in 0 .. n { + for acc_bits < u32(bitlen) { + if vi < v.len { + acc |= u32(v[vi]) << acc_bits + vi++ + acc_bits += 8 + } + } + w := acc & mask + if w > max_value { + return error('mldsa: coefficient out of range') + } + r[i] = field_sub_to_montgomery(u32(b), w) + acc >>= u32(bitlen) + acc_bits -= u32(bitlen) + } + + return r +} + +fn bits_len(x u32) int { + if x == 0 { + return 0 + } + mut v := x + mut n_ := 0 + for v > 0 { + v >>= 1 + n_++ + } + return n_ +} + +// algo. 24: skEncode (s. 7.2) +fn sk_encode(rho []u8, capital_k [32]u8, tr [64]u8, s1 []NttElement, s2 []NttElement, t0 []NttElement, p Params) []u8 { + mut out := []u8{} + out << rho[..32] + out << capital_k[..] + out << tr[..] + eta := int(p.eta) + for i in 0 .. p.l { + out << bit_pack_slow(inverse_ntt(s1[i]), eta, eta) + } + for i in 0 .. p.k { + out << bit_pack_slow(inverse_ntt(s2[i]), eta, eta) + } + for i in 0 .. p.k { + out << bit_pack_slow(inverse_ntt(t0[i]), 4095, 4096) + } + return out +} + +// algo. 25: skDecode (s. 7.2) +fn sk_decode(sk []u8, p Params) !([]u8, [32]u8, [64]u8, []RingElement, []RingElement, []RingElement) { + k, l, eta := p.k, p.l, p.eta + if sk.len != priv_key_size(p) { + return error('mldsa: invalid private key size') + } + rho := sk[..32].clone() + capital_k := slice_to_32(sk[32..64]) + tr := slice_to_64(sk[64..128]) + mut offset := 128 + + eta_len := n * bits_len(u32(eta * 2)) / 8 + mut s1 := []RingElement{len: l} + for i in 0 .. l { + s1[i] = bit_unpack_slow(sk[offset..offset + eta_len], eta, eta)! + offset += eta_len + } + + mut s2 := []RingElement{len: k} + for i in 0 .. k { + s2[i] = bit_unpack_slow(sk[offset..offset + eta_len], eta, eta)! + offset += eta_len + } + + t0_len := n * 13 / 8 + mut t0 := []RingElement{len: k} + for i in 0 .. k { + t0[i] = bit_unpack_slow(sk[offset..offset + t0_len], 4095, 4096)! + offset += t0_len + } + + return rho, capital_k, tr, s1, s2, t0 +} diff --git a/vlib/x/crypto/mldsa/field.v b/vlib/x/crypto/mldsa/field.v new file mode 100644 index 000000000..abb8ce9d6 --- /dev/null +++ b/vlib/x/crypto/mldsa/field.v @@ -0,0 +1,159 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Ported to V from Go's crypto/internal/fips140/mldsa. +module mldsa + +import crypto.internal.subtle + +// s. 2.3, appendix a +const q = u32(8380417) // 2^23 - 2^13 + 1 +const rr = u32(2365951) // R^2 mod q (R = 2^32) +const q_neg_inv = u32(4236238847) // -q^-1 mod R (appendix a: QINV = 58728449) +const mont_one = u32(4193792) // R mod q +const mont_minus_one = u32(4186625) // (q-1)*R mod q +const n = 256 +const d = 13 + +// signing uses rejection sampling that succeeds with p =~ 1/5.22 +// per attempt (worst case: ML-DSA-65, over 10k sigs) +// P(no converg. after 512 iters) = (1 - 1/5.22)^512 =~ 2^-157 +// for ref. ML-DSA-44 security is 2^-128 +const max_sign_attempts = 512 + +type FieldElement = u32 +type RingElement = [256]u32 +type NttElement = [256]u32 + +fn field_to_montgomery(a u32) !FieldElement { + if a >= q { + return error('unreduced field element ${a}') + } + return field_montgomery_mul(FieldElement(a), rr) +} + +fn field_sub_to_montgomery(a u32, b u32) FieldElement { + x := a - b + q + return field_montgomery_mul(FieldElement(x), rr) +} + +fn field_from_montgomery(a FieldElement) u32 { + return u32(field_montgomery_reduce(u64(a))) +} + +fn field_centered_mod(r FieldElement) i32 { + x := i32(field_from_montgomery(r)) + return ct_select_leq(x, i32(q / 2), x, x - i32(q)) +} + +fn field_infinity_norm(r FieldElement) u32 { + x := i32(field_from_montgomery(r)) + return u32(ct_select_leq(x, i32(q / 2), x, i32(q) - x)) +} + +fn field_reduce_once(a u32) FieldElement { + if a >= q { + return FieldElement(a - q) + } + return FieldElement(a) +} + +fn field_add(a FieldElement, b FieldElement) FieldElement { + return field_reduce_once(u32(a) + u32(b)) +} + +fn field_sub(a FieldElement, b FieldElement) FieldElement { + return field_reduce_once(u32(a) - u32(b) + q) +} + +fn field_montgomery_mul(a FieldElement, b FieldElement) FieldElement { + x := u64(a) * u64(b) + return field_montgomery_reduce(x) +} + +// algo. 49: MontgomeryReduce +fn field_montgomery_reduce(x u64) FieldElement { + t := u32(x) * q_neg_inv + u_ := (x + u64(t) * u64(q)) >> 32 + return field_reduce_once(u32(u_)) +} + +fn field_montgomery_mul_sub(a FieldElement, b FieldElement, c FieldElement) FieldElement { + x := u64(a) * u64(u32(b) - u32(c) + q) + return field_montgomery_reduce(x) +} + +fn field_montgomery_add_mul(a FieldElement, b FieldElement, c FieldElement, d_ FieldElement) FieldElement { + x := u64(a) * u64(b) + u64(c) * u64(d_) + return field_montgomery_reduce(x) +} + +@[direct_array_access] +fn poly_add_ring(a RingElement, b RingElement) RingElement { + mut s := RingElement{} + for i in 0 .. n { + s[i] = field_add(a[i], b[i]) + } + return s +} + +@[direct_array_access] +fn poly_add_ntt(a NttElement, b NttElement) NttElement { + mut s := NttElement{} + for i in 0 .. n { + s[i] = field_add(a[i], b[i]) + } + return s +} + +@[direct_array_access] +fn poly_sub_ring(a RingElement, b RingElement) RingElement { + mut s := RingElement{} + for i in 0 .. n { + s[i] = field_sub(a[i], b[i]) + } + return s +} + +@[direct_array_access] +fn poly_sub_ntt(a NttElement, b NttElement) NttElement { + mut s := NttElement{} + for i in 0 .. n { + s[i] = field_sub(a[i], b[i]) + } + return s +} + +// algo. 45: MultiplyNTT +@[direct_array_access] +fn ntt_mul(a NttElement, b NttElement) NttElement { + mut p := NttElement{} + for i in 0 .. n { + p[i] = field_montgomery_mul(a[i], b[i]) + } + return p +} + +@[direct_array_access] +fn coefficients_exceed_bound(w RingElement, bound u32) bool { + for i in 0 .. n { + if field_infinity_norm(w[i]) >= bound { + return true + } + } + return false +} + +fn ct_select_leq(a i32, b i32, yes i32, no i32) i32 { + return if subtle.constant_time_less_or_eq(int(a), int(b)) == 1 { yes } else { no } +} + +fn ct_select_eq(a u32, b u32, yes u32, no u32) u32 { + return u32(subtle.constant_time_select(subtle.constant_time_eq(int(a), int(b)), int(yes), + int(no))) +} + +fn ct_abs(x i32) u32 { + return u32(ct_select_leq(0, x, x, -x)) +} diff --git a/vlib/x/crypto/mldsa/mldsa.v b/vlib/x/crypto/mldsa/mldsa.v new file mode 100644 index 000000000..4ef49e4ac --- /dev/null +++ b/vlib/x/crypto/mldsa/mldsa.v @@ -0,0 +1,518 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Ported to V from Go's crypto/internal/fips140/mldsa. + +// ML-DSA (Module-Lattice-Based Digital Signature Algorithm) per FIPS 204 +// https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.204.pdf + +module mldsa + +import crypto.rand +import crypto.sha3 +import crypto.internal.subtle + +@[direct_array_access] +fn slice_to_32(s []u8) [32]u8 { + mut a := [32]u8{} + for i in 0 .. 32 { + a[i] = s[i] + } + return a +} + +@[direct_array_access] +fn slice_to_64(s []u8) [64]u8 { + mut a := [64]u8{} + for i in 0 .. 64 { + a[i] = s[i] + } + return a +} + +pub struct PrivateKey { + seed [32]u8 + pk PublicKey + s1 []NttElement // len = l + s2 []NttElement // len = k + t0 []NttElement // len = k + k [32]u8 +} + +pub struct PublicKey { + raw []u8 + p Params + a []NttElement // k*l matrix in NTT domain + t1 []NttElement // len = k, NTT(t1 * 2^d) + tr [64]u8 +} + +// algo. 1: ML-DSA.KeyGen (s. 5.1) +pub fn PrivateKey.generate(kind Kind) !PrivateKey { + return new_private_key(slice_to_32(rand.read(32)!), kind.params()) +} + +pub fn PrivateKey.from_seed(seed []u8, kind Kind) !PrivateKey { + if seed.len != 32 { + return error('invalid seed length') + } + return new_private_key(slice_to_32(seed), kind.params()) +} + +// from FIPS 204 semi-expanded encoding. seed() and equal() are +// meaningless on the result — use from_seed when possible. +pub fn PrivateKey.from_bytes(raw []u8, kind Kind) !PrivateKey { + return new_private_key_from_bytes(raw, kind.params()) +} + +pub fn PublicKey.from_bytes(raw []u8, kind Kind) !PublicKey { + return new_public_key(raw, kind.params()) +} + +pub fn (sk &PrivateKey) public_key() &PublicKey { + return &sk.pk +} + +pub fn (sk &PrivateKey) seed() []u8 { + mut s := []u8{len: 32} + for i in 0 .. 32 { + s[i] = sk.seed[i] + } + return s +} + +pub fn (sk &PrivateKey) bytes() []u8 { + return sk_encode(sk.pk.raw[..32], sk.k, sk.pk.tr, sk.s1, sk.s2, sk.t0, sk.pk.p) +} + +// seed-based constant-time comparison. not meaningful for from_bytes keys. +pub fn (sk &PrivateKey) equal(other &PrivateKey) bool { + mut a := []u8{len: 32} + mut b := []u8{len: 32} + for i in 0 .. 32 { + a[i] = sk.seed[i] + b[i] = other.seed[i] + } + return sk.pk.p == other.pk.p && subtle.constant_time_compare(a, b) == 1 +} + +// constant-time comparison of the serialized key material. slower but works for from_bytes keys. +pub fn (sk &PrivateKey) equal_bytes(other &PrivateKey) bool { + return sk.pk.p == other.pk.p && subtle.constant_time_compare(sk.bytes(), other.bytes()) == 1 +} + +// algo. 2/4: ML-DSA.Sign / HashML-DSA.Sign (s. 5.2, 5.4.1) +pub fn (sk &PrivateKey) sign(msg []u8, opts SignerOpts) ![]u8 { + if opts.context.len > 255 { + return error('context too long') + } + mu := if opts.prehash != .none { + compute_mu_prehash(sk.pk.tr[..], msg, opts.context, opts.prehash) + } else { + compute_mu(sk.pk.tr[..], msg, opts.context) + } + if opts.deterministic { + return sign_internal(sk, mu, [32]u8{}) + } + return sign_internal(sk, mu, slice_to_32(rand.read(32)!)) +} + +// sign_mu signs a precomputed mu value with explicit randomness. +// mu must be 64 bytes. rnd must be 32 bytes (use all zeros for deterministic signing). +pub fn (sk &PrivateKey) sign_mu(mu []u8, rnd []u8) ![]u8 { + if mu.len != 64 { + return error('mu must be 64 bytes') + } + if rnd.len != 32 { + return error('rnd must be 32 bytes') + } + return sign_internal(sk, slice_to_64(mu), slice_to_32(rnd)) +} + +pub fn (pk &PublicKey) bytes() []u8 { + return pk.raw.clone() +} + +// tr returns the 64-byte transcript hash (H(pk)) used in mu computation. +pub fn (pk &PublicKey) tr() []u8 { + return pk.tr[..] +} + +pub fn (pk &PublicKey) equal(other &PublicKey) bool { + return pk.p == other.p && subtle.constant_time_compare(pk.raw, other.raw) == 1 +} + +// algo. 3/5: ML-DSA.Verify / HashML-DSA.Verify (s. 5.3, 5.4.1) +pub fn (pk &PublicKey) verify(msg []u8, sig []u8, opts SignerOpts) !bool { + if opts.context.len > 255 { + return error('context too long') + } + mu := if opts.prehash != .none { + compute_mu_prehash(pk.tr[..], msg, opts.context, opts.prehash) + } else { + compute_mu(pk.tr[..], msg, opts.context) + } + return verify_internal(pk, mu, sig) +} + +pub fn (pk &PublicKey) verify_mu(mu []u8, sig []u8) !bool { + if mu.len != 64 { + return error('mu must be exactly 64 bytes') + } + return verify_internal(pk, slice_to_64(mu), sig) +} + +// algo. 6: ML-DSA.KeyGen_internal (s. 6.1) +fn new_private_key(seed [32]u8, p Params) PrivateKey { + k, l := p.k, p.l + + // expand seed into rho, rho', K + mut xi := sha3.new_shake256() + xi.write(seed[..]) + xi.write([u8(k), u8(l)]) + rho := xi.read(32) + rho_s := xi.read(64) + k_bytes := xi.read(32) + + a := compute_matrix_a(rho, p) + + mut s1 := []NttElement{len: l} + for r in 0 .. l { + s1[r] = ntt(sample_bounded_poly(rho_s, u8(r), p)) + } + mut s2 := []NttElement{len: k} + for r in 0 .. k { + s2[r] = ntt(sample_bounded_poly(rho_s, u8(l + r), p)) + } + + // t_hat = A_hat * s1_hat + s2_hat + mut t_hat := []NttElement{len: k} + for i in 0 .. k { + t_hat[i] = s2[i] + for j in 0 .. l { + t_hat[i] = poly_add_ntt(t_hat[i], ntt_mul(a[i * l + j], s1[j])) + } + } + + mut t1 := [][]u16{len: k, init: []u16{len: n}} + mut t0 := []NttElement{len: k} + for i in 0 .. k { + t_i := inverse_ntt(t_hat[i]) + mut w := RingElement{} + for j in 0 .. n { + t1[i][j], w[j] = power2_round(t_i[j]) + } + t0[i] = ntt(w) + } + + pk_bytes := pk_encode(rho, t1, p) + tr := compute_pk_hash(pk_bytes) + t1_hat := compute_t1_hat(t1) + + k_arr := slice_to_32(k_bytes) + + return PrivateKey{ + seed: seed + pk: PublicKey{ + raw: pk_bytes + p: p + a: a + t1: t1_hat + tr: tr + } + s1: s1 + s2: s2 + t0: t0 + k: k_arr + } +} + +fn new_private_key_from_bytes(sk []u8, p Params) !PrivateKey { + k, l := p.k, p.l + + rho, capital_k, tr, s1_ring, s2_ring, t0_ring := sk_decode(sk, p)! + + a := compute_matrix_a(rho, p) + + mut s1 := []NttElement{len: l} + for r in 0 .. l { + s1[r] = ntt(s1_ring[r]) + } + mut s2 := []NttElement{len: k} + for r in 0 .. k { + s2[r] = ntt(s2_ring[r]) + } + mut t0 := []NttElement{len: k} + for r in 0 .. k { + t0[r] = ntt(t0_ring[r]) + } + + // recompute t1 from rho, s1, s2 to verify consistency + mut t1 := [][]u16{len: k, init: []u16{len: n}} + for i in 0 .. k { + mut t_hat := s2[i] + for j in 0 .. l { + t_hat = poly_add_ntt(t_hat, ntt_mul(a[i * l + j], s1[j])) + } + t_i := inverse_ntt(t_hat) + for j in 0 .. n { + r1, r0 := power2_round(t_i[j]) + t1[i][j] = r1 + if r0 != t0_ring[i][j] { + return error('mldsa: private key inconsistent with t0') + } + } + } + + pk_bytes := pk_encode(rho, t1, p) + computed_tr := compute_pk_hash(pk_bytes) + if computed_tr != tr { + return error('mldsa: private key inconsistent with public key hash') + } + t1_hat := compute_t1_hat(t1) + + // use random bytes for seed since the semi-expanded format doesn't contain it + seed := slice_to_32(rand.read(32)!) + + return PrivateKey{ + seed: seed + pk: PublicKey{ + raw: pk_bytes + p: p + a: a + t1: t1_hat + tr: tr + } + s1: s1 + s2: s2 + t0: t0 + k: capital_k + } +} + +fn new_public_key(raw []u8, p Params) !PublicKey { + k, l := p.k, p.l + + rho, t1 := pk_decode(raw, p)! + a := compute_matrix_a(rho, p) + tr := compute_pk_hash(raw) + t1_hat := compute_t1_hat(t1) + + return PublicKey{ + raw: raw.clone() + p: p + a: a[..k * l].clone() + t1: t1_hat[..k].clone() + tr: tr + } +} + +// algo. 2, lines 10-11: M' = 0x00 || |ctx| || ctx || M; mu = H(tr || M', 64) +// compute_mu computes mu = H(tr || M', 64) where M' = 0x00 || |ctx| || ctx || msg. +pub fn compute_mu(tr []u8, msg []u8, context string) [64]u8 { + mut h := sha3.new_shake256() + h.write(tr) + h.write([u8(0)]) // pure mode domain sep + h.write([u8(context.len)]) + h.write(context.bytes()) + h.write(msg) + return slice_to_64(h.read(64)) +} + +// algo. 7: ML-DSA.Sign_internal (s. 6.2) +@[direct_array_access] +fn sign_internal(sk &PrivateKey, mu [64]u8, random [32]u8) ![]u8 { + p := sk.pk.p + k, l := p.k, p.l + a := sk.pk.a + s1 := sk.s1 + s2 := sk.s2 + t0 := sk.t0 + + beta := u32(p.tau * p.eta) + gamma1 := u32(1) << p.gamma1 + gamma1_beta := gamma1 - beta + gamma2 := (q - 1) / u32(p.gamma2) + gamma2_beta := gamma2 - beta + + // line 7: rho'' = H(K || rnd || mu, 64) + mut h_nonce := sha3.new_shake256() + h_nonce.write(sk.k[..]) + h_nonce.write(random[..]) + h_nonce.write(mu[..]) + nonce := h_nonce.read(64) + + mut kappa := 0 + + mut y := []RingElement{len: l} + mut y_hat := []NttElement{len: l} + mut w := []RingElement{len: k} + mut cs1 := []RingElement{len: l} + mut cs2 := []RingElement{len: k} + mut z := []RingElement{len: l} + mut ct0 := []RingElement{len: k} + mut h := [][256]u8{len: k, init: [256]u8{}} + mut w1_buf := []u8{len: w1_encode_len(p)} + + // lines 10-32: rejection sampling loop (bounded by max_sign_attempts) + for _ in 0 .. max_sign_attempts { + // line 11: y = ExpandMask(rho'', kappa) (algo. 34) + for r in 0 .. l { + counter := [u8(kappa & 0xff), u8(kappa >> 8)] + kappa++ + + mut h_y := sha3.new_shake256() + h_y.write(nonce) + h_y.write(counter) + v_bytes := h_y.read((p.gamma1 + 1) * n / 8) + y[r] = bit_unpack(v_bytes, p) + } + + // line 12: w = NTT^-1(A_hat * NTT(y)) + for i in 0 .. l { + y_hat[i] = ntt(y[i]) + } + for i in 0 .. k { + mut w_hat := NttElement{} + for j in 0 .. l { + w_hat = poly_add_ntt(w_hat, ntt_mul(a[i * l + j], y_hat[j])) + } + w[i] = inverse_ntt(w_hat) + } + + // line 13-14: w1 = HighBits(w); c_tilde = H(mu || w1Encode(w1), lambda/4) + mut h_ch := sha3.new_shake256() + h_ch.write(mu[..]) + for i in 0 .. k { + w1_encode(high_bits(w[i], p), p, mut w1_buf) + h_ch.write(w1_buf) + } + ch := h_ch.read(p.lambda / 4) + + // line 15-16: c = SampleInBall(c_tilde); c_hat = NTT(c) + c := ntt(sample_in_ball(ch, p)) + + // lines 17-20: cs1 = NTT^-1(c_hat * s1_hat); z = y + cs1 + for i in 0 .. l { + cs1[i] = inverse_ntt(ntt_mul(c, s1[i])) + } + for i in 0 .. k { + cs2[i] = inverse_ntt(ntt_mul(c, s2[i])) + } + + // line 23: ||z||_inf >= gamma1 - beta + mut reject := false + for i in 0 .. l { + z[i] = poly_add_ring(y[i], cs1[i]) + if coefficients_exceed_bound(z[i], gamma1_beta) { + reject = true + break + } + } + if reject { + continue + } + + // line 23: ||r0||_inf >= gamma2 - beta + reject = false + for i in 0 .. k { + r0 := poly_sub_ring(w[i], cs2[i]) + if low_bits_exceed_bound(r0, gamma2_beta, p) { + reject = true + break + } + } + if reject { + continue + } + + // line 25, 28: ct0 = NTT^-1(c_hat * t0_hat); ||ct0||_inf >= gamma2 + reject = false + for i in 0 .. k { + ct0[i] = inverse_ntt(ntt_mul(c, t0[i])) + if coefficients_exceed_bound(ct0[i], gamma2) { + reject = true + break + } + } + if reject { + continue + } + + // line 26, 28: h = MakeHint(-ct0, w - cs2 + ct0); count(h) > omega + mut count1s := 0 + for i in 0 .. k { + hint_result, count := make_hint(ct0[i], w[i], cs2[i], p) + h[i] = hint_result + count1s += count + } + if count1s > p.omega { + continue + } + + return sig_encode(ch, z, h, p) // line 33: sigEncode(c_tilde, z, h) + } + return error('signing failed: rejection sampling did not converge after ${max_sign_attempts} attempts') +} + +// algo. 8: ML-DSA.Verify_internal (s. 6.3) +@[direct_array_access] +fn verify_internal(pk &PublicKey, mu [64]u8, sig []u8) !bool { + p := pk.p + k, l := p.k, p.l + t1 := pk.t1 + a := pk.a + + beta := u32(p.tau * p.eta) + gamma1 := u32(1) << p.gamma1 + gamma1_beta := gamma1 - beta + + ch, z, h := sig_decode(sig, p) or { return false } + + c := ntt(sample_in_ball(ch, p)) + + // line 9: w'_approx = NTT^-1(A_hat * NTT(z) - NTT(c) * NTT(t1 * 2^d)) + mut z_hat := []NttElement{len: l} + for i in 0 .. l { + z_hat[i] = ntt(z[i]) + } + mut w := []RingElement{len: k} + for i in 0 .. k { + mut w_hat := NttElement{} + for j in 0 .. l { + w_hat = poly_add_ntt(w_hat, ntt_mul(a[i * l + j], z_hat[j])) + } + w_hat = poly_sub_ntt(w_hat, ntt_mul(c, t1[i])) + w[i] = inverse_ntt(w_hat) + } + + // line 10: w'1 = UseHint(h, w'_approx) + mut w1 := [][256]u8{len: k, init: [256]u8{}} + for i in 0 .. k { + w1[i] = use_hint(w[i], h[i], p) + } + + // line 12: c_tilde' = H(mu || w1Encode(w'1), lambda/4) + mut h_ch := sha3.new_shake256() + h_ch.write(mu[..]) + mut w1_buf := []u8{len: w1_encode_len(p)} + for i in 0 .. k { + w1_encode(w1[i], p, mut w1_buf) + h_ch.write(w1_buf) + } + computed_ch := h_ch.read(p.lambda / 4) + + // line 13: ||z||_inf < gamma1 - beta and c_tilde == c_tilde' + for i in 0 .. l { + if coefficients_exceed_bound(z[i], gamma1_beta) { + return false + } + } + + if subtle.constant_time_compare(ch, computed_ch) != 1 { + return false + } + + return true +} diff --git a/vlib/x/crypto/mldsa/mldsa_test.v b/vlib/x/crypto/mldsa/mldsa_test.v new file mode 100644 index 000000000..c2a3a8895 --- /dev/null +++ b/vlib/x/crypto/mldsa/mldsa_test.v @@ -0,0 +1,221 @@ +// regenerate go test vecs: v run testdata/gen.vsh [go-source-path] + +module mldsa + +import crypto.sha256 +import encoding.hex +import json +import os + +struct TestVec { + kind string + seed string + msg string + pk_sha256 string + sig_sha256 string + context string +} + +const vecs_json = os.read_file(os.real_path(os.join_path(os.dir(@FILE), 'testdata', 'vectors.json'))) or { + panic(err) +} +const test_vecs = json.decode([]TestVec, vecs_json) or { panic(err) } + +fn parse_kind(s string) Kind { + return match s { + 'ml_dsa_44' { Kind.ml_dsa_44 } + 'ml_dsa_65' { Kind.ml_dsa_65 } + 'ml_dsa_87' { Kind.ml_dsa_87 } + else { panic('unknown kind: ${s}') } + } +} + +fn test_keygen_sign_verify() { + assert test_vecs.len > 0, 'no test vectors loaded' + + for tv in test_vecs { + kind := parse_kind(tv.kind) + seed := hex.decode(tv.seed) or { panic(err) } + msg := hex.decode(tv.msg) or { panic(err) } + expected_pk_hash := hex.decode(tv.pk_sha256) or { panic(err) } + expected_sig_hash := hex.decode(tv.sig_sha256) or { panic(err) } + + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + pk := sk.public_key() + + pk_hash := sha256.sum(pk.bytes()) + assert pk_hash[..] == expected_pk_hash, 'pk hash mismatch for ${tv.kind} seed=${tv.seed[..16]}...' + + sig := sk.sign(msg, deterministic: true, context: tv.context) or { panic(err) } + sig_hash := sha256.sum(sig) + assert sig_hash[..] == expected_sig_hash, 'sig hash mismatch for ${tv.kind} seed=${tv.seed[..16]}...' + + verified := pk.verify(msg, sig, context: tv.context) or { panic(err) } + assert verified, 'verify returned false for ${tv.kind} seed=${tv.seed[..16]}...' + } +} + +fn test_verify_rejects_bad_signature() { + for kind in [Kind.ml_dsa_44, .ml_dsa_65, .ml_dsa_87] { + seed := []u8{len: 32, init: 0x00} + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + pk := sk.public_key() + msg := 'deadbeef'.bytes() + + sig := sk.sign(msg, deterministic: true) or { panic(err) } + + mut bad_sig := sig.clone() + bad_sig[10] ^= 0xff + + result := pk.verify(msg, bad_sig) or { false } + assert result == false, 'verify should reject tampered sig for ${kind}' + } +} + +fn test_verify_rejects_wrong_message() { + for kind in [Kind.ml_dsa_44, .ml_dsa_65, .ml_dsa_87] { + seed := []u8{len: 32, init: 0x01} + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + pk := sk.public_key() + msg := 'the beef is alive'.bytes() + + sig := sk.sign(msg, deterministic: true) or { panic(err) } + + result := pk.verify('I love strawberries'.bytes(), sig) or { false } + assert result == false, 'verify should reject wrong message for ${kind}' + } +} + +fn test_verify_rejects_wrong_context() { + for kind in [Kind.ml_dsa_44, .ml_dsa_65, .ml_dsa_87] { + seed := []u8{len: 32, init: 0x02} + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + pk := sk.public_key() + msg := 'very cool message'.bytes() + + sig := sk.sign(msg, deterministic: true, context: 'some context a') or { panic(err) } + + result := pk.verify(msg, sig, context: 'another context b') or { false } + assert result == false, 'verify should reject wrong context for ${kind}' + } +} + +fn test_public_key_roundtrip() { + for kind in [Kind.ml_dsa_44, .ml_dsa_65, .ml_dsa_87] { + seed := []u8{len: 32, init: 0x03} + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + pk := sk.public_key() + msg := 'pk roundtrip'.bytes() + + sig := sk.sign(msg, deterministic: true) or { panic(err) } + + pk2 := PublicKey.from_bytes(pk.bytes(), kind) or { panic(err) } + assert pk.equal(&pk2), 'roundtripped public key not equal' + + verified := pk2.verify(msg, sig) or { panic(err) } + assert verified, 'verify failed after public key roundtrip for ${kind}' + } +} + +fn test_prehash_sign_verify() { + prehashes := [ + PreHash.sha2_256, + .sha2_384, + .sha2_512, + .sha3_256, + .sha3_512, + .shake_128, + .shake_256, + ] + for kind in [Kind.ml_dsa_44, .ml_dsa_65, .ml_dsa_87] { + seed := []u8{len: 32, init: 0x05} + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + pk := sk.public_key() + msg := 'prehash test message'.bytes() + + for ph in prehashes { + sig := sk.sign(msg, deterministic: true, prehash: ph) or { panic(err) } + verified := pk.verify(msg, sig, prehash: ph) or { panic(err) } + assert verified, 'prehash verify failed for ${kind} ${ph}' + + // pure verify must reject a prehashed sig + pure_result := pk.verify(msg, sig) or { false } + assert pure_result == false, 'pure verify should reject prehash signature for ${kind} ${ph}' + } + } +} + +fn test_field_to_montgomery_roundtrip() { + for val in [u32(0), 1, 2, 100, 1000, q - 1] { + m := field_to_montgomery(val) or { panic(err) } + back := field_from_montgomery(m) + assert back == val, 'roundtrip failed for ${val}: got ${back}' + } +} + +fn test_field_add_sub() { + a := field_to_montgomery(100) or { panic(err) } + b := field_to_montgomery(200) or { panic(err) } + sum := field_add(a, b) + assert field_from_montgomery(sum) == 300 + + diff := field_sub(sum, b) + assert field_from_montgomery(diff) == 100 +} + +fn test_field_mul() { + a := field_to_montgomery(1000) or { panic(err) } + b := field_to_montgomery(2000) or { panic(err) } + prod := field_montgomery_mul(a, b) + assert field_from_montgomery(prod) == (1000 * 2000) % q +} + +fn test_ntt_inverse_ntt_roundtrip() { + mut f := RingElement{} + for i in 0 .. n { + f[i] = field_to_montgomery(u32(i % 100)) or { panic(err) } + } + ntt_f := ntt(f) + back := inverse_ntt(ntt_f) + for i in 0 .. n { + assert field_from_montgomery(back[i]) == field_from_montgomery(f[i]), 'NTT roundtrip failed at index ${i}' + } +} + +fn test_ntt_mul_is_polynomial_product() { + // (1 + x)^2 ?= x^2 + 2x + 1 + mut a := RingElement{} + a[0] = field_to_montgomery(1) or { panic(err) } + a[1] = field_to_montgomery(1) or { panic(err) } + + a_ntt := ntt(a) + prod_ntt := ntt_mul(a_ntt, a_ntt) + prod := inverse_ntt(prod_ntt) + + assert field_from_montgomery(prod[0]) == 1, 'expected x^2' + assert field_from_montgomery(prod[1]) == 2, 'expected 2x' + assert field_from_montgomery(prod[2]) == 1, 'expected 1' + + for i in 3 .. n { + assert field_from_montgomery(prod[i]) == 0, 'expected 0 at index ${i}, got ${field_from_montgomery(prod[i])}' + } +} + +fn test_power2_round() { + for val in [u32(0), 1, 100, 1000, q / 2, q - 1] { + r := field_to_montgomery(val) or { panic(err) } + hi, lo := power2_round(r) + reconstructed := field_add(field_to_montgomery(u32(hi) << d) or { panic(err) }, + lo) + assert field_from_montgomery(reconstructed) == val, 'power2_round failed for ${val}' + } +} + +fn test_private_key_roundtrip() { + for kind in [Kind.ml_dsa_44, .ml_dsa_65, .ml_dsa_87] { + seed := []u8{len: 32, init: 0x04} + sk := PrivateKey.from_seed(seed, kind) or { panic(err) } + sk2 := PrivateKey.from_seed(sk.seed(), kind) or { panic(err) } + assert sk.equal(&sk2), 'roundtripped private key not equal for ${kind}' + } +} diff --git a/vlib/x/crypto/mldsa/ntt.v b/vlib/x/crypto/mldsa/ntt.v new file mode 100644 index 000000000..7ad245ded --- /dev/null +++ b/vlib/x/crypto/mldsa/ntt.v @@ -0,0 +1,190 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Ported to V from Go's crypto/internal/fips140/mldsa. +module mldsa + +// appendix b: zeta^BitRev8(k) mod q in montgomery domain +const zetas = [u32(4193792), 25847, 5771523, 7861508, 237124, 7602457, 7504169, 466468, 1826347, + 2353451, 8021166, 6288512, 3119733, 5495562, 3111497, 2680103, 2725464, 1024112, 7300517, 3585928, + 7830929, 7260833, 2619752, 6271868, 6262231, 4520680, 6980856, 5102745, 1757237, 8360995, 4010497, + 280005, 2706023, 95776, 3077325, 3530437, 6718724, 4788269, 5842901, 3915439, 4519302, 5336701, + 3574422, 5512770, 3539968, 8079950, 2348700, 7841118, 6681150, 6736599, 3505694, 4558682, 3507263, + 6239768, 6779997, 3699596, 811944, 531354, 954230, 3881043, 3900724, 5823537, 2071892, 5582638, + 4450022, 6851714, 4702672, 5339162, 6927966, 3475950, 2176455, 6795196, 7122806, 1939314, 4296819, + 7380215, 5190273, 5223087, 4747489, 126922, 3412210, 7396998, 2147896, 2715295, 5412772, 4686924, + 7969390, 5903370, 7709315, 7151892, 8357436, 7072248, 7998430, 1349076, 1852771, 6949987, 5037034, + 264944, 508951, 3097992, 44288, 7280319, 904516, 3958618, 4656075, 8371839, 1653064, 5130689, + 2389356, 8169440, 759969, 7063561, 189548, 4827145, 3159746, 6529015, 5971092, 8202977, 1315589, + 1341330, 1285669, 6795489, 7567685, 6940675, 5361315, 4499357, 4751448, 3839961, 2091667, 3407706, + 2316500, 3817976, 5037939, 2244091, 5933984, 4817955, 266997, 2434439, 7144689, 3513181, 4860065, + 4621053, 7183191, 5187039, 900702, 1859098, 909542, 819034, 495491, 6767243, 8337157, 7857917, + 7725090, 5257975, 2031748, 3207046, 4823422, 7855319, 7611795, 4784579, 342297, 286988, 5942594, + 4108315, 3437287, 5038140, 1735879, 203044, 2842341, 2691481, 5790267, 1265009, 4055324, 1247620, + 2486353, 1595974, 4613401, 1250494, 2635921, 4832145, 5386378, 1869119, 1903435, 7329447, 7047359, + 1237275, 5062207, 6950192, 7929317, 1312455, 3306115, 6417775, 7100756, 1917081, 5834105, 7005614, + 1500165, 777191, 2235880, 3406031, 7838005, 5548557, 6709241, 6533464, 5796124, 4656147, 594136, + 4603424, 6366809, 2432395, 2454455, 8215696, 1957272, 3369112, 185531, 7173032, 5196991, 162844, + 1616392, 3014001, 810149, 1652634, 4686184, 6581310, 5341501, 3523897, 3866901, 269760, 2213111, + 7404533, 1717735, 472078, 7953734, 1723600, 6577327, 1910376, 6712985, 7276084, 8119771, 4546524, + 5441381, 6144432, 7959518, 6094090, 183443, 7403526, 1612842, 4834730, 7826001, 3919660, 8332111, + 7018208, 3937738, 1400424, 7534263, 1976782]! + +// algo. 41: NTT (s. 7.5) +@[direct_array_access] +fn ntt(f_ RingElement) NttElement { + mut f := unsafe { f_ } // workaround for false mutability notice + mut m := u8(0) + + mut len := 128 + for len >= 8 { + mut start := 0 + for start < 256 { + m++ + zeta := FieldElement(zetas[m]) + + mut j := 0 + for j < len { + t := field_montgomery_mul(zeta, f[start + len + j]) + f[start + len + j] = field_sub(f[start + j], t) + f[start + j] = field_add(f[start + j], t) + + t2 := field_montgomery_mul(zeta, f[start + len + j + 1]) + f[start + len + j + 1] = field_sub(f[start + j + 1], t2) + f[start + j + 1] = field_add(f[start + j + 1], t2) + + j += 2 + } + start += 2 * len + } + len /= 2 + } + + for start := 0; start < 256; start += 8 { + m++ + zeta := FieldElement(zetas[m]) + + mut t := field_montgomery_mul(zeta, f[start + 4]) + f[start + 4] = field_sub(f[start], t) + f[start] = field_add(f[start], t) + + t = field_montgomery_mul(zeta, f[start + 5]) + f[start + 5] = field_sub(f[start + 1], t) + f[start + 1] = field_add(f[start + 1], t) + + t = field_montgomery_mul(zeta, f[start + 6]) + f[start + 6] = field_sub(f[start + 2], t) + f[start + 2] = field_add(f[start + 2], t) + + t = field_montgomery_mul(zeta, f[start + 7]) + f[start + 7] = field_sub(f[start + 3], t) + f[start + 3] = field_add(f[start + 3], t) + } + + for start := 0; start < 256; start += 4 { + m++ + zeta := FieldElement(zetas[m]) + + mut t := field_montgomery_mul(zeta, f[start + 2]) + f[start + 2] = field_sub(f[start], t) + f[start] = field_add(f[start], t) + + t = field_montgomery_mul(zeta, f[start + 3]) + f[start + 3] = field_sub(f[start + 1], t) + f[start + 1] = field_add(f[start + 1], t) + } + + for start := 0; start < 256; start += 2 { + m++ + zeta := FieldElement(zetas[m]) + + t := field_montgomery_mul(zeta, f[start + 1]) + f[start + 1] = field_sub(f[start], t) + f[start] = field_add(f[start], t) + } + + return NttElement(f) +} + +// algo. 42: NTT^-1 (s. 7.5) +@[direct_array_access] +fn inverse_ntt(f_ NttElement) RingElement { + mut f := unsafe { f_ } // workaround for false mutability notice + mut m := u8(255) + + for start := 0; start < 256; start += 2 { + zeta := FieldElement(zetas[m]) + m-- + + t := f[start] + f[start] = field_add(t, f[start + 1]) + f[start + 1] = field_montgomery_mul_sub(zeta, f[start + 1], t) + } + + for start := 0; start < 256; start += 4 { + zeta := FieldElement(zetas[m]) + m-- + + mut t := f[start] + f[start] = field_add(t, f[start + 2]) + f[start + 2] = field_montgomery_mul_sub(zeta, f[start + 2], t) + + t = f[start + 1] + f[start + 1] = field_add(t, f[start + 3]) + f[start + 3] = field_montgomery_mul_sub(zeta, f[start + 3], t) + } + + for start := 0; start < 256; start += 8 { + zeta := FieldElement(zetas[m]) + m-- + + mut t := f[start] + f[start] = field_add(t, f[start + 4]) + f[start + 4] = field_montgomery_mul_sub(zeta, f[start + 4], t) + + t = f[start + 1] + f[start + 1] = field_add(t, f[start + 5]) + f[start + 5] = field_montgomery_mul_sub(zeta, f[start + 5], t) + + t = f[start + 2] + f[start + 2] = field_add(t, f[start + 6]) + f[start + 6] = field_montgomery_mul_sub(zeta, f[start + 6], t) + + t = f[start + 3] + f[start + 3] = field_add(t, f[start + 7]) + f[start + 7] = field_montgomery_mul_sub(zeta, f[start + 7], t) + } + + mut len2 := 8 + for len2 < 256 { + mut start := 0 + for start < 256 { + zeta := FieldElement(zetas[m]) + m-- + + mut j := 0 + for j < len2 { + mut t := f[start + j] + f[start + j] = field_add(t, f[start + len2 + j]) + f[start + len2 + j] = field_montgomery_mul_sub(zeta, f[start + len2 + j], + t) + + t = f[start + j + 1] + f[start + j + 1] = field_add(t, f[start + len2 + j + 1]) + f[start + len2 + j + 1] = field_montgomery_mul_sub(zeta, f[start + len2 + j + 1], + t) + + j += 2 + } + start += 2 * len2 + } + len2 *= 2 + } + + // algo. 42, line 21: f = 8347681 = 256^-1 mod q; in montgomery: 16382 + for i in 0 .. 256 { + f[i] = field_montgomery_mul(f[i], 16382) + } + return RingElement(f) +} diff --git a/vlib/x/crypto/mldsa/params.v b/vlib/x/crypto/mldsa/params.v new file mode 100644 index 000000000..8c805327c --- /dev/null +++ b/vlib/x/crypto/mldsa/params.v @@ -0,0 +1,129 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Ported to V from Go's crypto/internal/fips140/mldsa. +module mldsa + +// s. 4, table 1 +pub enum Kind { + ml_dsa_44 + ml_dsa_65 + ml_dsa_87 +} + +fn (k Kind) params() Params { + return match k { + .ml_dsa_44 { params_44 } + .ml_dsa_65 { params_65 } + .ml_dsa_87 { params_87 } + } +} + +pub fn (k Kind) public_key_size() int { + return pub_key_size(k.params()) +} + +pub fn (k Kind) private_key_size() int { + return priv_key_size(k.params()) +} + +pub fn (k Kind) signature_size() int { + return sig_size(k.params()) +} + +// FIPS 204 s. 5.4: approved pre-hash functions for HashML-DSA. +pub enum PreHash { + none // pure ML-DSA (default) + sha2_224 + sha2_256 + sha2_384 + sha2_512 + sha2_512_224 + sha2_512_256 + sha3_224 + sha3_256 + sha3_384 + sha3_512 + shake_128 + shake_256 +} + +@[params] +pub struct SignerOpts { +pub: + context string + deterministic bool + prehash PreHash +} + +struct Params { + k int + l int + eta int + gamma1 int + gamma2 int + lambda int + tau int + omega int +} + +// s. 4, table 1 +const params_44 = Params{ + k: 4 + l: 4 + eta: 2 + gamma1: 17 + gamma2: 88 + lambda: 128 + tau: 39 + omega: 80 +} + +const params_65 = Params{ + k: 6 + l: 5 + eta: 4 + gamma1: 19 + gamma2: 32 + lambda: 192 + tau: 49 + omega: 55 +} + +const params_87 = Params{ + k: 8 + l: 7 + eta: 2 + gamma1: 19 + gamma2: 32 + lambda: 256 + tau: 60 + omega: 75 +} + +pub const seed_size = 32 + +// s. 4, table 2 +pub const public_key_size_44 = 32 + 4 * n * 10 / 8 +pub const public_key_size_65 = 32 + 6 * n * 10 / 8 +pub const public_key_size_87 = 32 + 8 * n * 10 / 8 + +// s. 4, table 2 +pub const signature_size_44 = 128 / 4 + 4 * n * (17 + 1) / 8 + 80 + 4 +pub const signature_size_65 = 192 / 4 + 5 * n * (19 + 1) / 8 + 55 + 6 +pub const signature_size_87 = 256 / 4 + 7 * n * (19 + 1) / 8 + 75 + 8 + +fn pub_key_size(p Params) int { + return 32 + p.k * n * 10 / 8 +} + +fn priv_key_size(p Params) int { + eta_bitlen := bits_len(u32(p.eta * 2)) + // rho + K + tr + l*n*eta-bit s1 + k*n*eta-bit s2 + k*n*13-bit t0 + return 32 + 32 + 64 + p.l * n * eta_bitlen / 8 + p.k * n * eta_bitlen / 8 + p.k * n * 13 / 8 +} + +fn sig_size(p Params) int { + return (p.lambda / 4) + p.l * n * (p.gamma1 + 1) / 8 + p.omega + p.k +} diff --git a/vlib/x/crypto/mldsa/prehash.v b/vlib/x/crypto/mldsa/prehash.v new file mode 100644 index 000000000..787ba57e6 --- /dev/null +++ b/vlib/x/crypto/mldsa/prehash.v @@ -0,0 +1,65 @@ +// FIPS 204 s. 5.4: Pre-Hash ML-DSA +// signs PH(M) instead of M directly, prefer using pure ml-dsa when possible + +module mldsa + +import crypto.sha256 +import crypto.sha512 +import crypto.sha3 + +// algo. 4/5: DER-encoded OID for each pre-hash function +// all under arc 2.16.840.1.101.3.4.2 +// joint-iso-itu-t(2) country(16) us(840) organization(1) gov(101) csor(3) nistAlgorithm(4) hashAlgs(2) +fn prehash_oid(ph PreHash) []u8 { + suffix := match ph { + .sha2_256 { u8(0x01) } + .sha2_384 { u8(0x02) } + .sha2_512 { u8(0x03) } + .sha2_224 { u8(0x04) } + .sha2_512_224 { u8(0x05) } + .sha2_512_256 { u8(0x06) } + .sha3_224 { u8(0x07) } + .sha3_256 { u8(0x08) } + .sha3_384 { u8(0x09) } + .sha3_512 { u8(0x0a) } + .shake_128 { u8(0x0b) } + .shake_256 { u8(0x0c) } + // unreachable, called from mu_prehash, which is called from sign when ph != .none + .none { panic('mldsa: prehash_oid called with .none') } + } + return [u8(0x06), 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, suffix] +} + +// algo. 4, lines 10-22: PH(M) for the given hash function +fn prehash_message(msg []u8, ph PreHash) []u8 { + return match ph { + .sha2_224 { sha256.sum224(msg) } + .sha2_256 { sha256.sum256(msg) } + .sha2_384 { sha512.sum384(msg) } + .sha2_512 { sha512.sum512(msg) } + .sha2_512_224 { sha512.sum512_224(msg) } + .sha2_512_256 { sha512.sum512_256(msg) } + .sha3_224 { sha3.sum224(msg) } + .sha3_256 { sha3.sum256(msg) } + .sha3_384 { sha3.sum384(msg) } + .sha3_512 { sha3.sum512(msg) } + .shake_128 { sha3.shake128(msg, 32) } + .shake_256 { sha3.shake256(msg, 64) } + // unreachable, called from mu_prehash, which is called from sign when ph != .none + .none { panic('mldsa: prehash_message called with .none') } + } +} + +// algo. 4, line 23: M' = 0x01 || |ctx| || ctx || OID || PH(M) +// algo. 7, line 6: mu = H(tr || M') +// compute_mu_prehash computes mu for prehash mode: H(tr || 0x01 || |ctx| || ctx || OID || PH(msg), 64). +pub fn compute_mu_prehash(tr []u8, msg []u8, context string, ph PreHash) [64]u8 { + mut h := sha3.new_shake256() + h.write(tr) + h.write([u8(0x01)]) // domain sep + h.write([u8(context.len)]) + h.write(context.bytes()) + h.write(prehash_oid(ph)) + h.write(prehash_message(msg, ph)) + return slice_to_64(h.read(64)) +} diff --git a/vlib/x/crypto/mldsa/sampling.v b/vlib/x/crypto/mldsa/sampling.v new file mode 100644 index 000000000..4d345a54a --- /dev/null +++ b/vlib/x/crypto/mldsa/sampling.v @@ -0,0 +1,146 @@ +// Copyright 2025 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// Ported to V from Go's crypto/internal/fips140/mldsa. +module mldsa + +import crypto.sha3 + +// algo. 30: RejNTTPoly (s. 7.3) +@[direct_array_access] +fn sample_ntt(rho []u8, s u8, r u8) NttElement { + mut g := sha3.new_shake128() + g.write(rho) + g.write([s, r]) + + mut a := NttElement{} + mut j := 0 + mut buf := g.read(168) + mut off := 0 + for j < n { + if off + 2 >= buf.len { + buf = g.read(168) + off = 0 + } + v := u32(buf[off]) | (u32(buf[off + 1]) << 8) | (u32(buf[off + 2]) << 16) + off += 3 + candidate := v & 0x7fffff + if candidate < q { + a[j] = field_to_montgomery(candidate) or { continue } + j++ + } + } + return a +} + +// algo. 31: RejBoundedPoly (s. 7.3) +@[direct_array_access] +fn sample_bounded_poly(rho []u8, r u8, p Params) RingElement { + mut h := sha3.new_shake256() + h.write(rho) + h.write([r, 0]) + + mut a := RingElement{} + mut j := 0 + mut buf := h.read(136) + mut off := 0 + for { + if off >= buf.len { + buf = h.read(136) + off = 0 + } + z0 := buf[off] & 0x0f + z1 := buf[off] >> 4 + off++ + + coeff, ok := coeff_from_half_byte(z0, p) + if ok { + a[j] = coeff + j++ + } + if j >= n { + break + } + + coeff2, ok2 := coeff_from_half_byte(z1, p) + if ok2 { + a[j] = coeff2 + j++ + } + if j >= n { + break + } + } + return a +} + +// algo. 29: SampleInBall (s. 7.3) +@[direct_array_access] +fn sample_in_ball(rho []u8, p Params) RingElement { + mut h := sha3.new_shake256() + h.write(rho) + s := h.read(8) + + // pre-read ~2x expected bytes to avoid per-byte allocations + mut buf := h.read(p.tau * 2) + mut off := 0 + + mut c := RingElement{} + for i := 256 - p.tau; i < 256; i++ { + for { + if off >= buf.len { + buf = h.read(256) + off = 0 + } + j := buf[off] + off++ + if j <= u8(i) { + c[i] = c[j] + bit_idx := i + p.tau - 256 + bit := (s[bit_idx / 8] >> (bit_idx % 8)) & 1 + if bit == 0 { + c[j] = mont_one + } else { + c[j] = mont_minus_one + } + break + } + } + } + return c +} + +// algo. 15: CoeffFromHalfByte (s. 7.1) +fn coeff_from_half_byte(b u8, p Params) (FieldElement, bool) { + match p.eta { + 2 { + if b > 14 { + return FieldElement(0), false + } + quotient := (u32(b) * 0x3334) >> 16 // barrett b % 5 + remainder := u32(b) - quotient * 5 + return field_sub_to_montgomery(2, remainder), true + } + 4 { + if b > 8 { + return FieldElement(0), false + } + return field_sub_to_montgomery(4, u32(b)), true + } + else { + panic('mldsa: unsupported eta') // unreachable + } + } +} + +// algo. 32: ExpandA (s. 7.3) +fn compute_matrix_a(rho []u8, p Params) []NttElement { + mut a := []NttElement{len: p.k * p.l} + for r in 0 .. p.k { + for s in 0 .. p.l { + a[r * p.l + s] = sample_ntt(rho, u8(s), u8(r)) + } + } + return a +} diff --git a/vlib/x/crypto/mldsa/testdata/gen.go b/vlib/x/crypto/mldsa/testdata/gen.go new file mode 100644 index 000000000..ddeaab201 --- /dev/null +++ b/vlib/x/crypto/mldsa/testdata/gen.go @@ -0,0 +1,120 @@ +// generates ML-DSA test vectors using Go's crypto/internal/fips140/mldsa +// run from within the Go source tree because mldsa is not public yet +// see https://github.com/golang/go/issues/77626 +// +// this is normally ran via gen.vsh +// +// GOROOT=. ./bin/go run ./src/crypto/internal/fips140/mldsa/gen +package main + +import ( + "crypto/internal/fips140/mldsa" + "crypto/internal/fips140/sha3" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" +) + +type Vector struct { + Kind string `json:"kind"` + Seed string `json:"seed"` + Msg string `json:"msg"` + PkSha256 string `json:"pk_sha256"` + SigSha256 string `json:"sig_sha256"` + Context string `json:"context,omitempty"` +} + +type variant struct { + name string + newPrivateKey func([]byte) (*mldsa.PrivateKey, error) + newPublicKey func([]byte) (*mldsa.PublicKey, error) +} + +func main() { + variants := []variant{ + {"ml_dsa_44", mldsa.NewPrivateKey44, mldsa.NewPublicKey44}, + {"ml_dsa_65", mldsa.NewPrivateKey65, mldsa.NewPublicKey65}, + {"ml_dsa_87", mldsa.NewPrivateKey87, mldsa.NewPublicKey87}, + } + + s := sha3.NewShake128() + seed := make([]byte, 32) + var vectors []Vector + + for _, v := range variants { + for i := 0; i < 3; i++ { + s.Read(seed) + priv, err := v.newPrivateKey(seed) + if err != nil { + panic(err) + } + pk := priv.PublicKey().Bytes() + + msg := make([]byte, 32+i*17) + s.Read(msg) + + sig, err := mldsa.SignDeterministic(priv, msg, "") + if err != nil { + panic(err) + } + + pub, err := v.newPublicKey(pk) + if err != nil { + panic(err) + } + if err := mldsa.Verify(pub, msg, sig, ""); err != nil { + panic(fmt.Sprintf("verify failed: %v", err)) + } + + pkHash := sha256.Sum256(pk) + sigHash := sha256.Sum256(sig) + vectors = append(vectors, Vector{ + Kind: v.name, + Seed: hex.EncodeToString(seed), + Msg: hex.EncodeToString(msg), + PkSha256: hex.EncodeToString(pkHash[:]), + SigSha256: hex.EncodeToString(sigHash[:]), + }) + } + + s.Read(seed) + priv, err := v.newPrivateKey(seed) + if err != nil { + panic(err) + } + pk := priv.PublicKey().Bytes() + msg := make([]byte, 40) + s.Read(msg) + + sig, err := mldsa.SignDeterministic(priv, msg, "test-context") + if err != nil { + panic(err) + } + pub, err := v.newPublicKey(pk) + if err != nil { + panic(err) + } + if err := mldsa.Verify(pub, msg, sig, "test-context"); err != nil { + panic(fmt.Sprintf("verify with context failed: %v", err)) + } + + pkHash := sha256.Sum256(pk) + sigHash := sha256.Sum256(sig) + vectors = append(vectors, Vector{ + Kind: v.name, + Seed: hex.EncodeToString(seed), + Msg: hex.EncodeToString(msg), + PkSha256: hex.EncodeToString(pkHash[:]), + SigSha256: hex.EncodeToString(sigHash[:]), + Context: "test-context", + }) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(vectors); err != nil { + panic(err) + } +} diff --git a/vlib/x/crypto/mldsa/testdata/gen.vsh b/vlib/x/crypto/mldsa/testdata/gen.vsh new file mode 100644 index 000000000..94ba9411a --- /dev/null +++ b/vlib/x/crypto/mldsa/testdata/gen.vsh @@ -0,0 +1,79 @@ +#!/usr/bin/env -S v run + +import os + +// generates ML-DSA test vectors by building Go's reference implementation +// and running gen.go with crypto/internal/fips140/mldsa +// +// Usage: +// v run testdata/gen.vsh +// v run testdata/gen.vsh # uses existing Go tree + +const go_repo = 'https://github.com/golang/go.git' +const go_ref = 'master' +const go_mldsa_subpath = os.join_path('src', 'crypto', 'internal', 'fips140', 'mldsa') +const go_gen_pkg = os.join_path('.', go_mldsa_subpath, 'gen') + +fn main() { + unbuffer_stdout() + script_dir := os.dir(@FILE) + + mut go_root := os.join_path(os.temp_dir(), 'go') + if os.args.len > 1 { + go_root = os.args[1] + if !os.exists(os.join_path(go_root, go_mldsa_subpath)) { + eprintln('error: ${go_root} does not look like a Go source tree') + exit(1) + } + println('> Using existing Go tree at ${go_root}') + } else { + if os.exists(os.join_path(go_root, 'src')) { + println('> Reusing cached Go tree at ${go_root}') + run_or_exit('git -C ${go_root} pull --quiet') + } else { + println('> Cloning Go source tree to ${go_root}...') + run_or_exit('git clone --depth 1 --branch ${go_ref} ${go_repo} ${go_root}') + } + } + + go_bin := os.join_path(go_root, 'bin', 'go') + if !os.exists(go_bin) { + println('> Building Go toolchain...') + run_or_exit('cd ${go_root}/src && bash make.bash') + } + println('> Go binary: ${go_bin}') + + gen_dst_dir := os.join_path(go_root, go_mldsa_subpath, 'gen') + os.mkdir_all(gen_dst_dir) or { + eprintln('error: mkdir failed: ${err}') + exit(1) + } + os.cp(os.join_path(script_dir, 'gen.go'), os.join_path(gen_dst_dir, 'main.go')) or { + eprintln('error: copy failed: ${err}') + exit(1) + } + + println('> Generating test vectors...') + res := run_or_exit('cd ${go_root} && GOROOT=${go_root} ${go_bin} run ${go_gen_pkg}') + + os.rmdir_all(gen_dst_dir) or { eprintln('warning: rmdir failed: ${err}') } + + vectors_path := os.join_path(script_dir, 'vectors.json') + os.write_file(vectors_path, res) or { + eprintln('error: write failed: ${err}') + exit(1) + } + println('> Wrote ${vectors_path}') +} + +fn run_or_exit(cmd string) string { + res := os.execute_opt(cmd) or { + eprintln('error: ${err}') + exit(1) + } + if res.exit_code != 0 { + eprintln('error: ${cmd}\n${res.output}') + exit(1) + } + return res.output +} diff --git a/vlib/x/crypto/mldsa/testdata/vectors.json b/vlib/x/crypto/mldsa/testdata/vectors.json new file mode 100644 index 000000000..a25ec2fa0 --- /dev/null +++ b/vlib/x/crypto/mldsa/testdata/vectors.json @@ -0,0 +1,89 @@ +[ + { + "kind": "ml_dsa_44", + "seed": "7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef26", + "msg": "3cb1eea988004b93103cfb0aeefd2a686e01fa4a58e8a3639ca8a1e3f9ae57e2", + "pk_sha256": "678babd487c6319b5c23a54d179b513629ec1f728c3f5c50a81ef2b564ac25fe", + "sig_sha256": "59fc79b87095bccf459639b831a588fa03ec7e341800459d13b527807e118cf2" + }, + { + "kind": "ml_dsa_44", + "seed": "35b8cc873c23dc62b8d260169afa2f75ab916a58d974918835d25e6a435085b2", + "msg": "badfd6dfaac359a5efbb7bcc4b59d538df9a04302e10c8bc1cbf1a0b3a5120ea17cda7cfad765f5623474d368ccca8af00", + "pk_sha256": "a55cc6aa33a79d1b8349fedb9cad362555bf6440d2df48fbf80002234cb3366b", + "sig_sha256": "29cbfbf3d8f618777f4411c7c41e58b253dd854531682a279eac3fc4fe4787ce" + }, + { + "kind": "ml_dsa_44", + "seed": "07cd9f5e4c849f167a580b14aabdefaee7eef47cb0fca9767be1fda69419dfb9", + "msg": "27e9df07348b196691abaeb580b32def58538b8d23f87732ea63b02b4fa0f4873360e2841928cd60dd4cee8cc0d4c922a96188d032675c8ac850933c7aff1533b94c", + "pk_sha256": "71a532a9bb9e27b13a8429dab5c83fe6459ddb9af7f97176622efe990660bf6c", + "sig_sha256": "3b14b5745c46d59df7296c46fc6dad0a7b4d82a78ebf6875571b78ea4b7ee720" + }, + { + "kind": "ml_dsa_44", + "seed": "834adbb69c6115bad4692d8619f90b0cdf8a7b9c264029ac185b70b83f2801f2", + "msg": "f4b3f70c593ea3aeeb613a7f1b1de33fd75081f592305f2e4526edc09631b10958f464d889f31ba0", + "pk_sha256": "7142dd3dedabd42fe95cd8163c20b1fa0726873c5124c351e1a0184266bbb0c0", + "sig_sha256": "dc746996fabd1798c059e50909c19116b8ad9a176d68559b215698fd8391ce87", + "context": "test-context" + }, + { + "kind": "ml_dsa_65", + "seed": "10250fda7f1368ec2967fc84ef2ae9aff268e0b1700affc6820b523a3d917135", + "msg": "f2dff2ee06bfe72b3124721d4a26c04e53a75e30e73a7a9c4a95d91c55d495e9", + "pk_sha256": "f5796043408f48fa583f50c8b0dc213db904ec2aa0ed260ade17e209483fbcfe", + "sig_sha256": "8361bb70f9ff33f5a724deae3bda69b0e196b069559aba43699529e81f54213f" + }, + { + "kind": "ml_dsa_65", + "seed": "f51dd0b5e9d83c6d5e8ce803aa62b8d654db53d09b8dcff273cdfeb573fad8bc", + "msg": "d45578bec2e770d01efde86e721a3f7c6cce275dabe6e2143f1af18da7efddc4c7b70b5e345db93cc936bea323491ccb38", + "pk_sha256": "fa7338dd88bf2e4a2cc2ea54329bc985d6513210f8e8d6642ee5b89b919def61", + "sig_sha256": "0094df47dabf8e010c475e2ae15a8e16e6c2f4ae5806f3fb8abcad274fafe766" + }, + { + "kind": "ml_dsa_65", + "seed": "a388f546a9ff00dd4e1300b9b2153d2041d205b443e41b45a653f2a5c4492c1a", + "msg": "dd544512dda2529833462b71a41a45be97290b6f4cffda2cf990051634a4b1edf6114fb49083c1fa3b302ee097f051266be69dc716fdeef91b0d4ab2de525550bf80", + "pk_sha256": "87c5c183b07579064590a5635d2270064ecc7a15f237bd8bc4c3d33fc26fbf83", + "sig_sha256": "9c485e41e4e6f4d88f91bef234273be74eedfdb00cb295e392b787dcfdbf928a" + }, + { + "kind": "ml_dsa_65", + "seed": "dc8a684bc3b5a4d46b7efae7afdc6292988dc9acae03f8634486c1abe2781aae", + "msg": "4c02f3460d2cd4e6a463a2ba9562ee623cf0e9f82ab4d0b5c9d040a269366479dff0038abfaf2e0f", + "pk_sha256": "ed9ad798178aca566a43a13f62a543d2813ce0a25cf64f0c9b434621c4196d1f", + "sig_sha256": "4b13f48b7cc3d2285818cd0a245f615b63980661fcf2d03bc2fb82232eab8c2a", + "context": "test-context" + }, + { + "kind": "ml_dsa_87", + "seed": "f21f36968972e3f104ddcbe1eb831a87c213162e29b34adfa564d121e9f6e772", + "msg": "9f4203fc5c6c22fa7a7350afddb620923a4a129b8acb19ea10f818c30e3b5b1c", + "pk_sha256": "d775983143d00204131c7cee69560fab5fc57980c72f3fd44e2daeac5ebfc935", + "sig_sha256": "5486471f23ff0426764108476ed6d05f55b778a0e268928af2814f59c614300e" + }, + { + "kind": "ml_dsa_87", + "seed": "571fa79e57ee304388316a02fcd93a0d8ee02bb85701ee4ff097534b502c1b12", + "msg": "fbb95c8ccb2f548921d99cc7c9fe17ac991b675e631144423eef7a5869168da63d1f4c21f650c02923bfd396ca6a5db541", + "pk_sha256": "43e98d4a1f8add2e68d962728e65cd7d768cb40a24e9628eaa4f8ee8e71cabc2", + "sig_sha256": "9e85f12b881853cd769c260ed3d9653b661f7556fb0930ea41bf7f52b968863f" + }, + { + "kind": "ml_dsa_87", + "seed": "068624cbc5ffe208c0d1a74e1a29618d0bb60036f5249abfa88898e393718d6e", + "msg": "fab05bb41279efcd4c5a0cc837ccfc22be4f725c081f6aa090749dba7077bae8d41af3fec5a6ee1b8adcd25e72de36434584ef567c643d344294e8b2086b87f69c3b", + "pk_sha256": "7de08e43f59c2ac1e0512a9ad123e94170f66ca2eca488af9fb8edf594afbbca", + "sig_sha256": "f18af676ecdda593f085d956723534948d7564ea52b65e8f5264566d5f1d6181" + }, + { + "kind": "ml_dsa_87", + "seed": "dc0d5969857082987ca1c63b7182e86898fb9b8039e75eda219e289331610369", + "msg": "271867b145b2908293963cd677c9a1ae6ceb28289b254cdeb76b12f33ce5cf3743131bfb550f0197", + "pk_sha256": "7f38baac96bb9b7608ec4d3f60c9e8f0233f178bb46d2dca3c61a0b44ee36031", + "sig_sha256": "3bec7820e2c95073ac6456de42f5b18c6aade840e0b18a2d79fe9027f70707fa", + "context": "test-context" + } +] -- 2.39.5