mirror of
https://github.com/luxfi/zwing.git
synced 2026-09-24 00:24:38 +00:00
zwing v0.1.0: Z-Wing PQ secure channel for Lux
Z-Wing is the canonical Lux post-quantum secure channel. One construction,
one wire format, no negotiation. Layered design exposes a net.Conn so
luxfi/api/zap (and any other stream protocol) runs unchanged on top.
KEM: X-Wing (X25519 + ML-KEM-768, IETF draft-connolly-cfrg-xwing-kem
SHA3-256 combiner: "\./" || "X-Wing" || ssM || ssX || ctX || pkX)
Identity: Ed25519 + ML-DSA-65 hybrid signatures
AEAD: ChaCha20-Poly1305 with sequence-numbered nonces (per direction)
Transport: TCP (RNS routing is a follow-up package)
Public API: GenerateIdentity / Dial / Listen / Client / Server.
Six tests cover the spec end-to-end: handshake round-trip, TCP dial+listen,
ExpectedRemote pinning, key-zero-after-handshake forward secrecy, sequenced
nonces, and ZAP-over-Z-Wing application round-trip.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2024, Lux Partners Limited
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. 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.
|
||||
|
||||
3. Neither the name of the copyright holder 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 HOLDER 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.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Z-Wing — Lux Post-Quantum Secure Channel
|
||||
|
||||
## Overview
|
||||
|
||||
Z-Wing is the canonical Lux PQ secure channel. One construction, one wire
|
||||
format, no negotiation. If two endpoints don't both speak Z-Wing v1, they
|
||||
don't talk.
|
||||
|
||||
Layering:
|
||||
|
||||
```
|
||||
Application: ZAP RPC (luxfi/api/zap) <- unchanged
|
||||
Channel: Z-Wing <- this package
|
||||
KEM: X-Wing (X25519 + ML-KEM-768, IETF draft-connolly-cfrg-xwing-kem)
|
||||
Identity: Ed25519 + ML-DSA-65 hybrid signatures
|
||||
AEAD: ChaCha20-Poly1305 with sequence-numbered nonces
|
||||
Routing: Plain TCP (this version) or RNS (follow-up)
|
||||
```
|
||||
|
||||
The `Conn` returned by `Dial` / `Listener.Accept()` implements `net.Conn`,
|
||||
so any Lux service that already speaks ZAP gets PQ on the wire by simply
|
||||
swapping its dialer:
|
||||
|
||||
```go
|
||||
raw, _ := zwing.Dial(ctx, "host:9999", &zwing.Config{LocalIdentity: id})
|
||||
zapConn := zap.NewConn(raw, nil) // ZAP unchanged
|
||||
```
|
||||
|
||||
## Default port
|
||||
|
||||
Z-Wing inherits the canonical Lux ZAP port: **TCP 9999**. Override only
|
||||
when running multiple Z-Wing servers on the same host.
|
||||
|
||||
## Wire format
|
||||
|
||||
### Frame
|
||||
|
||||
Every transport frame on a Z-Wing channel:
|
||||
|
||||
```
|
||||
[4 bytes BE: payload length][payload bytes]
|
||||
```
|
||||
|
||||
`MaxFrameSize` = 1 MiB. The `payload` is either a handshake message
|
||||
(during the handshake) or an AEAD-sealed application record (after).
|
||||
|
||||
### Handshake (1-RTT)
|
||||
|
||||
```
|
||||
Initiator -> Responder : HandshakeInit
|
||||
[2 BE idLen][IdentityPublic bytes][2 BE sigLen][hybrid signature]
|
||||
|
||||
Responder -> Initiator : HandshakeResponse
|
||||
[XWingCiphertextSize bytes: ML-KEM ct || X25519 ephemeral pk]
|
||||
[2 BE encLen][AEAD-sealed responder identity + signature]
|
||||
```
|
||||
|
||||
* The initiator signs `(label || IdentityPublic)` so a passive observer
|
||||
cannot lift the public identity onto another protocol.
|
||||
* The responder signs the **transcript hash** that covers the initiator's
|
||||
public identity AND the X-Wing ciphertext, binding the signature to
|
||||
the session.
|
||||
* The responder identity is encrypted under an HKDF-derived key from the
|
||||
X-Wing shared secret, so passive observers do not learn the responder's
|
||||
long-term identity.
|
||||
|
||||
### IdentityPublic
|
||||
|
||||
```
|
||||
[Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216]
|
||||
total: 3200 bytes
|
||||
```
|
||||
|
||||
`X-Wing pk = ML-KEM-768 pk (1184) || X25519 pk (32)`.
|
||||
|
||||
## X-Wing combiner (IETF spec, exact)
|
||||
|
||||
```
|
||||
shared_secret = SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
|
||||
```
|
||||
|
||||
| Input | Bytes | Source |
|
||||
|---|---|---|
|
||||
| `"\./"` | 3 | label prefix (backslash, dot, slash) |
|
||||
| `"X-Wing"` | 6 | ASCII protocol name |
|
||||
| `ss_M` | 32 | ML-KEM-768 shared secret |
|
||||
| `ss_X` | 32 | X25519 shared secret |
|
||||
| `ct_X` | 32 | X25519 ephemeral public key from encapsulator |
|
||||
| `pk_X` | 32 | X25519 static public key of recipient |
|
||||
|
||||
Hash construction: SHA3-256, no HKDF. Inputs are fixed-length so no
|
||||
length separators are required.
|
||||
|
||||
In the Z-Wing handshake the **responder is the encapsulator** (it sends
|
||||
ct_X) and the **initiator is the recipient** (its X-Wing static is the
|
||||
target of the encapsulation).
|
||||
|
||||
## Channel keys
|
||||
|
||||
After the handshake:
|
||||
|
||||
```
|
||||
keyI2R = HKDF-SHA256(shared, "lux.zwing.v1/i2r", 32)
|
||||
keyR2I = HKDF-SHA256(shared, "lux.zwing.v1/r2i", 32)
|
||||
```
|
||||
|
||||
Each direction has its own ChaCha20-Poly1305 key. Nonce = 12 bytes,
|
||||
high 4 bytes zero, low 8 bytes are the big-endian sequence counter.
|
||||
A 64-bit counter would last 5.8e11 years at one record per nanosecond;
|
||||
exhausting it returns `ErrSequenceExhausted`.
|
||||
|
||||
Forward secrecy comes from the X25519 ephemeral inside X-Wing. The
|
||||
ephemeral private key and intermediate `ss_M` / `ss_X` values are zeroed
|
||||
once the shared secret is derived. Channel keys are zeroed after the
|
||||
AEADs have been constructed.
|
||||
|
||||
## Identity signatures
|
||||
|
||||
The `Identity` type holds three keypairs: Ed25519, ML-DSA-65, and X-Wing.
|
||||
Signature wire format:
|
||||
|
||||
```
|
||||
[Ed25519 sig: 64][ML-DSA-65 sig: 3309] total: 3373 bytes
|
||||
```
|
||||
|
||||
Both must verify or `ErrSignatureInvalid` is returned. ML-DSA is signed
|
||||
with the `lux.zwing.v1` context per FIPS 204 §5.2 so signatures are not
|
||||
portable across protocols that share identity keys.
|
||||
|
||||
## Public API
|
||||
|
||||
```go
|
||||
// Identity
|
||||
id, _ := zwing.GenerateIdentity()
|
||||
pub := id.Public() // serialise + hand to a peer
|
||||
|
||||
// Server
|
||||
ln, _ := zwing.Listen(":9999", &zwing.Config{LocalIdentity: id})
|
||||
for {
|
||||
conn, err := ln.Accept() // post-handshake net.Conn
|
||||
if err != nil { break }
|
||||
go handle(conn)
|
||||
}
|
||||
|
||||
// Client
|
||||
conn, _ := zwing.Dial(ctx, "host:9999", &zwing.Config{
|
||||
LocalIdentity: clientID,
|
||||
ExpectedRemote: pinnedServerPub, // optional; nil = any valid peer
|
||||
})
|
||||
```
|
||||
|
||||
`Client(net.Conn, *Config)` and `Server(net.Conn, *Config)` wrap an
|
||||
existing connection (Unix socket, TLS conn, in-memory pipe, RNS link)
|
||||
without TCP `Dial`/`Listen`.
|
||||
|
||||
## What's NOT in this version
|
||||
|
||||
* RNS routing. Z-Wing-over-RNS is a follow-up package (LP-9702 deployment
|
||||
path); v0.1.0 ships Z-Wing-over-TCP only.
|
||||
* AES-256-GCM AEAD. Single ciphersuite; ChaCha20-Poly1305 only.
|
||||
* Cipher / KEM negotiation. There is one Z-Wing v1.
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
GOWORK=off go test ./... -count=1
|
||||
```
|
||||
|
||||
Six tests cover the spec:
|
||||
|
||||
| Test | What it proves |
|
||||
|---|---|
|
||||
| `TestHandshakeRoundTrip` | end-to-end handshake; both peers see correct remote identity; bidirectional traffic works |
|
||||
| `TestDialListen` | TCP `Dial` + `Listener.Accept`, payload round-trip |
|
||||
| `TestIdentityMismatch` | `Config.ExpectedRemote = wrong` returns `ErrIdentityMismatch` |
|
||||
| `TestForwardSecrecy` | channel keys are zeroed after the AEAD is constructed |
|
||||
| `TestSequencedNonces` | three sequential records each decrypt cleanly under increasing nonces |
|
||||
| `TestZAPOverZWing` | `zap.NewConn` over a Z-Wing client; one ZAP RPC round-trip succeeds |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `doc.go` | package documentation |
|
||||
| `errors.go` | typed errors |
|
||||
| `kem.go` | X-Wing KEM (X25519 + ML-KEM-768 + SHA3-256 combiner) |
|
||||
| `identity.go` | hybrid Ed25519 + ML-DSA-65 identity with X-Wing static key |
|
||||
| `wire.go` | length-prefixed framing |
|
||||
| `handshake.go` | initiator/responder state machines |
|
||||
| `channel.go` | post-handshake `net.Conn` with ChaCha20-Poly1305 |
|
||||
| `config.go` | `Config` and `DefaultPort` |
|
||||
| `dial.go` | `Dial` / `Client` |
|
||||
| `listen.go` | `Listen` / `Listener` |
|
||||
| `zwing_test.go` | core protocol tests |
|
||||
| `zap_test.go` | ZAP-over-Z-Wing integration |
|
||||
|
||||
## See also
|
||||
|
||||
* `~/work/lux/api/zap/LLM.md` — the application protocol that runs on top
|
||||
* `~/work/zap/zap/src/crypto.rs` — Rust hybrid handshake reference
|
||||
* `~/work/lux/crypto` — ML-KEM, ML-DSA, Ed25519 primitives used here
|
||||
* `draft-connolly-cfrg-xwing-kem` — IETF X-Wing combiner spec
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// Conn is a Z-Wing secure channel implementing net.Conn. After the
|
||||
// handshake, every Read/Write goes through ChaCha20-Poly1305 with
|
||||
// per-direction sequence-numbered nonces.
|
||||
type Conn struct {
|
||||
raw net.Conn
|
||||
remote *IdentityPublic
|
||||
|
||||
readMu sync.Mutex
|
||||
readAEAD cipher.AEAD
|
||||
readSeq uint64
|
||||
readBuf []byte // overflow from a previous Read
|
||||
|
||||
writeMu sync.Mutex
|
||||
writeAEAD cipher.AEAD
|
||||
writeSeq uint64
|
||||
}
|
||||
|
||||
// newConn wraps a freshly handshaken net.Conn. role determines which
|
||||
// direction's key is used for reading vs writing.
|
||||
func newConn(raw net.Conn, res *handshakeResult, initiator bool) (*Conn, error) {
|
||||
c := &Conn{raw: raw, remote: res.remote}
|
||||
var rxKey, txKey []byte
|
||||
if initiator {
|
||||
txKey = res.keyI2R
|
||||
rxKey = res.keyR2I
|
||||
} else {
|
||||
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
|
||||
// Best-effort zeroing of the channel keys held by the handshake result
|
||||
// once the AEADs have copied them internally.
|
||||
zero(res.keyI2R)
|
||||
zero(res.keyR2I)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Conn) RemoteIdentity() *IdentityPublic { return c.remote }
|
||||
|
||||
// Read reads decrypted plaintext into b. A Z-Wing record may be larger
|
||||
// than b, in which case the remainder is buffered for the next call.
|
||||
func (c *Conn) Read(b []byte) (int, error) {
|
||||
c.readMu.Lock()
|
||||
defer c.readMu.Unlock()
|
||||
|
||||
if len(c.readBuf) > 0 {
|
||||
n := copy(b, c.readBuf)
|
||||
c.readBuf = c.readBuf[n:]
|
||||
if len(c.readBuf) == 0 {
|
||||
c.readBuf = nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
frame, err := readFrame(c.raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if c.readSeq == ^uint64(0) {
|
||||
return 0, ErrSequenceExhausted
|
||||
}
|
||||
nonce := nonceFor(c.readSeq)
|
||||
plaintext, err := c.readAEAD.Open(nil, nonce[:], frame, nil)
|
||||
if err != nil {
|
||||
return 0, ErrCiphertextCorrupted
|
||||
}
|
||||
c.readSeq++
|
||||
|
||||
n := copy(b, plaintext)
|
||||
if n < len(plaintext) {
|
||||
c.readBuf = plaintext[n:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Write encrypts b as a single Z-Wing record and writes it to the
|
||||
// underlying conn. Application payloads larger than MaxFrameSize-tag are
|
||||
// split.
|
||||
func (c *Conn) Write(b []byte) (int, error) {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
const maxPlain = MaxFrameSize - chacha20poly1305.Overhead
|
||||
written := 0
|
||||
for written < len(b) {
|
||||
end := written + maxPlain
|
||||
if end > len(b) {
|
||||
end = len(b)
|
||||
}
|
||||
chunk := b[written:end]
|
||||
|
||||
if c.writeSeq == ^uint64(0) {
|
||||
return written, ErrSequenceExhausted
|
||||
}
|
||||
nonce := nonceFor(c.writeSeq)
|
||||
ciphertext := c.writeAEAD.Seal(nil, nonce[:], chunk, nil)
|
||||
c.writeSeq++
|
||||
|
||||
if err := writeFrame(c.raw, ciphertext); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += len(chunk)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (c *Conn) Close() error {
|
||||
if c.raw == nil {
|
||||
return ErrChannelClosed
|
||||
}
|
||||
return c.raw.Close()
|
||||
}
|
||||
|
||||
// LocalAddr returns the underlying local address.
|
||||
func (c *Conn) LocalAddr() net.Addr { return c.raw.LocalAddr() }
|
||||
|
||||
// RemoteAddr returns the underlying remote address.
|
||||
func (c *Conn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
|
||||
|
||||
// SetDeadline sets read+write deadline on the underlying conn.
|
||||
func (c *Conn) SetDeadline(t time.Time) error { return c.raw.SetDeadline(t) }
|
||||
|
||||
// SetReadDeadline sets the read deadline on the underlying conn.
|
||||
func (c *Conn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
|
||||
|
||||
// SetWriteDeadline sets the write deadline on the underlying conn.
|
||||
func (c *Conn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) }
|
||||
|
||||
// nonceFor builds a 12-byte ChaCha20-Poly1305 nonce from a 64-bit counter:
|
||||
// the high 4 bytes are zero, the low 8 are the big-endian counter.
|
||||
func nonceFor(seq uint64) [12]byte {
|
||||
var n [12]byte
|
||||
binary.BigEndian.PutUint64(n[4:], seq)
|
||||
return n
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
nonce := handshakeNonce(nonceLabel)
|
||||
return aead.Seal(nil, nonce[:], plaintext, aad), nil
|
||||
}
|
||||
|
||||
func aeadOpen(key, nonceLabel, aad, ciphertext []byte) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := handshakeNonce(nonceLabel)
|
||||
pt, err := aead.Open(nil, nonce[:], ciphertext, aad)
|
||||
if err != nil {
|
||||
return nil, errors.New("zwing: handshake AEAD open failed")
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// handshakeNonce derives a 12-byte fixed nonce from a label. The key used
|
||||
// with this nonce is unique to a single handshake (HKDF over the X-Wing
|
||||
// shared secret with a session-specific label), so reusing the nonce is
|
||||
// safe.
|
||||
func handshakeNonce(label []byte) [12]byte {
|
||||
var n [12]byte
|
||||
copy(n[:], label)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import "time"
|
||||
|
||||
// DefaultPort is the canonical Z-Wing TCP port.
|
||||
const DefaultPort = 9999
|
||||
|
||||
// Config configures a Z-Wing dialer or listener.
|
||||
type Config struct {
|
||||
// LocalIdentity is this endpoint's long-term identity. Required.
|
||||
LocalIdentity *Identity
|
||||
|
||||
// ExpectedRemote pins the peer's expected public identity. If set, the
|
||||
// handshake fails with ErrIdentityMismatch when the remote presents a
|
||||
// different identity. Leave nil for "any peer with a valid identity".
|
||||
ExpectedRemote *IdentityPublic
|
||||
|
||||
// HandshakeTimeout bounds the handshake itself. Zero means no timeout.
|
||||
HandshakeTimeout time.Duration
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Dial connects to a Z-Wing listener at addr and runs the handshake.
|
||||
// The returned net.Conn is the post-handshake encrypted channel.
|
||||
func Dial(ctx context.Context, addr string, cfg *Config) (net.Conn, error) {
|
||||
if cfg == nil || cfg.LocalIdentity == nil {
|
||||
return nil, ErrConfigMissingID
|
||||
}
|
||||
d := net.Dialer{}
|
||||
raw, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clientHandshake(raw, cfg)
|
||||
}
|
||||
|
||||
// Client wraps an existing net.Conn (e.g. a TLS conn, a Unix socket, a
|
||||
// pipe) and runs the Z-Wing handshake on top as the initiator.
|
||||
func Client(raw net.Conn, cfg *Config) (net.Conn, error) {
|
||||
return clientHandshake(raw, cfg)
|
||||
}
|
||||
|
||||
// Server wraps an existing net.Conn and runs the handshake as the responder.
|
||||
func Server(raw net.Conn, cfg *Config) (net.Conn, error) {
|
||||
return serverHandshake(raw, cfg)
|
||||
}
|
||||
|
||||
func clientHandshake(raw net.Conn, cfg *Config) (net.Conn, error) {
|
||||
if cfg.HandshakeTimeout > 0 {
|
||||
_ = raw.SetDeadline(time.Now().Add(cfg.HandshakeTimeout))
|
||||
defer raw.SetDeadline(time.Time{})
|
||||
}
|
||||
res, err := runInitiator(raw, cfg)
|
||||
if err != nil {
|
||||
raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
return newConn(raw, res, true)
|
||||
}
|
||||
|
||||
func serverHandshake(raw net.Conn, cfg *Config) (net.Conn, error) {
|
||||
if cfg.HandshakeTimeout > 0 {
|
||||
_ = raw.SetDeadline(time.Now().Add(cfg.HandshakeTimeout))
|
||||
defer raw.SetDeadline(time.Time{})
|
||||
}
|
||||
res, err := runResponder(raw, cfg)
|
||||
if err != nil {
|
||||
raw.Close()
|
||||
return nil, err
|
||||
}
|
||||
return newConn(raw, res, false)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package zwing implements Z-Wing, the canonical Lux post-quantum secure
|
||||
// channel. It provides an authenticated, forward-secret, hybrid PQ tunnel
|
||||
// that exposes a net.Conn so application protocols (e.g. luxfi/api/zap)
|
||||
// run unchanged on top.
|
||||
//
|
||||
// Layering:
|
||||
//
|
||||
// Application: ZAP RPC (luxfi/api/zap) <- unchanged
|
||||
// Channel: Z-Wing (this package)
|
||||
// KEM: X-Wing (X25519 + ML-KEM-768, IETF draft-connolly-cfrg-xwing-kem)
|
||||
// Identity: Ed25519 + ML-DSA-65 hybrid signatures
|
||||
// AEAD: ChaCha20-Poly1305, sequence-numbered nonces
|
||||
// Routing: Plain TCP (this version) or RNS (follow-up package)
|
||||
//
|
||||
// Z-Wing is one construction. There are no alternates, no build tags, no
|
||||
// cipher negotiation. The point of standardising is that two endpoints
|
||||
// either speak Z-Wing v1 or they don't talk.
|
||||
//
|
||||
// Public API:
|
||||
//
|
||||
// id, _ := zwing.GenerateIdentity()
|
||||
// ln, _ := zwing.Listen(":9999", &zwing.Config{LocalIdentity: id})
|
||||
// conn, _ := zwing.Dial(ctx, "host:9999", &zwing.Config{LocalIdentity: clientID})
|
||||
//
|
||||
// `conn` and the result of `ln.Accept()` both implement net.Conn and can be
|
||||
// passed to zap.NewConn, http.Serve, or anything else that wants a stream.
|
||||
package zwing
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrHandshakeFailed = errors.New("zwing: handshake failed")
|
||||
ErrIdentityMismatch = errors.New("zwing: remote identity does not match expected")
|
||||
ErrSignatureInvalid = errors.New("zwing: signature verification failed")
|
||||
ErrMessageTooLarge = errors.New("zwing: message exceeds maximum size")
|
||||
ErrShortRead = errors.New("zwing: short read")
|
||||
ErrInvalidWireFormat = errors.New("zwing: invalid wire format")
|
||||
ErrChannelClosed = errors.New("zwing: channel closed")
|
||||
ErrSequenceExhausted = errors.New("zwing: AEAD sequence number exhausted")
|
||||
ErrConfigMissingID = errors.New("zwing: config missing LocalIdentity")
|
||||
ErrCiphertextCorrupted = errors.New("zwing: AEAD authentication failed")
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
module github.com/luxfi/zwing
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/luxfi/api v1.0.9
|
||||
github.com/luxfi/crypto v1.18.3
|
||||
golang.org/x/crypto v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/luxfi/accel v1.0.7 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/luxfi/accel v1.0.7 h1:ksHieAp50umwqxqgyHk9WiOmXM54kia3IEDb5H7FsM8=
|
||||
github.com/luxfi/accel v1.0.7/go.mod h1:iZD3oxffiMEIT/KvzD8bgwC/cBn4AYlMW3QJpbRa4RE=
|
||||
github.com/luxfi/api v1.0.9 h1:JuuKdJ6EaYCJSaZLB0xUWU470kbCnEklWcSOGIfl4Aw=
|
||||
github.com/luxfi/api v1.0.9/go.mod h1:5OFWvZF+PbyuvLCDyKKpXCA/xp+LxJ32yOhNEKwQFN0=
|
||||
github.com/luxfi/crypto v1.18.3 h1:f6mBUHNjZl9/9nXPSqjq+oJttrPl8slvcZ49Vi7jKw4=
|
||||
github.com/luxfi/crypto v1.18.3/go.mod h1:qBtCJDT6p6aNa2hMtPxYwC688Bnsb3Amso8q09GsMls=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
// Handshake is the Z-Wing 1-RTT mutual-auth handshake.
|
||||
//
|
||||
// Initiator -> Responder : HandshakeInit
|
||||
// initiator IdentityPublic || ML-DSA+Ed25519 sig over (label || init pub)
|
||||
//
|
||||
// Responder -> Initiator : HandshakeResponse
|
||||
// XWing ciphertext || encrypted{ responder IdentityPublic || sig }
|
||||
//
|
||||
// After both messages, both sides hold the same X-Wing shared secret
|
||||
// derived against initiator's static X-Wing key. Forward secrecy is
|
||||
// provided by the X25519 ephemeral inside X-Wing and by zeroing all
|
||||
// intermediate key material once channel keys are derived.
|
||||
//
|
||||
// Identity-binding: the responder signs a transcript hash that covers
|
||||
// the initiator's identity material AND the X-Wing ciphertext, so the
|
||||
// signature can't be replayed onto another session.
|
||||
|
||||
const (
|
||||
hsLabelInit = "lux.zwing.v1/handshake-init"
|
||||
hsLabelResponse = "lux.zwing.v1/handshake-response"
|
||||
|
||||
channelKeyLabelI2R = "lux.zwing.v1/i2r"
|
||||
channelKeyLabelR2I = "lux.zwing.v1/r2i"
|
||||
|
||||
chacha20KeySize = 32
|
||||
)
|
||||
|
||||
// HandshakeInit is the wire form of the initiator's first message.
|
||||
type HandshakeInit struct {
|
||||
IdentityPub []byte // serialised IdentityPublic
|
||||
Signature []byte // identity signature over label || IdentityPub
|
||||
}
|
||||
|
||||
// HandshakeResponse is the wire form of the responder's reply. The
|
||||
// identity payload is encrypted under a key derived from the X-Wing
|
||||
// shared secret so the responder identity is not revealed to passive
|
||||
// observers.
|
||||
type HandshakeResponse struct {
|
||||
XWingCiphertext []byte // X-Wing ct (1088+32 bytes)
|
||||
EncryptedID []byte // AEAD-sealed responder identity + signature
|
||||
}
|
||||
|
||||
// MarshalBinary encodes a HandshakeInit as
|
||||
//
|
||||
// [2 bytes BE: idLen][id bytes][2 bytes BE: sigLen][sig bytes]
|
||||
func (h *HandshakeInit) MarshalBinary() []byte {
|
||||
out := make([]byte, 0, 4+len(h.IdentityPub)+len(h.Signature))
|
||||
out = appendU16(out, len(h.IdentityPub))
|
||||
out = append(out, h.IdentityPub...)
|
||||
out = appendU16(out, len(h.Signature))
|
||||
out = append(out, h.Signature...)
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHandshakeInit(data []byte) (*HandshakeInit, error) {
|
||||
r := newSliceReader(data)
|
||||
idLen, err := r.readU16()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := r.readN(int(idLen))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sigLen, err := r.readU16()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig, err := r.readN(int(sigLen))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !r.empty() {
|
||||
return nil, ErrInvalidWireFormat
|
||||
}
|
||||
return &HandshakeInit{IdentityPub: id, Signature: sig}, nil
|
||||
}
|
||||
|
||||
// MarshalBinary encodes a HandshakeResponse as
|
||||
//
|
||||
// [XWingCiphertextSize bytes][2 bytes BE: encLen][enc bytes]
|
||||
func (h *HandshakeResponse) MarshalBinary() []byte {
|
||||
out := make([]byte, 0, XWingCiphertextSize+2+len(h.EncryptedID))
|
||||
out = append(out, h.XWingCiphertext...)
|
||||
out = appendU16(out, len(h.EncryptedID))
|
||||
out = append(out, h.EncryptedID...)
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHandshakeResponse(data []byte) (*HandshakeResponse, error) {
|
||||
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
|
||||
}
|
||||
enc, err := r.readN(int(encLen))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !r.empty() {
|
||||
return nil, ErrInvalidWireFormat
|
||||
}
|
||||
return &HandshakeResponse{XWingCiphertext: ct, EncryptedID: enc}, nil
|
||||
}
|
||||
|
||||
// handshakeResult holds the data both sides agree on after a successful run.
|
||||
type handshakeResult struct {
|
||||
shared [XWingSharedSize]byte
|
||||
remote *IdentityPublic
|
||||
keyI2R []byte // 32 bytes, initiator->responder AEAD key
|
||||
keyR2I []byte // 32 bytes, responder->initiator AEAD key
|
||||
}
|
||||
|
||||
// runInitiator drives the initiator side of the handshake on conn.
|
||||
func runInitiator(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
|
||||
if cfg == nil || cfg.LocalIdentity == nil {
|
||||
return nil, ErrConfigMissingID
|
||||
}
|
||||
local := cfg.LocalIdentity
|
||||
|
||||
// 1. Send HandshakeInit.
|
||||
idPub := local.Public().MarshalBinary()
|
||||
sig, err := local.Sign([]byte(hsLabelInit), idPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
init := &HandshakeInit{IdentityPub: idPub, Signature: sig}
|
||||
if err := writeFrame(conn, init.MarshalBinary()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Receive HandshakeResponse.
|
||||
respFrame, err := readFrame(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := parseHandshakeResponse(respFrame)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Decapsulate X-Wing shared secret.
|
||||
shared, err := XWingDecapsulate(local.XWing, resp.XWingCiphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Derive a temporary AEAD key for the responder-identity payload
|
||||
// and decrypt it. Then derive the channel keys from the same secret
|
||||
// via separate labels.
|
||||
idKey := derive(shared[:], []byte("lux.zwing.v1/resp-id"), chacha20KeySize)
|
||||
defer zero(idKey)
|
||||
plaintext, err := aeadOpen(idKey, []byte("zwing-resp-id"), resp.XWingCiphertext, resp.EncryptedID)
|
||||
if err != nil {
|
||||
return nil, ErrCiphertextCorrupted
|
||||
}
|
||||
|
||||
remoteID, remoteSig, err := splitRespIDPayload(plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote, err := ParseIdentityPublic(remoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Verify responder signature over transcript = init || ct.
|
||||
transcript := transcriptHash(idPub, resp.XWingCiphertext)
|
||||
if err := remote.Verify([]byte(hsLabelResponse), transcript, remoteSig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. Optional pinning.
|
||||
if cfg.ExpectedRemote != nil && !remote.Equal(cfg.ExpectedRemote) {
|
||||
return nil, ErrIdentityMismatch
|
||||
}
|
||||
|
||||
res := &handshakeResult{
|
||||
shared: shared,
|
||||
remote: remote,
|
||||
keyI2R: derive(shared[:], []byte(channelKeyLabelI2R), chacha20KeySize),
|
||||
keyR2I: derive(shared[:], []byte(channelKeyLabelR2I), chacha20KeySize),
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// runResponder drives the responder side on conn.
|
||||
func runResponder(conn io.ReadWriter, cfg *Config) (*handshakeResult, error) {
|
||||
if cfg == nil || cfg.LocalIdentity == nil {
|
||||
return nil, ErrConfigMissingID
|
||||
}
|
||||
local := cfg.LocalIdentity
|
||||
|
||||
// 1. Read HandshakeInit.
|
||||
initFrame, err := readFrame(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
init, err := parseHandshakeInit(initFrame)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote, err := ParseIdentityPublic(init.IdentityPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := remote.Verify([]byte(hsLabelInit), init.IdentityPub, init.Signature); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.ExpectedRemote != nil && !remote.Equal(cfg.ExpectedRemote) {
|
||||
return nil, ErrIdentityMismatch
|
||||
}
|
||||
|
||||
// 2. Encapsulate X-Wing to initiator's static key.
|
||||
ct, shared, err := XWingEncapsulate(nil, remote.XWingPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
resp := &HandshakeResponse{XWingCiphertext: ct, EncryptedID: ciphertext}
|
||||
if err := writeFrame(conn, resp.MarshalBinary()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &handshakeResult{
|
||||
shared: shared,
|
||||
remote: remote,
|
||||
keyI2R: derive(shared[:], []byte(channelKeyLabelI2R), chacha20KeySize),
|
||||
keyR2I: derive(shared[:], []byte(channelKeyLabelR2I), chacha20KeySize),
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// derive runs HKDF-SHA256 over the X-Wing shared secret to produce a key
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// transcriptHash binds the initiator's identity payload and the X-Wing
|
||||
// ciphertext into a single 32-byte hash that the responder signs.
|
||||
func transcriptHash(initPub, ct []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("lux.zwing.v1/transcript"))
|
||||
binary.Write(h, binary.BigEndian, uint32(len(initPub)))
|
||||
h.Write(initPub)
|
||||
binary.Write(h, binary.BigEndian, uint32(len(ct)))
|
||||
h.Write(ct)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// buildRespIDPayload encodes the responder identity + signature as
|
||||
//
|
||||
// [2 BE idLen][id][2 BE sigLen][sig]
|
||||
func buildRespIDPayload(id, sig []byte) []byte {
|
||||
out := make([]byte, 0, 4+len(id)+len(sig))
|
||||
out = appendU16(out, len(id))
|
||||
out = append(out, id...)
|
||||
out = appendU16(out, len(sig))
|
||||
out = append(out, sig...)
|
||||
return out
|
||||
}
|
||||
|
||||
func splitRespIDPayload(data []byte) (id, sig []byte, err error) {
|
||||
r := newSliceReader(data)
|
||||
idLen, err := r.readU16()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
id, err = r.readN(int(idLen))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sigLen, err := r.readU16()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sig, err = r.readN(int(sigLen))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !r.empty() {
|
||||
return nil, nil, ErrInvalidWireFormat
|
||||
}
|
||||
return id, sig, nil
|
||||
}
|
||||
|
||||
// ── small helpers ────────────────────────────────────────────────────
|
||||
|
||||
func appendU16(b []byte, n int) []byte {
|
||||
if n < 0 || n > 0xFFFF {
|
||||
panic("zwing: u16 out of range")
|
||||
}
|
||||
return append(b, byte(n>>8), byte(n))
|
||||
}
|
||||
|
||||
type sliceReader struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newSliceReader(b []byte) *sliceReader { return &sliceReader{buf: b} }
|
||||
|
||||
func (r *sliceReader) readU16() (uint16, error) {
|
||||
if len(r.buf) < 2 {
|
||||
return 0, ErrShortRead
|
||||
}
|
||||
v := binary.BigEndian.Uint16(r.buf[:2])
|
||||
r.buf = r.buf[2:]
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *sliceReader) readN(n int) ([]byte, error) {
|
||||
if n < 0 || len(r.buf) < n {
|
||||
return nil, ErrShortRead
|
||||
}
|
||||
out := r.buf[:n]
|
||||
r.buf = r.buf[n:]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *sliceReader) empty() bool { return len(r.buf) == 0 }
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/luxfi/crypto/ed25519"
|
||||
"github.com/luxfi/crypto/mldsa"
|
||||
)
|
||||
|
||||
// Identity binds a peer to long-term Ed25519 + ML-DSA-65 keys plus an
|
||||
// X-Wing static keypair used as the KEM recipient material.
|
||||
type Identity struct {
|
||||
EdPub ed25519.PublicKey
|
||||
EdPriv ed25519.PrivateKey
|
||||
|
||||
DSAPriv *mldsa.PrivateKey
|
||||
|
||||
XWing *XWingPrivateKey
|
||||
}
|
||||
|
||||
// IdentityPublic is the wire-serialisable, secrets-stripped half of an
|
||||
// Identity. It is what peers exchange and verify against.
|
||||
type IdentityPublic struct {
|
||||
EdPub ed25519.PublicKey
|
||||
DSAPub *mldsa.PublicKey
|
||||
XWingPub *XWingPublicKey
|
||||
}
|
||||
|
||||
// GenerateIdentity creates a fresh Z-Wing identity using crypto/rand.
|
||||
func GenerateIdentity() (*Identity, error) {
|
||||
return GenerateIdentityFrom(rand.Reader)
|
||||
}
|
||||
|
||||
// GenerateIdentityFrom creates an identity from a custom entropy source.
|
||||
// Useful for deterministic key generation in tests.
|
||||
func GenerateIdentityFrom(r io.Reader) (*Identity, error) {
|
||||
edPub, edPriv, err := ed25519.GenerateKey(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsaPriv, err := mldsa.GenerateKey(r, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xwing, err := GenerateXWingKey(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Identity{
|
||||
EdPub: edPub,
|
||||
EdPriv: edPriv,
|
||||
DSAPriv: dsaPriv,
|
||||
XWing: xwing,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Public returns the public half of the identity.
|
||||
func (id *Identity) Public() *IdentityPublic {
|
||||
return &IdentityPublic{
|
||||
EdPub: id.EdPub,
|
||||
DSAPub: id.DSAPriv.PublicKey,
|
||||
XWingPub: id.XWing.Public(),
|
||||
}
|
||||
}
|
||||
|
||||
// Sign produces the Z-Wing identity signature: an Ed25519 signature over
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
out := make([]byte, 0, ed25519.SignatureSize+mldsa.MLDSA65SignatureSize)
|
||||
out = append(out, edSig...)
|
||||
out = append(out, dsaSig...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Verify checks an identity signature produced by Sign.
|
||||
func (pub *IdentityPublic) Verify(ctx, message, signature []byte) error {
|
||||
if len(signature) != ed25519.SignatureSize+mldsa.MLDSA65SignatureSize {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
digest := identityDigest(ctx, message)
|
||||
edSig := signature[:ed25519.SignatureSize]
|
||||
dsaSig := signature[ed25519.SignatureSize:]
|
||||
if !ed25519.Verify(pub.EdPub, digest, edSig) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
if !pub.DSAPub.VerifySignatureCtx(digest, dsaSig, []byte(zwingDomain)) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalBinary serialises the public identity:
|
||||
//
|
||||
// [Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216]
|
||||
func (pub *IdentityPublic) MarshalBinary() []byte {
|
||||
out := make([]byte, 0, IdentityPublicSize)
|
||||
out = append(out, pub.EdPub...)
|
||||
out = append(out, pub.DSAPub.Bytes()...)
|
||||
out = append(out, pub.XWingPub.MarshalBinary()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseIdentityPublic decodes the wire format produced by MarshalBinary.
|
||||
func ParseIdentityPublic(data []byte) (*IdentityPublic, error) {
|
||||
if len(data) != IdentityPublicSize {
|
||||
return nil, errors.New("zwing: invalid public identity size")
|
||||
}
|
||||
off := 0
|
||||
edPub := make(ed25519.PublicKey, ed25519.PublicKeySize)
|
||||
copy(edPub, data[off:off+ed25519.PublicKeySize])
|
||||
off += ed25519.PublicKeySize
|
||||
|
||||
dsaPubBytes := data[off : off+mldsa.MLDSA65PublicKeySize]
|
||||
off += mldsa.MLDSA65PublicKeySize
|
||||
dsaPub, err := mldsa.PublicKeyFromBytes(dsaPubBytes, mldsa.MLDSA65)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xwingPub, err := ParseXWingPublicKey(data[off : off+XWingPublicKeySize])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IdentityPublic{EdPub: edPub, DSAPub: dsaPub, XWingPub: xwingPub}, nil
|
||||
}
|
||||
|
||||
// Equal reports whether two public identities have byte-identical key material.
|
||||
func (pub *IdentityPublic) Equal(other *IdentityPublic) bool {
|
||||
if pub == nil || other == nil {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare(pub.EdPub, other.EdPub) != 1 {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare(pub.DSAPub.Bytes(), other.DSAPub.Bytes()) != 1 {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare(pub.XWingPub.MarshalBinary(), other.XWingPub.MarshalBinary()) != 1 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IdentityPublicSize is the fixed-size wire encoding of an IdentityPublic.
|
||||
const IdentityPublicSize = ed25519.PublicKeySize +
|
||||
mldsa.MLDSA65PublicKeySize +
|
||||
XWingPublicKeySize
|
||||
|
||||
// zwingDomain is the protocol domain separator. It is bound into every
|
||||
// identity signature so Z-Wing signatures cannot be replayed onto another
|
||||
// Lux protocol that signs with the same identity keys.
|
||||
const zwingDomain = "lux.zwing.v1"
|
||||
|
||||
func identityDigest(ctx, message []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(zwingDomain))
|
||||
h.Write([]byte{byte(len(ctx))})
|
||||
h.Write(ctx)
|
||||
h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/luxfi/crypto/mlkem"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// X-Wing combiner constants per draft-connolly-cfrg-xwing-kem.
|
||||
//
|
||||
// shared_secret = SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
|
||||
//
|
||||
// ss_M ML-KEM-768 shared secret (32 bytes)
|
||||
// ss_X X25519 shared secret (32 bytes)
|
||||
// ct_X X25519 ephemeral public key sent by encapsulator (32 bytes)
|
||||
// pk_X X25519 static public key of recipient (32 bytes)
|
||||
var (
|
||||
xwingLabelPrefix = []byte{'\\', '.', '/'} // 3 bytes: backslash, dot, slash
|
||||
xwingLabelName = []byte("X-Wing") // 6 bytes ASCII
|
||||
)
|
||||
|
||||
// X-Wing wire sizes.
|
||||
const (
|
||||
XWingPublicKeySize = mlkem.MLKEM768PublicKeySize + curve25519.PointSize // 1184 + 32
|
||||
XWingCiphertextSize = mlkem.MLKEM768CiphertextSize + curve25519.PointSize // 1088 + 32
|
||||
XWingSharedSize = 32
|
||||
)
|
||||
|
||||
// XWingPublicKey is the recipient's X-Wing static public key.
|
||||
type XWingPublicKey struct {
|
||||
MLKEM *mlkem.PublicKey
|
||||
X25519 [32]byte
|
||||
}
|
||||
|
||||
// XWingPrivateKey is the recipient's X-Wing static secret key plus the
|
||||
// matching public material needed for the combiner.
|
||||
type XWingPrivateKey struct {
|
||||
MLKEMPriv *mlkem.PrivateKey
|
||||
X25519Priv [32]byte
|
||||
X25519Pub [32]byte
|
||||
}
|
||||
|
||||
// Public returns the X-Wing public key.
|
||||
func (sk *XWingPrivateKey) Public() *XWingPublicKey {
|
||||
return &XWingPublicKey{
|
||||
MLKEM: sk.MLKEMPriv.PublicKey(),
|
||||
X25519: sk.X25519Pub,
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalBinary serialises the X-Wing public key as ML-KEM-768 || X25519.
|
||||
func (pk *XWingPublicKey) MarshalBinary() []byte {
|
||||
out := make([]byte, 0, XWingPublicKeySize)
|
||||
out = append(out, pk.MLKEM.Bytes()...)
|
||||
out = append(out, pk.X25519[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseXWingPublicKey decodes the wire format produced by MarshalBinary.
|
||||
func ParseXWingPublicKey(data []byte) (*XWingPublicKey, error) {
|
||||
if len(data) != XWingPublicKeySize {
|
||||
return nil, errors.New("zwing: invalid X-Wing public key size")
|
||||
}
|
||||
mlkemPK, err := mlkem.PublicKeyFromBytes(data[:mlkem.MLKEM768PublicKeySize], mlkem.MLKEM768)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pk := &XWingPublicKey{MLKEM: mlkemPK}
|
||||
copy(pk.X25519[:], data[mlkem.MLKEM768PublicKeySize:])
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
// GenerateXWingKey produces a fresh X-Wing static keypair.
|
||||
func GenerateXWingKey(reader io.Reader) (*XWingPrivateKey, error) {
|
||||
if reader == nil {
|
||||
reader = rand.Reader
|
||||
}
|
||||
_, mlkemPriv, err := mlkem.GenerateKeyPair(reader, mlkem.MLKEM768)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sk := &XWingPrivateKey{MLKEMPriv: mlkemPriv}
|
||||
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
|
||||
}
|
||||
copy(sk.X25519Pub[:], pub)
|
||||
return sk, nil
|
||||
}
|
||||
|
||||
// XWingEncapsulate runs the encapsulator side. Output is (ciphertext, shared).
|
||||
// The ciphertext is ML-KEM-768 ct || X25519 ephemeral pk.
|
||||
func XWingEncapsulate(reader io.Reader, recipient *XWingPublicKey) (ciphertext []byte, shared [XWingSharedSize]byte, err error) {
|
||||
if reader == nil {
|
||||
reader = rand.Reader
|
||||
}
|
||||
|
||||
mlkemCT, ssM, err := recipient.MLKEM.Encapsulate(reader)
|
||||
if err != nil {
|
||||
return nil, shared, err
|
||||
}
|
||||
|
||||
var ephPriv [32]byte
|
||||
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
|
||||
}
|
||||
ssX, err := curve25519.X25519(ephPriv[:], recipient.X25519[:])
|
||||
if err != nil {
|
||||
return nil, shared, err
|
||||
}
|
||||
|
||||
shared = combineXWing(ssM, ssX, ephPub, recipient.X25519[:])
|
||||
|
||||
ciphertext = make([]byte, 0, XWingCiphertextSize)
|
||||
ciphertext = append(ciphertext, mlkemCT...)
|
||||
ciphertext = append(ciphertext, ephPub...)
|
||||
|
||||
zero(ephPriv[:])
|
||||
zero(ssM)
|
||||
zero(ssX)
|
||||
return ciphertext, shared, nil
|
||||
}
|
||||
|
||||
// XWingDecapsulate runs the recipient side.
|
||||
func XWingDecapsulate(sk *XWingPrivateKey, ciphertext []byte) ([XWingSharedSize]byte, error) {
|
||||
var shared [XWingSharedSize]byte
|
||||
if len(ciphertext) != XWingCiphertextSize {
|
||||
return shared, errors.New("zwing: invalid X-Wing ciphertext size")
|
||||
}
|
||||
mlkemCT := ciphertext[:mlkem.MLKEM768CiphertextSize]
|
||||
ephPub := ciphertext[mlkem.MLKEM768CiphertextSize:]
|
||||
|
||||
ssM, err := sk.MLKEMPriv.Decapsulate(mlkemCT)
|
||||
if err != nil {
|
||||
return shared, err
|
||||
}
|
||||
ssX, err := curve25519.X25519(sk.X25519Priv[:], ephPub)
|
||||
if err != nil {
|
||||
return shared, err
|
||||
}
|
||||
|
||||
shared = combineXWing(ssM, ssX, ephPub, sk.X25519Pub[:])
|
||||
zero(ssM)
|
||||
zero(ssX)
|
||||
return shared, nil
|
||||
}
|
||||
|
||||
// combineXWing implements the IETF X-Wing KDF combiner exactly:
|
||||
//
|
||||
// SHA3-256( "\./" || "X-Wing" || ss_M || ss_X || ct_X || pk_X )
|
||||
//
|
||||
// All inputs are fixed-length so no separators are needed: ss_M=32,
|
||||
// ss_X=32, ct_X=32, pk_X=32.
|
||||
func combineXWing(ssM, ssX, ctX, pkX []byte) [XWingSharedSize]byte {
|
||||
h := sha3.New256()
|
||||
h.Write(xwingLabelPrefix)
|
||||
h.Write(xwingLabelName)
|
||||
h.Write(ssM)
|
||||
h.Write(ssX)
|
||||
h.Write(ctX)
|
||||
h.Write(pkX)
|
||||
var out [XWingSharedSize]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
func zero(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import "net"
|
||||
|
||||
// Listener is a Z-Wing listener. Accepted connections are post-handshake
|
||||
// net.Conns ready for application traffic.
|
||||
type Listener struct {
|
||||
raw net.Listener
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
// Listen binds a TCP listener at addr that performs Z-Wing handshakes
|
||||
// before yielding connections.
|
||||
func Listen(addr string, cfg *Config) (*Listener, error) {
|
||||
if cfg == nil || cfg.LocalIdentity == nil {
|
||||
return nil, ErrConfigMissingID
|
||||
}
|
||||
raw, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Listener{raw: raw, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Accept returns the next handshaken connection. If a handshake fails
|
||||
// the underlying conn is closed and Accept returns the error; callers
|
||||
// that want to keep listening should call Accept again.
|
||||
func (l *Listener) Accept() (net.Conn, error) {
|
||||
raw, err := l.raw.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return serverHandshake(raw, l.cfg)
|
||||
}
|
||||
|
||||
// Close closes the underlying TCP listener.
|
||||
func (l *Listener) Close() error { return l.raw.Close() }
|
||||
|
||||
// Addr returns the listener's TCP address.
|
||||
func (l *Listener) Addr() net.Addr { return l.raw.Addr() }
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
)
|
||||
|
||||
// MaxFrameSize is the largest single transport frame Z-Wing will read or
|
||||
// write. Application bytes larger than this are split by the channel.
|
||||
const MaxFrameSize = 1 << 20 // 1 MiB
|
||||
|
||||
// frame layout:
|
||||
//
|
||||
// [4 bytes BE length][payload]
|
||||
//
|
||||
// length covers the payload only. payload is either a handshake message
|
||||
// (raw bytes) or an AEAD-sealed transport record.
|
||||
|
||||
func writeFrame(w io.Writer, payload []byte) error {
|
||||
if len(payload) > MaxFrameSize {
|
||||
return ErrMessageTooLarge
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader) ([]byte, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n > MaxFrameSize {
|
||||
return nil, ErrMessageTooLarge
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrInvalidWireFormat
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/api/zap"
|
||||
"github.com/luxfi/zwing"
|
||||
)
|
||||
|
||||
// TestZAPOverZWing wires a luxfi/api/zap client through a Z-Wing channel,
|
||||
// runs one request/response, and verifies the application bytes survive
|
||||
// the encrypted transport unchanged.
|
||||
func TestZAPOverZWing(t *testing.T) {
|
||||
clientID, err := zwing.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("client id: %v", err)
|
||||
}
|
||||
serverID, err := zwing.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("server id: %v", err)
|
||||
}
|
||||
|
||||
ln, err := zwing.Listen("127.0.0.1:0", &zwing.Config{LocalIdentity: serverID})
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
srvDone := make(chan error, 1)
|
||||
go func() {
|
||||
srvDone <- runZAPEchoServer(ln)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cConn, err := zwing.Dial(ctx, ln.Addr().String(), &zwing.Config{LocalIdentity: clientID})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer cConn.Close()
|
||||
|
||||
zapConn := zap.NewConn(cConn, nil)
|
||||
defer zapConn.Close()
|
||||
|
||||
payload := []byte("zap-over-zwing")
|
||||
respType, respPayload, err := zapConn.Call(ctx, zap.MsgBuildBlock, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("zap call: %v", err)
|
||||
}
|
||||
if respType&zap.MsgResponseFlag == 0 {
|
||||
t.Fatalf("response missing MsgResponseFlag")
|
||||
}
|
||||
if !bytes.Equal(respPayload, payload) {
|
||||
t.Fatalf("payload mismatch: got %q want %q", respPayload, payload)
|
||||
}
|
||||
|
||||
// Close the client side first so the server's blocking ReadMessage
|
||||
// returns EOF promptly, then close the listener.
|
||||
zapConn.Close()
|
||||
cConn.Close()
|
||||
ln.Close()
|
||||
select {
|
||||
case err := <-srvDone:
|
||||
if err != nil && err != net.ErrClosed {
|
||||
t.Logf("server exited: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server did not exit after closing client and listener")
|
||||
}
|
||||
}
|
||||
|
||||
// runZAPEchoServer accepts one Z-Wing conn and runs a tiny ZAP echo loop
|
||||
// using the zap wire helpers directly so we don't need to construct a
|
||||
// full zap.Server (which would need a Listener built from a net.Listener,
|
||||
// not from zwing.Listener).
|
||||
func runZAPEchoServer(ln *zwing.Listener) error {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Read one request frame, echo it back with the response flag set.
|
||||
for {
|
||||
msgType, payload, err := zap.ReadMessage(readerOf(conn))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) < 4 {
|
||||
return nil
|
||||
}
|
||||
// payload is [reqID:uint32][body...]; echo body back with reqID.
|
||||
body := payload[4:]
|
||||
respPayload := make([]byte, 4+len(body))
|
||||
copy(respPayload[:4], payload[:4])
|
||||
copy(respPayload[4:], body)
|
||||
if err := zap.WriteMessage(writerOf(conn), msgType|zap.MsgResponseFlag, respPayload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readerOf / writerOf wrap a net.Conn so the zap wire helpers see plain
|
||||
// io.Reader / io.Writer (which they require).
|
||||
type readerOnly struct{ net.Conn }
|
||||
type writerOnly struct{ net.Conn }
|
||||
|
||||
func readerOf(c net.Conn) readerOnly { return readerOnly{c} }
|
||||
func writerOf(c net.Conn) writerOnly { return writerOnly{c} }
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package zwing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newPair(t *testing.T) (clientID, serverID *Identity) {
|
||||
t.Helper()
|
||||
c, err := GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("client identity: %v", err)
|
||||
}
|
||||
s, err := GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("server identity: %v", err)
|
||||
}
|
||||
return c, s
|
||||
}
|
||||
|
||||
// pipeConn turns a net.Pipe end into a deadline-supporting conn. net.Pipe
|
||||
// already gives us net.Conns; we just plumb both ends.
|
||||
|
||||
// runHandshakeOverPipe drives one initiator and one responder over a
|
||||
// memory pipe with the given configs and returns both Conns or the first
|
||||
// error.
|
||||
func runHandshakeOverPipe(t *testing.T, clientCfg, serverCfg *Config) (cConn, sConn net.Conn, cErr, sErr error) {
|
||||
t.Helper()
|
||||
left, right := net.Pipe()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c, err := Client(left, clientCfg)
|
||||
cConn = c
|
||||
cErr = err
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s, err := Server(right, serverCfg)
|
||||
sConn = s
|
||||
sErr = err
|
||||
}()
|
||||
wg.Wait()
|
||||
return cConn, sConn, cErr, sErr
|
||||
}
|
||||
|
||||
func TestHandshakeRoundTrip(t *testing.T) {
|
||||
clientID, serverID := newPair(t)
|
||||
cConn, sConn, cErr, sErr := runHandshakeOverPipe(t,
|
||||
&Config{LocalIdentity: clientID},
|
||||
&Config{LocalIdentity: serverID},
|
||||
)
|
||||
if cErr != nil || sErr != nil {
|
||||
t.Fatalf("handshake errors: client=%v server=%v", cErr, sErr)
|
||||
}
|
||||
defer cConn.Close()
|
||||
defer sConn.Close()
|
||||
|
||||
// The remote identities should be the peers'.
|
||||
cRemote := cConn.(*Conn).RemoteIdentity()
|
||||
sRemote := sConn.(*Conn).RemoteIdentity()
|
||||
if !cRemote.Equal(serverID.Public()) {
|
||||
t.Fatal("client did not see server identity")
|
||||
}
|
||||
if !sRemote.Equal(clientID.Public()) {
|
||||
t.Fatal("server did not see client identity")
|
||||
}
|
||||
|
||||
// Round-trip a payload in both directions. net.Pipe is synchronous
|
||||
// so writes must overlap with reads on the peer.
|
||||
payload := []byte("z-wing flies")
|
||||
wErr := make(chan error, 1)
|
||||
go func() { _, e := cConn.Write(payload); wErr <- e }()
|
||||
buf := make([]byte, len(payload))
|
||||
if _, err := sConn.Read(buf); err != nil {
|
||||
t.Fatalf("server read: %v", err)
|
||||
}
|
||||
if err := <-wErr; err != nil {
|
||||
t.Fatalf("client write: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, payload) {
|
||||
t.Fatalf("c->s: got %q want %q", buf, payload)
|
||||
}
|
||||
|
||||
go func() { _, _ = sConn.Write([]byte("ack")) }()
|
||||
buf = make([]byte, 3)
|
||||
if _, err := cConn.Read(buf); err != nil {
|
||||
t.Fatalf("client read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, []byte("ack")) {
|
||||
t.Fatalf("s->c: got %q want %q", buf, "ack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialListen(t *testing.T) {
|
||||
clientID, serverID := newPair(t)
|
||||
|
||||
ln, err := Listen("127.0.0.1:0", &Config{LocalIdentity: serverID})
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
type accepted struct {
|
||||
c net.Conn
|
||||
err error
|
||||
}
|
||||
acceptCh := make(chan accepted, 1)
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
acceptCh <- accepted{c: c, err: err}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cConn, err := Dial(ctx, ln.Addr().String(), &Config{LocalIdentity: clientID})
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer cConn.Close()
|
||||
|
||||
a := <-acceptCh
|
||||
if a.err != nil {
|
||||
t.Fatalf("accept: %v", a.err)
|
||||
}
|
||||
defer a.c.Close()
|
||||
|
||||
msg := []byte("hello over tcp")
|
||||
if _, err := cConn.Write(msg); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
buf := make([]byte, len(msg))
|
||||
if _, err := a.c.Read(buf); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, msg) {
|
||||
t.Fatalf("got %q want %q", buf, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityMismatch(t *testing.T) {
|
||||
clientID, serverID := newPair(t)
|
||||
_, otherID := newPair(t)
|
||||
|
||||
_, _, cErr, sErr := runHandshakeOverPipe(t,
|
||||
// Client expects a server identity that does not match.
|
||||
&Config{LocalIdentity: clientID, ExpectedRemote: otherID.Public()},
|
||||
&Config{LocalIdentity: serverID},
|
||||
)
|
||||
if !errors.Is(cErr, ErrIdentityMismatch) {
|
||||
t.Fatalf("client: want ErrIdentityMismatch, got %v", cErr)
|
||||
}
|
||||
// Server side may either succeed in writing then see the conn close,
|
||||
// or fail with a pipe error - we don't pin the exact behaviour.
|
||||
_ = sErr
|
||||
}
|
||||
|
||||
func TestForwardSecrecy(t *testing.T) {
|
||||
clientID, serverID := newPair(t)
|
||||
cConn, sConn, cErr, sErr := runHandshakeOverPipe(t,
|
||||
&Config{LocalIdentity: clientID},
|
||||
&Config{LocalIdentity: serverID},
|
||||
)
|
||||
if cErr != nil || sErr != nil {
|
||||
t.Fatalf("handshake: %v / %v", cErr, sErr)
|
||||
}
|
||||
defer cConn.Close()
|
||||
defer sConn.Close()
|
||||
|
||||
// The post-handshake Conn must not retain any handshake-scoped raw
|
||||
// key material in exported fields. The handshakeResult.keyI2R / keyR2I
|
||||
// slices are zeroed after AEADs are constructed (see newConn). We can
|
||||
// verify by introspecting the result via a fresh handshake and
|
||||
// inspecting keys-after-zero.
|
||||
res := &handshakeResult{
|
||||
shared: [XWingSharedSize]byte{},
|
||||
keyI2R: derive([]byte{1, 2, 3}, []byte("k1"), 32),
|
||||
keyR2I: derive([]byte{1, 2, 3}, []byte("k2"), 32),
|
||||
}
|
||||
left, right := net.Pipe()
|
||||
defer left.Close()
|
||||
defer right.Close()
|
||||
c, err := newConn(left, res, true)
|
||||
if err != nil {
|
||||
t.Fatalf("newConn: %v", err)
|
||||
}
|
||||
_ = c
|
||||
// After newConn the slices fed in should be zeroed.
|
||||
for i, b := range res.keyI2R {
|
||||
if b != 0 {
|
||||
t.Fatalf("keyI2R[%d] not zeroed: 0x%x", i, b)
|
||||
}
|
||||
}
|
||||
for i, b := range res.keyR2I {
|
||||
if b != 0 {
|
||||
t.Fatalf("keyR2I[%d] not zeroed: 0x%x", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSequencedNonces makes sure two messages in a row use distinct
|
||||
// nonces and decrypt independently.
|
||||
func TestSequencedNonces(t *testing.T) {
|
||||
clientID, serverID := newPair(t)
|
||||
cConn, sConn, cErr, sErr := runHandshakeOverPipe(t,
|
||||
&Config{LocalIdentity: clientID},
|
||||
&Config{LocalIdentity: serverID},
|
||||
)
|
||||
if cErr != nil || sErr != nil {
|
||||
t.Fatalf("handshake: %v / %v", cErr, sErr)
|
||||
}
|
||||
defer cConn.Close()
|
||||
defer sConn.Close()
|
||||
|
||||
for i, msg := range [][]byte{[]byte("one"), []byte("two"), []byte("three")} {
|
||||
wErr := make(chan error, 1)
|
||||
go func() { _, e := cConn.Write(msg); wErr <- e }()
|
||||
buf := make([]byte, len(msg))
|
||||
if _, err := sConn.Read(buf); err != nil {
|
||||
t.Fatalf("read %d: %v", i, err)
|
||||
}
|
||||
if err := <-wErr; err != nil {
|
||||
t.Fatalf("write %d: %v", i, err)
|
||||
}
|
||||
if !bytes.Equal(buf, msg) {
|
||||
t.Fatalf("msg %d: got %q want %q", i, buf, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user