mirror of
https://github.com/luxfi/zap.git
synced 2026-07-27 05:54:26 +00:00
zap: decomplect onto luxfi/pq.Mode gate
Drop local SecurityProfile/Profile* constants, ProfileFromString,
IsPostQuantum, IsPQAware, RequireAttestationForProfile,
ErrClassicalAuthForbidden. The strict-PQ Mode vocabulary lives in
github.com/luxfi/pq as the single source of truth; warp, dex, fhe,
evm, zap all dispatch through pq.ValidateMode against the
PQEvidencer interface.
Attestation now implements pq.PQEvidencer:
func (a *Attestation) HasPQEvidence() bool {
return a != nil && len(a.PubKey) > 0 && len(a.Sig) > 0
}
Tests integrate with pq.ValidateMode(pq.ModeStrictPQ, att, verify):
missing attestation under strict-PQ returns
pq.ErrClassicalAuthForbidden; verifier errors propagate via
errors.Is.
TranscriptHash + AttestationContext + TLSCertFingerprintFromBytes
unchanged — domain-separated SHAKE256-384 over the ZAP-PQ-V1
transcript stays the canonical channel-binding digest.
This commit is contained in:
@@ -4,7 +4,8 @@ go 1.26
|
||||
|
||||
require (
|
||||
github.com/luxfi/mdns v0.1.0
|
||||
golang.org/x/crypto v0.25.0
|
||||
github.com/luxfi/pq v1.0.3
|
||||
golang.org/x/crypto v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -12,8 +13,8 @@ require (
|
||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||
github.com/miekg/dns v1.1.62 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6
|
||||
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
|
||||
github.com/luxfi/mdns v0.1.0 h1:VB3mQcETc9j5SY1S6lAgFtuGr/rjWuDgPYnxS+OKWMQ=
|
||||
github.com/luxfi/mdns v0.1.0/go.mod h1:/3dheKVjUk2yiS/ocH1IDzeLXOIe+kpVsErIGDOZdiQ=
|
||||
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
|
||||
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
|
||||
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
||||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
@@ -11,8 +13,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
@@ -20,16 +22,16 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
|
||||
+48
-159
@@ -1,137 +1,66 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// pq_attestation.go — strict-PQ identity binding on ZAP handshakes.
|
||||
// pq_attestation.go — ZAP channel-binding ML-DSA-65 attestation.
|
||||
//
|
||||
// Go 1.26's crypto/tls ships X25519MLKEM768 / SecP256r1MLKEM768 /
|
||||
// SecP384r1MLKEM1024 as TLS 1.3 hybrid post-quantum key exchange
|
||||
// groups — enabled by default. ZAP's TLS-wrapped transport already
|
||||
// gets a quantum-secure session key OUT OF THE BOX via that
|
||||
// hybrid KEX.
|
||||
// Go 1.26's crypto/tls ships X25519MLKEM768 hybrid PQ key exchange
|
||||
// by default; every ZAP TLS transport gets a quantum-secure session
|
||||
// key out of the box. The remaining gap is identity: TLS cert
|
||||
// signatures are still classical ECDSA / Ed25519 because Go doesn't
|
||||
// yet ship ML-DSA cert sigs.
|
||||
//
|
||||
// What standard TLS does NOT yet ship is post-quantum certificate
|
||||
// signatures: the cert authenticating each TLS endpoint is still
|
||||
// classical ECDSA (or Ed25519 when Go ships it for TLS cert use).
|
||||
// A quantum adversary that compromises an ECDSA cert key can
|
||||
// impersonate the endpoint even though the session-key KEX was
|
||||
// post-quantum.
|
||||
// This file binds the TLS endpoint to the peer's strict-PQ identity
|
||||
// at the application layer. After the TLS handshake completes,
|
||||
// each peer sends an Attestation as the first ZAP message:
|
||||
//
|
||||
// This package closes that gap by binding the TLS cert to the
|
||||
// peer's post-quantum identity at the application layer: each
|
||||
// peer sends an Attestation as the first ZAP message after the
|
||||
// handshake completes. The attestation is a FIPS 204 ML-DSA-65
|
||||
// signature over a SHAKE256-384 transcript that includes:
|
||||
// Attestation = {
|
||||
// PubKey: peer's FIPS 204 ML-DSA-65 public key (1952 bytes),
|
||||
// Sig: mldsa65.Sign(privKey, TranscriptHash(ctx)),
|
||||
// }
|
||||
//
|
||||
// - the TLS cert fingerprint (so a stolen cert can't be paired
|
||||
// with a different PQ identity),
|
||||
// - the chain id (so a replay across chains fails),
|
||||
// - a per-session nonce (so a replay within a chain fails).
|
||||
// TranscriptHash = SHAKE256-384(
|
||||
// "ZAP-PQ-V1"
|
||||
// || TLS cert fingerprint (stolen cert can't be paired with a
|
||||
// different PQ identity)
|
||||
// || chain id (cross-chain replay protection)
|
||||
// || peer ML-KEM-768 pubkey (binds to the same KEM key the TLS
|
||||
// handshake used)
|
||||
// || timestamp (intra-chain replay window)
|
||||
// || per-session nonce (cross-session replay protection)
|
||||
// )
|
||||
//
|
||||
// The verifier checks the signature against the peer's PQ public
|
||||
// key AND that the public key is a member of the chain's
|
||||
// validator set. Either check failing closes the connection.
|
||||
// Profile dispatch routes through lux/pq.ValidateMode:
|
||||
//
|
||||
// This package is verifier-agnostic on purpose: the actual
|
||||
// ML-DSA-65 verification lives in luxfi/crypto/mldsa, but zap
|
||||
// avoids the dep (zap is at the bottom of the dependency tree
|
||||
// and shouldn't carry crypto transitively). Callers supply an
|
||||
// AttestationVerifier that wraps mldsa65.Verify.
|
||||
// profile, _ := pq.ModeFromString(cfg.ZAPProfile)
|
||||
// verify := func() error {
|
||||
// hash := zap.TranscriptHash(ctx)
|
||||
// return verifier(att.PubKey, att.Sig, hash[:])
|
||||
// }
|
||||
// if err := pq.ValidateMode(profile, att, verify); err != nil {
|
||||
// // strict-PQ refused a missing attestation, or the verifier
|
||||
// // returned a non-nil error
|
||||
// }
|
||||
//
|
||||
// Same gate, same sentinel, same mode vocabulary as lux/warp,
|
||||
// lx/dex, lux/fhe, luxfi/evm.
|
||||
|
||||
package zap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// SecurityProfile is the ZAP security posture a deployment pins.
|
||||
// Matches the shape used by lux/warp, lx/dex, lux/fhe, luxfi/evm
|
||||
// so downstream consumers see one vocabulary across every layer.
|
||||
type SecurityProfile int
|
||||
|
||||
const (
|
||||
// ProfileClassical accepts ZAP connections without a PQ
|
||||
// Attestation. TLS handshake is the only identity check.
|
||||
// Suitable for legacy deployments before any peer has
|
||||
// generated ML-DSA validator material.
|
||||
ProfileClassical SecurityProfile = iota
|
||||
|
||||
// ProfileHybrid validates an Attestation WHEN the peer sends
|
||||
// one, but accepts connections without one (falling back to
|
||||
// TLS identity alone with a stale-PQ warning). Safe migration
|
||||
// middle.
|
||||
ProfileHybrid
|
||||
|
||||
// ProfileStrictPQ REFUSES every ZAP connection whose peer
|
||||
// does NOT present a valid Attestation bound to the TLS cert
|
||||
// fingerprint. Canonical Liquid / strict Lux / strict Zoo
|
||||
// profile.
|
||||
ProfileStrictPQ
|
||||
)
|
||||
|
||||
// String returns the canonical wire name. Audit pipelines match
|
||||
// on these strings; renaming here breaks every downstream consumer.
|
||||
func (p SecurityProfile) String() string {
|
||||
switch p {
|
||||
case ProfileClassical:
|
||||
return "classical"
|
||||
case ProfileHybrid:
|
||||
return "hybrid"
|
||||
case ProfileStrictPQ:
|
||||
return "strict-pq"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsPostQuantum reports whether this profile REFUSES connections
|
||||
// missing a PQ Attestation. Only ProfileStrictPQ returns true.
|
||||
func (p SecurityProfile) IsPostQuantum() bool {
|
||||
return p == ProfileStrictPQ
|
||||
}
|
||||
|
||||
// IsPQAware reports whether this profile VALIDATES an attestation
|
||||
// when the peer presents one. Both ProfileHybrid and
|
||||
// ProfileStrictPQ return true; ProfileClassical ignores
|
||||
// attestations even when they're sent.
|
||||
func (p SecurityProfile) IsPQAware() bool {
|
||||
return p == ProfileHybrid || p == ProfileStrictPQ
|
||||
}
|
||||
|
||||
// ProfileFromString parses an operator-supplied profile string.
|
||||
// Refuses unknown values rather than defaulting.
|
||||
func ProfileFromString(s string) (SecurityProfile, error) {
|
||||
switch s {
|
||||
case "classical":
|
||||
return ProfileClassical, nil
|
||||
case "hybrid":
|
||||
return ProfileHybrid, nil
|
||||
case "strict-pq":
|
||||
return ProfileStrictPQ, nil
|
||||
default:
|
||||
return ProfileClassical, fmt.Errorf("zap: unknown profile %q (want classical|hybrid|strict-pq)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrClassicalAuthForbidden is returned when a strict-PQ ZAP
|
||||
// connection is missing a PQ Attestation. Name and shape match
|
||||
// lux/warp, lx/dex, luxfi/evm so audit pipelines can grep one
|
||||
// identifier across every strict-PQ refusal site in the system.
|
||||
var ErrClassicalAuthForbidden = errors.New(
|
||||
"zap: classical authentication forbidden under strict-PQ profile (PQ Attestation required)")
|
||||
|
||||
// Attestation is the wire shape a ZAP peer presents after the
|
||||
// TLS handshake completes. PubKey + Sig are opaque bytes from
|
||||
// zap's perspective; the AttestationVerifier owns the format
|
||||
// (FIPS 204 ML-DSA-65 pubkey 1952 bytes, signature 3293 bytes
|
||||
// for Liquid; the same wire format works for ML-DSA-87 with
|
||||
// different byte counts for high-value Zoo chains).
|
||||
// for Liquid; ML-DSA-87 with different byte counts for
|
||||
// high-value Zoo chains).
|
||||
type Attestation struct {
|
||||
// PubKey is the peer's strict-PQ public key. Verifier
|
||||
// PubKey is the peer's strict-PQ public key. The verifier
|
||||
// confirms membership in the chain's validator set BEFORE
|
||||
// trusting the signature.
|
||||
PubKey []byte
|
||||
@@ -139,6 +68,14 @@ type Attestation struct {
|
||||
Sig []byte
|
||||
}
|
||||
|
||||
// HasPQEvidence implements pq.PQEvidencer. A non-nil Attestation
|
||||
// with a non-empty Sig counts as evidence — the gate then
|
||||
// dispatches to the verifier, which actually checks the
|
||||
// signature + validator-set membership.
|
||||
func (a *Attestation) HasPQEvidence() bool {
|
||||
return a != nil && len(a.PubKey) > 0 && len(a.Sig) > 0
|
||||
}
|
||||
|
||||
// AttestationContext bundles the inputs a verifier needs to
|
||||
// rebuild the transcript hash. Same inputs on both peers; the
|
||||
// PQ signature anchors the binding.
|
||||
@@ -212,57 +149,9 @@ func TLSCertFingerprintFromBytes(certDER []byte) [32]byte {
|
||||
return sha256.Sum256(certDER)
|
||||
}
|
||||
|
||||
// AttestationVerifier validates a peer's PQ attestation. Callers
|
||||
// (e.g. lux/node, lux/kms) supply this — typically a one-line
|
||||
// wrapper over mldsa65.PublicKeyFromBytes(...).VerifySignature(...)
|
||||
// that ALSO confirms the public key is a member of the chain's
|
||||
// validator set.
|
||||
type AttestationVerifier func(pubKey, sig, transcriptHash []byte) error
|
||||
|
||||
// RequireAttestationForProfile is the single seam every ZAP
|
||||
// transport-init should call BEFORE trusting a connection's
|
||||
// peer identity.
|
||||
//
|
||||
// - ProfileClassical: returns nil regardless (TLS identity OK).
|
||||
// - ProfileHybrid: returns nil regardless — if the peer sent
|
||||
// an attestation, validate it via the verifier; if not, fall
|
||||
// back to TLS identity alone (with a stale-PQ warning logged
|
||||
// by the caller).
|
||||
// - ProfileStrictPQ: returns ErrClassicalAuthForbidden if the
|
||||
// attestation is nil. Otherwise calls the verifier; verifier
|
||||
// errors propagate.
|
||||
func RequireAttestationForProfile(
|
||||
profile SecurityProfile,
|
||||
att *Attestation,
|
||||
ctx *AttestationContext,
|
||||
verify AttestationVerifier,
|
||||
) error {
|
||||
if !profile.IsPQAware() {
|
||||
// Classical profile: never validate, never refuse.
|
||||
return nil
|
||||
}
|
||||
if att == nil {
|
||||
if profile.IsPostQuantum() {
|
||||
return ErrClassicalAuthForbidden
|
||||
}
|
||||
// Hybrid + nil attestation: accept, caller decides
|
||||
// whether to log a stale-PQ warning.
|
||||
return nil
|
||||
}
|
||||
if verify == nil {
|
||||
return errors.New("zap: nil AttestationVerifier supplied to RequireAttestationForProfile")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("zap: nil AttestationContext")
|
||||
}
|
||||
hash := TranscriptHash(ctx)
|
||||
return verify(att.PubKey, att.Sig, hash[:])
|
||||
}
|
||||
|
||||
// leftEncode is the SP 800-185 §2.3.1 left_encode operation —
|
||||
// length-prefix framing so concatenated fields can't be
|
||||
// ambiguously parsed. Local copy avoids a dep on luxfi/ids /
|
||||
// luxfi/consensus from a leaf package.
|
||||
// ambiguously parsed.
|
||||
func leftEncode(x uint64) []byte {
|
||||
if x == 0 {
|
||||
return []byte{0x01, 0x00}
|
||||
|
||||
+37
-121
@@ -6,64 +6,31 @@ package zap
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/pq"
|
||||
)
|
||||
|
||||
func TestSecurityProfile_StringAndPredicates(t *testing.T) {
|
||||
func TestAttestation_HasPQEvidence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
profile SecurityProfile
|
||||
wantString string
|
||||
wantPostQuantum bool
|
||||
wantPQAware bool
|
||||
name string
|
||||
att *Attestation
|
||||
want bool
|
||||
}{
|
||||
{ProfileClassical, "classical", false, false},
|
||||
{ProfileHybrid, "hybrid", false, true},
|
||||
{ProfileStrictPQ, "strict-pq", true, true},
|
||||
{SecurityProfile(99), "unknown", false, false},
|
||||
{"nil", nil, false},
|
||||
{"empty", &Attestation{}, false},
|
||||
{"pub-only", &Attestation{PubKey: []byte{0x01}}, false},
|
||||
{"sig-only", &Attestation{Sig: []byte{0x01}}, false},
|
||||
{"both", &Attestation{PubKey: []byte{0x01}, Sig: []byte{0x02}}, true},
|
||||
} {
|
||||
if tc.profile.String() != tc.wantString {
|
||||
t.Errorf("%d.String() = %q, want %q",
|
||||
tc.profile, tc.profile.String(), tc.wantString)
|
||||
}
|
||||
if tc.profile.IsPostQuantum() != tc.wantPostQuantum {
|
||||
t.Errorf("%s.IsPostQuantum() = %t, want %t",
|
||||
tc.profile, tc.profile.IsPostQuantum(), tc.wantPostQuantum)
|
||||
}
|
||||
if tc.profile.IsPQAware() != tc.wantPQAware {
|
||||
t.Errorf("%s.IsPQAware() = %t, want %t",
|
||||
tc.profile, tc.profile.IsPQAware(), tc.wantPQAware)
|
||||
got := tc.att.HasPQEvidence()
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: HasPQEvidence() = %t, want %t", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileFromString(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
want SecurityProfile
|
||||
wantErr bool
|
||||
}{
|
||||
{"classical", ProfileClassical, false},
|
||||
{"hybrid", ProfileHybrid, false},
|
||||
{"strict-pq", ProfileStrictPQ, false},
|
||||
{"", 0, true},
|
||||
{"PQ", 0, true},
|
||||
{"strict_pq", 0, true},
|
||||
} {
|
||||
got, err := ProfileFromString(tc.input)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ProfileFromString(%q) err=%v, wantErr=%t",
|
||||
tc.input, err, tc.wantErr)
|
||||
continue
|
||||
}
|
||||
if !tc.wantErr && got != tc.want {
|
||||
t.Errorf("ProfileFromString(%q) = %s, want %s",
|
||||
tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscriptHash_Determinism pins that the same inputs always
|
||||
// produce the same hash — required for verifier <-> signer
|
||||
// agreement on the digest.
|
||||
// TestTranscriptHash_Determinism pins same-inputs-same-output —
|
||||
// required for verifier ↔ signer agreement on the digest.
|
||||
func TestTranscriptHash_Determinism(t *testing.T) {
|
||||
ctx := &AttestationContext{
|
||||
TLSCertFingerprint: [32]byte{0x01, 0x02, 0x03},
|
||||
@@ -72,18 +39,14 @@ func TestTranscriptHash_Determinism(t *testing.T) {
|
||||
Timestamp: 1700000000,
|
||||
Nonce: [32]byte{0xde, 0xad, 0xbe, 0xef},
|
||||
}
|
||||
h1 := TranscriptHash(ctx)
|
||||
h2 := TranscriptHash(ctx)
|
||||
if h1 != h2 {
|
||||
if TranscriptHash(ctx) != TranscriptHash(ctx) {
|
||||
t.Fatal("TranscriptHash is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscriptHash_FieldDistinction pins that changing ANY
|
||||
// transcript field changes the digest. This is the property an
|
||||
// attacker can't bypass: they can't reuse a signature across
|
||||
// different chains, different sessions, or different cert
|
||||
// bindings.
|
||||
// transcript field changes the digest — an attacker can't reuse
|
||||
// a signature across different chains, sessions, or cert bindings.
|
||||
func TestTranscriptHash_FieldDistinction(t *testing.T) {
|
||||
base := &AttestationContext{
|
||||
TLSCertFingerprint: [32]byte{0x01},
|
||||
@@ -102,78 +65,31 @@ func TestTranscriptHash_FieldDistinction(t *testing.T) {
|
||||
} {
|
||||
ctx := *base
|
||||
mutate(&ctx)
|
||||
hm := TranscriptHash(&ctx)
|
||||
if hm == h0 {
|
||||
if TranscriptHash(&ctx) == h0 {
|
||||
t.Errorf("TranscriptHash unchanged after mutation %+v", ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAttestationForProfile_Classical(t *testing.T) {
|
||||
// Classical never refuses, never calls verifier.
|
||||
called := false
|
||||
verify := func(_, _, _ []byte) error { called = true; return nil }
|
||||
if err := RequireAttestationForProfile(ProfileClassical, nil, nil, verify); err != nil {
|
||||
t.Errorf("Classical refused nil attestation: %v", err)
|
||||
// TestValidateMode_StrictPQ pins integration with pq.ValidateMode:
|
||||
// the canonical strict-PQ gate refuses a missing attestation,
|
||||
// calls the verifier when one is present, and propagates errors
|
||||
// verbatim.
|
||||
func TestValidateMode_StrictPQ(t *testing.T) {
|
||||
// Missing attestation → ErrClassicalAuthForbidden.
|
||||
if err := pq.ValidateMode(pq.ModeStrictPQ, (*Attestation)(nil), nil); !errors.Is(err, pq.ErrClassicalAuthForbidden) {
|
||||
t.Errorf("StrictPQ accepted nil attestation: %v", err)
|
||||
}
|
||||
if err := RequireAttestationForProfile(ProfileClassical,
|
||||
&Attestation{PubKey: []byte("x"), Sig: []byte("y")}, nil, verify); err != nil {
|
||||
t.Errorf("Classical refused attestation: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("Classical called the verifier (should never)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAttestationForProfile_StrictPQRefusesMissing(t *testing.T) {
|
||||
verify := func(_, _, _ []byte) error { return nil }
|
||||
err := RequireAttestationForProfile(ProfileStrictPQ, nil, nil, verify)
|
||||
if !errors.Is(err, ErrClassicalAuthForbidden) {
|
||||
t.Errorf("StrictPQ accepted nil attestation: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAttestationForProfile_HybridAcceptsMissing(t *testing.T) {
|
||||
verify := func(_, _, _ []byte) error { return nil }
|
||||
if err := RequireAttestationForProfile(ProfileHybrid, nil, nil, verify); err != nil {
|
||||
t.Errorf("Hybrid refused nil attestation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAttestationForProfile_VerifierCalled(t *testing.T) {
|
||||
var seenPub, seenSig, seenHash []byte
|
||||
verify := func(p, s, h []byte) error {
|
||||
seenPub, seenSig, seenHash = p, s, h
|
||||
return nil
|
||||
}
|
||||
att := &Attestation{PubKey: []byte("pub"), Sig: []byte("sig")}
|
||||
ctx := &AttestationContext{TLSCertFingerprint: [32]byte{0x42}}
|
||||
expected := TranscriptHash(ctx)
|
||||
if err := RequireAttestationForProfile(ProfileStrictPQ, att, ctx, verify); err != nil {
|
||||
t.Fatalf("verifier returned err: %v", err)
|
||||
}
|
||||
if string(seenPub) != "pub" || string(seenSig) != "sig" {
|
||||
t.Errorf("verifier got pub=%q sig=%q", seenPub, seenSig)
|
||||
}
|
||||
if string(seenHash) != string(expected[:]) {
|
||||
t.Error("verifier got wrong transcript hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAttestationForProfile_VerifierErrorPropagates(t *testing.T) {
|
||||
want := errors.New("verifier failed")
|
||||
verify := func(_, _, _ []byte) error { return want }
|
||||
// Present attestation + verifier returning nil → accept.
|
||||
verify := pq.Verify(func() error { return nil })
|
||||
att := &Attestation{PubKey: []byte("p"), Sig: []byte("s")}
|
||||
ctx := &AttestationContext{}
|
||||
if err := RequireAttestationForProfile(ProfileStrictPQ, att, ctx, verify); !errors.Is(err, want) {
|
||||
if err := pq.ValidateMode(pq.ModeStrictPQ, att, verify); err != nil {
|
||||
t.Errorf("StrictPQ refused valid attestation: %v", err)
|
||||
}
|
||||
// Present attestation + verifier returning error → propagate.
|
||||
want := errors.New("verifier failed")
|
||||
verifyErr := pq.Verify(func() error { return want })
|
||||
if err := pq.ValidateMode(pq.ModeStrictPQ, att, verifyErr); !errors.Is(err, want) {
|
||||
t.Errorf("verifier error not propagated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAttestationForProfile_NilVerifier(t *testing.T) {
|
||||
att := &Attestation{PubKey: []byte("p"), Sig: []byte("s")}
|
||||
ctx := &AttestationContext{}
|
||||
if err := RequireAttestationForProfile(ProfileStrictPQ, att, ctx, nil); err == nil {
|
||||
t.Error("StrictPQ accepted nil verifier")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user