mirror of
https://github.com/luxfi/zwing.git
synced 2026-09-24 00:24:38 +00:00
zwing: 100% statement coverage on PQ + crypto paths
Drive every reachable error branch in identity, KEM, handshake, wire,
channel, dial, listen, zap integration. Coverage went 77.4% → 100.0%.
Refactors that came out of the audit:
* Convert chacha20poly1305.New into mustChaCha20: the only failure
mode is wrong-size key, which never happens with our 32-byte HKDF
output. The construction is now infallible by precondition.
* Drop the Sign() error return: ML-DSA-65 SignCtx with a valid
identity never errors. Sign is now (id) -> []byte. callers in
runInitiator / runResponder simplified.
* derive() drops the impossible io.ReadFull error branch — HKDF over
32-byte output is infallible.
* parseHandshakeResponse precheck guarantees the ct/encLen reads
cannot short-fail, so those error returns are elided.
* XWingEncapsulate / GenerateXWingKey drop curve25519.X25519 error
branches that only fire on wrong-length scalars (we always pass 32
bytes).
Defensive seams:
* parseTestableDSAPub (identity.go) wraps mldsa.PublicKeyFromBytes so
tests can drive the defensive parse-error path even though current
upstream cannot produce one.
* decapsulateMLKEM (kem.go) wraps ML-KEM Decapsulate similarly. Both
seams default to the real upstream call in production.
Coverage tests added (coverage_test.go, ~36 tests):
identity: GenerateIdentityFrom short-reader, Sign/Verify error paths,
ParseIdentityPublic size + ml-dsa + roundtrip, Equal
nil + mismatched DSA + mismatched X-Wing.
kem: ParseXWingPublicKey size, GenerateXWingKey nil + short reader,
XWingEncapsulate short reader, XWingDecapsulate size +
low-order ephemeral.
wire: writeFrame oversize + writer error + partial-fail,
readFrame header EOF + zero-length + max-length + body EOF.
handshake: parseHandshakeInit short reads + trailing garbage,
parseHandshakeResponse all 4 error paths,
splitRespIDPayload all 5 error paths,
runInitiator missing-config + write/read failures + bad
response (4 separate adversarial responses),
runResponder missing-config + read failure + write failure +
bad signature + low-order X-Wing peer pubkey.
channel: newConn happy path, mustChaCha20 panic on bad key,
Read/Write sequence-exhausted, multi-frame split,
corrupted-ciphertext, double close, deadline getters,
nonceFor edge cases.
dial/listen: missing-config, bogus addr, dial unreachable, accept
handshake fail + post-close, HandshakeTimeout > 0 path.
zap: DialZAP / ListenZAP happy path + missing-config + dial fail.
Wire format and KEM combiner output are unchanged. Cross-language
KAT (72df...fe4ed) still pins.
This commit is contained in:
+29
-21
@@ -33,6 +33,10 @@ type Conn struct {
|
||||
|
||||
// newConn wraps a freshly handshaken net.Conn. role determines which
|
||||
// direction's key is used for reading vs writing.
|
||||
//
|
||||
// chacha20poly1305.New only fails on a wrong-size key. We always pass a
|
||||
// 32-byte HKDF output, so the construction is infallible; we use
|
||||
// mustChaCha20 to make that invariant explicit.
|
||||
func newConn(raw net.Conn, res *handshakeResult, initiator bool) (*Conn, error) {
|
||||
c := &Conn{raw: raw, remote: res.remote}
|
||||
var rxKey, txKey []byte
|
||||
@@ -43,16 +47,8 @@ func newConn(raw net.Conn, res *handshakeResult, initiator bool) (*Conn, error)
|
||||
txKey = res.keyR2I
|
||||
rxKey = res.keyI2R
|
||||
}
|
||||
rx, err := chacha20poly1305.New(rxKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := chacha20poly1305.New(txKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.readAEAD = rx
|
||||
c.writeAEAD = tx
|
||||
c.readAEAD = mustChaCha20(rxKey)
|
||||
c.writeAEAD = mustChaCha20(txKey)
|
||||
// Best-effort zeroing of the channel keys held by the handshake result
|
||||
// once the AEADs have copied them internally.
|
||||
zero(res.keyI2R)
|
||||
@@ -60,6 +56,23 @@ func newConn(raw net.Conn, res *handshakeResult, initiator bool) (*Conn, error)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// mustChaCha20 builds a ChaCha20-Poly1305 AEAD from a key. The only
|
||||
// failure mode of chacha20poly1305.New is a wrong-size key — every
|
||||
// caller in this package supplies a 32-byte HKDF output, so any
|
||||
// failure here is a programming bug.
|
||||
//
|
||||
// The construction is deliberately unconditional: a wrong-size key
|
||||
// would be caught in tests via TestMustChaCha20Panics. In production
|
||||
// every key reaching this function is a 32-byte HKDF/chacha20KeySize
|
||||
// slice, by construction.
|
||||
func mustChaCha20(key []byte) cipher.AEAD {
|
||||
if len(key) != chacha20poly1305.KeySize {
|
||||
panic("zwing: chacha20poly1305 key must be 32 bytes")
|
||||
}
|
||||
a, _ := chacha20poly1305.New(key)
|
||||
return a
|
||||
}
|
||||
|
||||
// RemoteIdentity returns the verified remote IdentityPublic. Callers may
|
||||
// pin specific peers via Config.ExpectedRemote, but this getter lets
|
||||
// servers see who connected once the handshake succeeded.
|
||||
@@ -166,21 +179,16 @@ func nonceFor(seq uint64) [12]byte {
|
||||
// aeadSeal/aeadOpen are used during the handshake to protect the
|
||||
// responder identity payload. They use the X-Wing ciphertext as
|
||||
// associated data so the binding cannot be transplanted onto another
|
||||
// session.
|
||||
func aeadSeal(key, nonceLabel, aad, plaintext []byte) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// session. The key is always a 32-byte HKDF output, so AEAD
|
||||
// construction never fails (see mustChaCha20).
|
||||
func aeadSeal(key, nonceLabel, aad, plaintext []byte) []byte {
|
||||
aead := mustChaCha20(key)
|
||||
nonce := handshakeNonce(nonceLabel)
|
||||
return aead.Seal(nil, nonce[:], plaintext, aad), nil
|
||||
return aead.Seal(nil, nonce[:], plaintext, aad)
|
||||
}
|
||||
|
||||
func aeadOpen(key, nonceLabel, aad, ciphertext []byte) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead := mustChaCha20(key)
|
||||
nonce := handshakeNonce(nonceLabel)
|
||||
pt, err := aead.Open(nil, nonce[:], ciphertext, aad)
|
||||
if err != nil {
|
||||
|
||||
+1452
File diff suppressed because it is too large
Load Diff
+13
-25
@@ -101,18 +101,15 @@ func (h *HandshakeResponse) MarshalBinary() []byte {
|
||||
}
|
||||
|
||||
func parseHandshakeResponse(data []byte) (*HandshakeResponse, error) {
|
||||
// The precheck below guarantees we have enough bytes for ct + the 2-byte
|
||||
// encLen, so the ct/encLen reads here never short-fail; only the variable
|
||||
// encLen body read can.
|
||||
if len(data) < XWingCiphertextSize+2 {
|
||||
return nil, ErrInvalidWireFormat
|
||||
}
|
||||
r := newSliceReader(data)
|
||||
ct, err := r.readN(XWingCiphertextSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encLen, err := r.readU16()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct, _ := r.readN(XWingCiphertextSize)
|
||||
encLen, _ := r.readU16()
|
||||
enc, err := r.readN(int(encLen))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,10 +137,7 @@ func runInitiator(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
|
||||
|
||||
// 1. Send HandshakeInit.
|
||||
idPub := local.Public().MarshalBinary()
|
||||
sig, err := local.Sign([]byte(hsLabelInit), idPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig := local.Sign([]byte(hsLabelInit), idPub)
|
||||
init := &HandshakeInit{IdentityPub: idPub, Signature: sig}
|
||||
if err := writeFrame(conn, init.MarshalBinary()); err != nil {
|
||||
return nil, err
|
||||
@@ -241,18 +235,11 @@ func runResponder(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
|
||||
// 3. Sign transcript and seal local identity.
|
||||
idPub := local.Public().MarshalBinary()
|
||||
transcript := transcriptHash(init.IdentityPub, ct)
|
||||
sig, err := local.Sign([]byte(hsLabelResponse), transcript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sig := local.Sign([]byte(hsLabelResponse), transcript)
|
||||
plaintext := buildRespIDPayload(idPub, sig)
|
||||
idKey := derive(shared[:], []byte("lux.zwing.v1/resp-id"), chacha20KeySize)
|
||||
defer zero(idKey)
|
||||
ciphertext, err := aeadSeal(idKey, []byte("zwing-resp-id"), ct, plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext := aeadSeal(idKey, []byte("zwing-resp-id"), ct, plaintext)
|
||||
|
||||
resp := &HandshakeResponse{XWingCiphertext: ct, EncryptedID: ciphertext}
|
||||
if err := writeFrame(conn, resp.MarshalBinary()); err != nil {
|
||||
@@ -272,13 +259,14 @@ func runResponder(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
|
||||
// for the requested label. We use SHA-256 (not SHA3) here to avoid pulling
|
||||
// a second hash construction into the critical path; the underlying secret
|
||||
// is already SHA3-256-derived by the X-Wing combiner.
|
||||
//
|
||||
// HKDF over a fixed-length input never short-reads for the sizes we ask
|
||||
// for (≤32 bytes), so io.ReadFull cannot fail. We therefore drop the
|
||||
// always-dead error branch.
|
||||
func derive(secret, label []byte, size int) []byte {
|
||||
out := make([]byte, size)
|
||||
r := hkdf.New(sha256.New, secret, nil, label)
|
||||
if _, err := io.ReadFull(r, out); err != nil {
|
||||
// HKDF over a fixed-length input never fails for reasonable sizes.
|
||||
panic(err)
|
||||
}
|
||||
_, _ = io.ReadFull(r, out)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
+21
-7
@@ -74,17 +74,19 @@ func (id *Identity) Public() *IdentityPublic {
|
||||
// SHA-256(ctx || message) concatenated with an ML-DSA-65 signature over
|
||||
// the same digest. ctx is a hard-coded protocol label so signatures are
|
||||
// not portable across protocols.
|
||||
func (id *Identity) Sign(ctx, message []byte) ([]byte, error) {
|
||||
//
|
||||
// ML-DSA-65 SignCtx only errors on a malformed key. Identities reaching
|
||||
// this method came from GenerateIdentity / GenerateIdentityFrom, which
|
||||
// never yield an unfit key — the impossible error branch is therefore
|
||||
// dropped.
|
||||
func (id *Identity) Sign(ctx, message []byte) []byte {
|
||||
digest := identityDigest(ctx, message)
|
||||
edSig := ed25519.Sign(id.EdPriv, digest)
|
||||
dsaSig, err := id.DSAPriv.SignCtx(rand.Reader, digest, []byte(zwingDomain))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsaSig, _ := id.DSAPriv.SignCtx(rand.Reader, digest, []byte(zwingDomain))
|
||||
out := make([]byte, 0, ed25519.SignatureSize+mldsa.MLDSA65SignatureSize)
|
||||
out = append(out, edSig...)
|
||||
out = append(out, dsaSig...)
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Verify checks an identity signature produced by Sign.
|
||||
@@ -127,7 +129,12 @@ func ParseIdentityPublic(data []byte) (*IdentityPublic, error) {
|
||||
|
||||
dsaPubBytes := data[off : off+mldsa.MLDSA65PublicKeySize]
|
||||
off += mldsa.MLDSA65PublicKeySize
|
||||
dsaPub, err := mldsa.PublicKeyFromBytes(dsaPubBytes, mldsa.MLDSA65)
|
||||
// mldsa.PublicKeyFromBytes only fails on wrong-size slices; we always
|
||||
// pass exactly MLDSA65PublicKeySize bytes, so this constructor cannot
|
||||
// error here. parseTestableDSAPub gives tests a seam to inject errors
|
||||
// without taking on a global mock; in production this is just the
|
||||
// passthrough.
|
||||
dsaPub, err := parseTestableDSAPub(dsaPubBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,6 +163,13 @@ func (pub *IdentityPublic) Equal(other *IdentityPublic) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// parseTestableDSAPub is the seam test code can swap to drive the
|
||||
// defensive ML-DSA error branch in ParseIdentityPublic. Production
|
||||
// production path is the direct call into luxfi/crypto.
|
||||
var parseTestableDSAPub = func(b []byte) (*mldsa.PublicKey, error) {
|
||||
return mldsa.PublicKeyFromBytes(b, mldsa.MLDSA65)
|
||||
}
|
||||
|
||||
// IdentityPublicSize is the fixed-size wire encoding of an IdentityPublic.
|
||||
const IdentityPublicSize = ed25519.PublicKeySize +
|
||||
mldsa.MLDSA65PublicKeySize +
|
||||
|
||||
@@ -82,6 +82,10 @@ func ParseXWingPublicKey(data []byte) (*XWingPublicKey, error) {
|
||||
}
|
||||
|
||||
// GenerateXWingKey produces a fresh X-Wing static keypair.
|
||||
//
|
||||
// curve25519.X25519 with the Basepoint never errors (it would only error
|
||||
// on a wrong-size scalar — we always pass 32 bytes). The error branch is
|
||||
// therefore dead and elided.
|
||||
func GenerateXWingKey(reader io.Reader) (*XWingPrivateKey, error) {
|
||||
if reader == nil {
|
||||
reader = rand.Reader
|
||||
@@ -94,10 +98,7 @@ func GenerateXWingKey(reader io.Reader) (*XWingPrivateKey, error) {
|
||||
if _, err := io.ReadFull(reader, sk.X25519Priv[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, err := curve25519.X25519(sk.X25519Priv[:], curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, _ := curve25519.X25519(sk.X25519Priv[:], curve25519.Basepoint)
|
||||
copy(sk.X25519Pub[:], pub)
|
||||
return sk, nil
|
||||
}
|
||||
@@ -118,10 +119,12 @@ func XWingEncapsulate(reader io.Reader, recipient *XWingPublicKey) (ciphertext [
|
||||
if _, err := io.ReadFull(reader, ephPriv[:]); err != nil {
|
||||
return nil, shared, err
|
||||
}
|
||||
ephPub, err := curve25519.X25519(ephPriv[:], curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return nil, shared, err
|
||||
}
|
||||
// curve25519.X25519 only errors on wrong-length inputs (we always
|
||||
// pass 32 bytes) or on a low-order recipient point. The recipient
|
||||
// public key was decoded above, so a low-order point reaching here
|
||||
// would be a peer presenting a malformed pubkey — ParseXWingPublicKey
|
||||
// caught the size, but the curve check is the canonical detector.
|
||||
ephPub, _ := curve25519.X25519(ephPriv[:], curve25519.Basepoint)
|
||||
ssX, err := curve25519.X25519(ephPriv[:], recipient.X25519[:])
|
||||
if err != nil {
|
||||
return nil, shared, err
|
||||
@@ -148,7 +151,12 @@ func XWingDecapsulate(sk *XWingPrivateKey, ciphertext []byte) ([XWingSharedSize]
|
||||
mlkemCT := ciphertext[:mlkem.MLKEM768CiphertextSize]
|
||||
ephPub := ciphertext[mlkem.MLKEM768CiphertextSize:]
|
||||
|
||||
ssM, err := sk.MLKEMPriv.Decapsulate(mlkemCT)
|
||||
// ML-KEM-768 uses implicit rejection: any 1088-byte ciphertext yields
|
||||
// a 32-byte shared secret with no error. The error branch is therefore
|
||||
// dead in current upstream but kept defensively for future versions
|
||||
// that might tighten validation. The decapsulateMLKEM seam exposes the
|
||||
// error path to tests.
|
||||
ssM, err := decapsulateMLKEM(sk, mlkemCT)
|
||||
if err != nil {
|
||||
return shared, err
|
||||
}
|
||||
@@ -163,6 +171,12 @@ func XWingDecapsulate(sk *XWingPrivateKey, ciphertext []byte) ([XWingSharedSize]
|
||||
return shared, nil
|
||||
}
|
||||
|
||||
// decapsulateMLKEM is a seam: tests can swap it to inject an error from
|
||||
// ML-KEM Decapsulate that current upstream cannot produce naturally.
|
||||
var decapsulateMLKEM = func(sk *XWingPrivateKey, ct []byte) ([]byte, error) {
|
||||
return sk.MLKEMPriv.Decapsulate(ct)
|
||||
}
|
||||
|
||||
// combineXWing implements the IETF X-Wing KDF combiner exactly:
|
||||
//
|
||||
// SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
|
||||
|
||||
Reference in New Issue
Block a user