Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9dfc87c
net.quic: HTTP/3 foundation — Phase 0 (build risk) + Phase 1 (primiti…
quaesitor-scientiam Jul 7, 2026
f204faf
net.quic: Phase 2a — QUIC Initial secrets + HKDF-Expand-Label (RFC 90…
quaesitor-scientiam Jul 19, 2026
786ee01
net.quic: Phase 2b — TLS 1.3 key schedule chain (RFC 8446 §7.1)
quaesitor-scientiam Jul 19, 2026
98badc8
net.quic: update PROGRESS.md for Phase 2b (fold-in, dropped from prio…
quaesitor-scientiam Jul 19, 2026
70a85aa
Merge upstream/master into http3-quic-foundation to stay current
quaesitor-scientiam Jul 19, 2026
1952916
net.quic: Phase 2c (part 1) — handshake message framing + Finished MAC
quaesitor-scientiam Jul 19, 2026
aaaf681
net.quic: Phase 2c (part 2) — quic_transport_parameters extension pay…
quaesitor-scientiam Jul 19, 2026
8084407
net.quic: Phase 2c (part 3) — ClientHello construction
quaesitor-scientiam Jul 19, 2026
75c1614
net.quic: Phase 2c (part 4) — ServerHello + EncryptedExtensions parsing
quaesitor-scientiam Jul 20, 2026
41fcaa9
net.quic: Phase 2c (part 5) — Certificate/CertificateVerify message p…
quaesitor-scientiam Jul 20, 2026
60d5c3e
net.quic + net.mbedtls: Phase 2c (part 6) — X.509 chain validation
quaesitor-scientiam Jul 20, 2026
91a5e08
net.quic + net.mbedtls: Phase 2c — CertificateVerify signature verifi…
quaesitor-scientiam Jul 20, 2026
bb1742f
net.quic: Phase 2c — client state machine (tls13_handshake.v)
quaesitor-scientiam Jul 20, 2026
45e2d07
net.quic: Phase 2c — real reference-implementation TLS 1.3 test vectors
quaesitor-scientiam Jul 20, 2026
5691400
net.quic: Phase 3 — packet protection and header protection
quaesitor-scientiam Jul 20, 2026
9040794
net.quic: Phase 4 — Initial packet exchange
quaesitor-scientiam Jul 20, 2026
c6f88ec
net.quic: add missing doc comments for two public functions
quaesitor-scientiam Jul 21, 2026
58a339f
Merge branch 'http3-quic-foundation' into http3-quic-packet-protection
quaesitor-scientiam Jul 21, 2026
4ba9c9f
Merge branch 'http3-quic-packet-protection' into http3-quic-initial-e…
quaesitor-scientiam Jul 21, 2026
3524f88
ci: install OpenSSL for msvc-windows and tcc-windows
quaesitor-scientiam Jul 21, 2026
41543d5
Merge branch 'http3-quic-foundation' into http3-quic-packet-protection
quaesitor-scientiam Jul 21, 2026
5c00103
Merge branch 'http3-quic-packet-protection' into http3-quic-initial-e…
quaesitor-scientiam Jul 21, 2026
ef2f414
ci: fix OpenSSL install path in msvc/tcc Windows workflows
quaesitor-scientiam Jul 21, 2026
bb220fd
Merge branch 'http3-quic-foundation' into http3-quic-packet-protection
quaesitor-scientiam Jul 21, 2026
3ec134d
Merge branch 'http3-quic-packet-protection' into http3-quic-initial-e…
quaesitor-scientiam Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/windows_ci_msvc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ jobs:
VTEST_SHOW_LONGEST_BY_TOTALTIME: 3
steps:
- uses: actions/checkout@v7
- name: Install OpenSSL
run: |
choco install openssl -y --no-progress
# choco's openssl package (Win64OpenSSL installer under the hood)
# deploys to `C:\Program Files\OpenSSL\`, matching
# ecdsa.c.v/rsa_pss.c.v's SECOND `#flag windows` fallback path --
# NOT the first one (`...OpenSSL-Win64\...`), confirmed by an
# actual CI run after guessing wrong the first time.
$opensslBin = 'C:\Program Files\OpenSSL\bin'
if (-not (Test-Path $opensslBin)) {
throw "OpenSSL bin directory not found at $opensslBin after choco install"
}
Add-Content -Path $env:GITHUB_PATH -Value $opensslBin
Write-Host "OpenSSL install:"
Get-ChildItem 'C:\Program Files\OpenSSL\lib\VC\x64\MD'
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/windows_ci_tcc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ jobs:
VTEST_SHOW_LONGEST_BY_TOTALTIME: 3
steps:
- uses: actions/checkout@v7
- name: Install OpenSSL
run: |
choco install openssl -y --no-progress
# choco's openssl package (Win64OpenSSL installer under the hood)
# deploys to `C:\Program Files\OpenSSL\`, matching
# ecdsa.c.v/rsa_pss.c.v's SECOND `#flag windows` fallback path --
# NOT the first one (`...OpenSSL-Win64\...`), confirmed by an
# actual CI run after guessing wrong the first time.
$opensslBin = 'C:\Program Files\OpenSSL\bin'
if (-not (Test-Path $opensslBin)) {
throw "OpenSSL bin directory not found at $opensslBin after choco install"
}
Add-Content -Path $env:GITHUB_PATH -Value $opensslBin
Write-Host "OpenSSL install:"
Get-ChildItem 'C:\Program Files\OpenSSL\lib\VC\x64\MD'
- name: Build with makev.bat -tcc
run: |
.\makev.bat -tcc
Expand Down
10 changes: 10 additions & 0 deletions vlib/crypto/ecdsa/ecdsa.c.v
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ pub const C.NID_secp384r1 int
pub const C.NID_secp521r1 int
pub const C.NID_secp256k1 int
pub const C.NID_X9_62_id_ecPublicKey int // The new opaque of public key pair high level API
// EVP_PKEY_fromdata() selection flag for public-key-only material (no private
// component) — used to reconstruct a peer's ephemeral P-256 public key from
// the raw uncompressed point bytes carried in a TLS 1.3 key_share extension.

pub const C.EVP_PKEY_PUBLIC_KEY int

@[typedef]
struct C.EVP_PKEY {}
Expand Down Expand Up @@ -105,6 +110,11 @@ fn C.EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx &C.EVP_PKEY_CTX, nid i32) i32
fn C.EVP_PKEY_CTX_set_ec_param_enc(ctx &C.EVP_PKEY_CTX, param_enc i32) i32
fn C.EVP_PKEY_CTX_free(ctx &C.EVP_PKEY_CTX)

// ECDH key agreement (used to derive TLS 1.3's P-256 ECDHE shared secret).
fn C.EVP_PKEY_derive_init(ctx &C.EVP_PKEY_CTX) i32
fn C.EVP_PKEY_derive_set_peer(ctx &C.EVP_PKEY_CTX, peer &C.EVP_PKEY) i32
fn C.EVP_PKEY_derive(ctx &C.EVP_PKEY_CTX, key &u8, keylen &usize) i32

fn C.EVP_PKEY_get_bits(pkey &C.EVP_PKEY) i32

// BIO input output declarations.
Expand Down
109 changes: 109 additions & 0 deletions vlib/crypto/ecdsa/ecdsa.v
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const openssl_ec_named_curve = C.OPENSSL_EC_NAMED_CURVE

// https://docs.openssl.org/3.0/man3/EVP_PKEY_fromdata/#selections
const evp_pkey_keypair = C.EVP_PKEY_KEYPAIR
const evp_pkey_public_key = C.EVP_PKEY_PUBLIC_KEY

// POINT_CONVERSION FORAMT
const point_conversion_uncompressed = 4
Expand Down Expand Up @@ -274,6 +275,48 @@ pub fn (pv PrivateKey) public_key() !PublicKey {
}
}

// derive_shared_secret performs ECDH key agreement between this private key
// and a peer's public key, returning the raw shared secret (the X-coordinate
// of the resulting curve point only, per SEC1 — this is what OpenSSL's
// EVP_PKEY_derive already returns for EC keys, and is exactly what TLS 1.3's
// key schedule expects as ECDHE input; it is NOT the full uncompressed point).
// Both keys must use the same curve.
pub fn (pv PrivateKey) derive_shared_secret(peer PublicKey) ![]u8 {
ctx := C.EVP_PKEY_CTX_new(pv.evpkey, 0)
if ctx == 0 {
C.EVP_PKEY_CTX_free(ctx)
return error('EVP_PKEY_CTX_new failed')
}
dinit := C.EVP_PKEY_derive_init(ctx)
if dinit <= 0 {
C.EVP_PKEY_CTX_free(ctx)
return error('EVP_PKEY_derive_init failed')
}
dpeer := C.EVP_PKEY_derive_set_peer(ctx, peer.evpkey)
if dpeer <= 0 {
C.EVP_PKEY_CTX_free(ctx)
return error('EVP_PKEY_derive_set_peer failed (mismatched curve or invalid peer key?)')
}
// First call with a nil buffer to learn the required secret length.
mut secret_len := usize(0)
dlen := C.EVP_PKEY_derive(ctx, unsafe { nil }, &secret_len)
if dlen <= 0 || secret_len == 0 {
C.EVP_PKEY_CTX_free(ctx)
return error('EVP_PKEY_derive (length query) failed')
}
mut secret := []u8{len: int(secret_len)}
dres := C.EVP_PKEY_derive(ctx, secret.data, &secret_len)
if dres <= 0 {
unsafe { secret.free() }
C.EVP_PKEY_CTX_free(ctx)
return error('EVP_PKEY_derive failed')
}
result := secret[..int(secret_len)].clone()
unsafe { secret.free() }
C.EVP_PKEY_CTX_free(ctx)
return result
}

// equal compares two private keys was equal.
pub fn (priv_key PrivateKey) equal(other PrivateKey) bool {
eq := C.EVP_PKEY_eq(voidptr(priv_key.evpkey), voidptr(other.evpkey))
Expand Down Expand Up @@ -310,6 +353,72 @@ pub fn (pb &PublicKey) free() {
C.EVP_PKEY_free(pb.evpkey)
}

// uncompressed_bytes returns the public key as an uncompressed EC point,
// `0x04 || X || Y` (SEC1 §2.3.3) — the wire format TLS 1.3's key_share
// extension uses for the secp256r1 (P-256) group (RFC 8446 §4.2.8.2).
pub fn (pb PublicKey) uncompressed_bytes() ![]u8 {
mut ptr := &u8(unsafe { nil })
n := C.EVP_PKEY_get1_encoded_public_key(pb.evpkey, &ptr)
if n == 0 {
return error('EVP_PKEY_get1_encoded_public_key failed')
}
result := unsafe { ptr.vbytes(int(n)).clone() }
C.OPENSSL_free(voidptr(ptr))
return result
}

// PublicKey.from_uncompressed_bytes reconstructs a public key (no private
// component) from an uncompressed EC point `0x04 || X || Y`, as received on
// the wire in a TLS 1.3 key_share extension. `opt.nid` must match the curve
// the peer actually used (v1 callers of net.quic only ever use `.prime256v1`
// here, since X25519 is handled entirely by crypto.x25519 instead).
pub fn PublicKey.from_uncompressed_bytes(bytes []u8, opt CurveOptions) !PublicKey {
if bytes.len == 0 {
return error('empty public key bytes')
}
if bytes[0] != 0x04 {
return error('only uncompressed EC points (0x04 prefix) are supported, got tag ${bytes[0]:02x}')
}
param_bld := C.OSSL_PARAM_BLD_new()
if param_bld == 0 {
return error('OSSL_PARAM_BLD_new failed')
}
n := C.OSSL_PARAM_BLD_push_utf8_string(param_bld, c'group', voidptr(opt.nid.str().str), 0)
o := C.OSSL_PARAM_BLD_push_octet_string(param_bld, c'pub', bytes.data, usize(bytes.len))
if n <= 0 || o <= 0 {
C.OSSL_PARAM_BLD_free(param_bld)
return error('OSSL_PARAM_BLD_push failed')
}
params := C.OSSL_PARAM_BLD_to_param(param_bld)
pctx := C.EVP_PKEY_CTX_new_id(nid_evp_pkey_ec, 0)
if params == 0 || pctx == 0 {
C.OSSL_PARAM_BLD_free(param_bld)
C.OSSL_PARAM_free(params)
if pctx != 0 {
C.EVP_PKEY_CTX_free(pctx)
}
return error('EVP_PKEY_CTX_new_id or OSSL_PARAM_BLD_to_param failed')
}
// Start from a nil pointer, not C.EVP_PKEY_new(): EVP_PKEY_fromdata()
// allocates its own new EVP_PKEY internally and overwrites *ppkey with
// it, so pre-allocating here would only leak that first object once
// fromdata replaces the pointer.
mut pkey := &C.EVP_PKEY(unsafe { nil })
p := C.EVP_PKEY_fromdata_init(pctx)
q := C.EVP_PKEY_fromdata(pctx, &pkey, evp_pkey_public_key, params)
// Cleanup independent of outcome.
C.OSSL_PARAM_BLD_free(param_bld)
C.OSSL_PARAM_free(params)
C.EVP_PKEY_CTX_free(pctx)
if p <= 0 || q <= 0 {
C.EVP_PKEY_free(pkey)
return error('EVP_PKEY_fromdata failed to build public key from raw bytes')
}
return PublicKey{
evpkey: pkey
}
}

// Helpers
//
// calc_digest_with_evpkey get the digest of the messages under the EVP_PKEY and options
Expand Down
89 changes: 89 additions & 0 deletions vlib/crypto/ecdsa/ecdsa_p256_ecdh_test.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
module ecdsa

// These tests cover the P-256 ECDH addition made for net.quic's TLS 1.3
// key_share support (RFC 8446 §4.2.8.2, secp256r1 group) — see
// https://github.com/vlang/v/issues/27675. ECDH (shared-secret derivation) is
// a distinct operation from the ECDSA sign/verify already covered elsewhere
// in this package: same curve math, different EVP_PKEY_CTX operation.
//
// A NIST CAVP known-answer test (independent oracle, not just "both sides
// agree with each other") is intentionally NOT included here yet — per this
// project's fixture-provenance discipline (see vlib/net/quic/README.md), no
// unverified expected value should be checked in. It's tracked as part of
// Phase 2's from-scratch TLS 1.3 test vector authoring
// (vlib/net/quic/testdata/tls13_vectors/), which will need a verified P-256
// ECDH vector anyway for the key-share portion of the handshake fixtures.

fn test_p256_ecdh_two_sided_shared_secret_matches() {
pub_a, priv_a := generate_key(nid: .prime256v1)!
pub_b, priv_b := generate_key(nid: .prime256v1)!
defer {
priv_a.free()
priv_b.free()
pub_a.free()
pub_b.free()
}

secret_ab := priv_a.derive_shared_secret(pub_b)!
secret_ba := priv_b.derive_shared_secret(pub_a)!

assert secret_ab.len > 0
assert secret_ab == secret_ba
// P-256's shared secret (the X-coordinate) is 32 bytes.
assert secret_ab.len == 32
}

fn test_p256_public_key_uncompressed_roundtrip() {
pub_a, priv_a := generate_key(nid: .prime256v1)!
defer {
priv_a.free()
pub_a.free()
}

wire_bytes := pub_a.uncompressed_bytes()!
// 0x04 prefix + 32-byte X + 32-byte Y for P-256.
assert wire_bytes.len == 65
assert wire_bytes[0] == 0x04

mut reconstructed := PublicKey.from_uncompressed_bytes(wire_bytes, nid: .prime256v1)!
defer {
reconstructed.free()
}
assert pub_a.equal(reconstructed)
}

fn test_p256_ecdh_using_reconstructed_peer_public_key() {
// Simulates the real net.quic usage: the peer's public key arrives on the
// wire as raw bytes (from a TLS 1.3 key_share extension) and must be
// reconstructed before deriving the shared secret.
pub_a, priv_a := generate_key(nid: .prime256v1)!
pub_b, priv_b := generate_key(nid: .prime256v1)!
defer {
priv_a.free()
priv_b.free()
pub_a.free()
pub_b.free()
}

wire_b := pub_b.uncompressed_bytes()!
mut reconstructed_b := PublicKey.from_uncompressed_bytes(wire_b, nid: .prime256v1)!
defer {
reconstructed_b.free()
}

secret_direct := priv_a.derive_shared_secret(pub_b)!
secret_via_wire := priv_a.derive_shared_secret(reconstructed_b)!
assert secret_direct == secret_via_wire
}

fn test_p256_from_uncompressed_bytes_rejects_compressed_point() {
// Compressed points use a 0x02/0x03 prefix; only uncompressed (0x04) is
// supported, matching what net.quic actually needs to parse from the wire.
mut bad := []u8{len: 33}
bad[0] = 0x02
PublicKey.from_uncompressed_bytes(bad, nid: .prime256v1) or {
assert err.msg().contains('uncompressed')
return
}
assert false, 'expected an error for a compressed point'
}
85 changes: 85 additions & 0 deletions vlib/crypto/rsa_pss/rsa_pss.c.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2019-2026 the V contributors. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module rsa_pss

// RSA-PSS sign/verify, added for net.quic's TLS 1.3 CertificateVerify support
// (RFC 8446 §4.2.3's `rsa_pss_rsae_sha256/384/512` mandatory-to-implement
// signature schemes) — see https://github.com/vlang/v/issues/27675. No RSA of
// any kind previously existed anywhere in V's stdlib.
//
// Follows the same OpenSSL EVP high-level API binding pattern already used by
// vlib/crypto/ecdsa, reusing the same `-lcrypto` linkage and per-platform
// `#flag` search paths.

#flag darwin -L/opt/homebrew/opt/openssl/lib
#flag darwin -I/opt/homebrew/opt/openssl/include
#flag darwin -I/usr/local/opt/openssl/include
#flag darwin -L/usr/local/opt/openssl/lib

#flag linux -I/usr/local/include/openssl
#flag linux -L/usr/local/lib64/

#flag openbsd -I/usr/local/include/eopenssl35
#flag openbsd -L/usr/local/lib/eopenssl35 -Wl,-rpath,/usr/local/lib/eopenssl35

// Installed through choco:
#flag windows -IC:/Program Files/OpenSSL-Win64/include
#flag windows -LC:/Program Files/OpenSSL-Win64/lib/VC/x64/MD

// Installed on the CI:
#flag windows -IC:/Program Files/OpenSSL/include
#flag windows -LC:/Program Files/OpenSSL/lib/VC/x64/MD

#flag -I/usr/include/openssl

#flag -lcrypto

#include <openssl/evp.h>
#include <openssl/rsa.h>

pub const C.EVP_PKEY_RSA int
pub const C.RSA_PKCS1_PSS_PADDING int // RSA_PSS_SALTLEN_DIGEST (-1): salt length equals the digest length — the
// deterministic choice RFC 8446 §4.2.3 requires for the `rsae` schemes
// (as opposed to OpenSSL's own default, "maximum possible salt length").

pub const C.RSA_PSS_SALTLEN_DIGEST int

@[typedef]
struct C.EVP_PKEY {}

fn C.EVP_PKEY_new() &C.EVP_PKEY
fn C.EVP_PKEY_free(key &C.EVP_PKEY)

@[typedef]
struct C.EVP_PKEY_CTX {}

fn C.EVP_PKEY_CTX_new_id(id i32, e voidptr) &C.EVP_PKEY_CTX
fn C.EVP_PKEY_CTX_new(pkey &C.EVP_PKEY, e voidptr) &C.EVP_PKEY_CTX
fn C.EVP_PKEY_CTX_free(ctx &C.EVP_PKEY_CTX)
fn C.EVP_PKEY_keygen_init(ctx &C.EVP_PKEY_CTX) i32
fn C.EVP_PKEY_keygen(ctx &C.EVP_PKEY_CTX, ppkey &&C.EVP_PKEY) i32
fn C.EVP_PKEY_CTX_set_rsa_keygen_bits(ctx &C.EVP_PKEY_CTX, bits i32) i32
fn C.EVP_PKEY_CTX_set_rsa_padding(ctx &C.EVP_PKEY_CTX, pad i32) i32
fn C.EVP_PKEY_CTX_set_rsa_pss_saltlen(ctx &C.EVP_PKEY_CTX, saltlen i32) i32

@[typedef]
struct C.EVP_MD_CTX {}

fn C.EVP_MD_CTX_new() &C.EVP_MD_CTX
fn C.EVP_MD_CTX_free(ctx &C.EVP_MD_CTX)

@[typedef]
struct C.EVP_MD {}

fn C.EVP_sha256() &C.EVP_MD
fn C.EVP_sha384() &C.EVP_MD
fn C.EVP_sha512() &C.EVP_MD

fn C.EVP_DigestSignInit(ctx &C.EVP_MD_CTX, pctx &&C.EVP_PKEY_CTX, tipe &C.EVP_MD, e voidptr, pkey &C.EVP_PKEY) i32
fn C.EVP_DigestSignUpdate(ctx &C.EVP_MD_CTX, d voidptr, cnt i32) i32
fn C.EVP_DigestSignFinal(ctx &C.EVP_MD_CTX, sig &u8, siglen &usize) i32

fn C.EVP_DigestVerifyInit(ctx &C.EVP_MD_CTX, pctx &&C.EVP_PKEY_CTX, tipe &C.EVP_MD, e voidptr, pkey &C.EVP_PKEY) i32
fn C.EVP_DigestVerifyUpdate(ctx &C.EVP_MD_CTX, d voidptr, cnt i32) i32
fn C.EVP_DigestVerifyFinal(ctx &C.EVP_MD_CTX, sig &u8, siglen i32) i32
Loading
Loading