mirror of
https://github.com/luxfi/threshold.git
synced 2026-07-27 04:01:59 +00:00
threshold: extract TEE custody into optional luxfi/tee extension
TEE-backed threshold custody was a threshold-internal, fenced opt-in
surface reachable only from tests (never on the ZAP dispatcher wire).
Decomplect it out: the dealerless/permissionless signing path is the
production default with zero TEE dependency.
Removed from the threshold core:
- protocols/{mldsa,rlwe,slhdsa}-tee -> moved to github.com/luxfi/tee
- pkg/thresholdd/tee_options.go (cc/attest + go-sev-guest glue)
- the optional Sign_TEE / SetTEEBackend surface on the pulsar, corona
and magnetar dispatcher schemes (teeBackend fields + unwired-error
sentinels)
- magnetar's duplicate slhdsatee-typed strict-PQ gate
(profile/magnetarRefuseUnderStrictPQ/SetChainSecurityProfile/
SetCombinerPool/Combine_TEE/AttestCombinerMember/PoolCombineMember)
- the dispatcher-only TEE test files + their testdata
The permissionless keygen/sign/sign_ctx/verify paths are byte-unchanged.
The ONE canonical strict-PQ gate (profile.go RefuseUnderStrictPQ, applied
at Sign_Ctx_Profile) is retained; magnetar's second, TEE-typed gate is
gone -- one gate, one place. go mod tidy drops go-sev-guest AND luxfi/mpc
(both were TEE-only deps; the core had zero real imports of either).
Dependency direction is one-way: luxfi/tee depends on the cores
(pulsar/corona/magnetar/mpc), never the reverse.
Build + vet + dispatcher tests green; no consensus/mpc/corona consumer
referenced the removed surface.
This commit is contained in:
@@ -9,7 +9,6 @@ require (
|
||||
// Lux crypto stack - this is the HIGH-LEVEL orchestration layer
|
||||
// that consumes primitives from these packages (LP-5703, LP-5704)
|
||||
github.com/luxfi/crypto v1.19.17 // ECDSA, EdDSA, BLS curves
|
||||
github.com/luxfi/fhe v1.8.2 // FHE primitives for TFHE protocol
|
||||
github.com/luxfi/lattice/v7 v7.1.4 // Lattice ops for Corona (post-quantum) + GPU acceleration
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -31,130 +30,48 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.7.0 // indirect
|
||||
cloud.google.com/go/kms v1.27.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.9.0 // indirect
|
||||
cloud.google.com/go/secretmanager v1.18.0 // indirect
|
||||
filippo.io/age v1.3.1 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/ALTree/bigfloat v0.2.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/ansel1/merry v1.8.1 // indirect
|
||||
github.com/ansel1/merry/v2 v2.2.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cosmos/go-bip39 v1.0.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gemalto/flume v1.0.0 // indirect
|
||||
github.com/gemalto/kmip-go v0.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/logger v1.1.1 // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
||||
github.com/gorilla/rpc v1.2.1 // indirect
|
||||
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/luxfi/accel v1.1.9 // indirect
|
||||
github.com/luxfi/cache v1.2.1 // indirect
|
||||
github.com/luxfi/compress v0.0.5 // indirect
|
||||
github.com/luxfi/concurrent v0.0.3 // indirect
|
||||
github.com/luxfi/database v1.19.3 // indirect
|
||||
github.com/luxfi/hsm v1.1.3 // indirect
|
||||
github.com/luxfi/ids v1.2.9 // indirect
|
||||
github.com/luxfi/math v1.4.1 // indirect
|
||||
github.com/luxfi/math/big v0.1.0 // indirect
|
||||
github.com/luxfi/mdns v0.1.1 // indirect
|
||||
github.com/luxfi/mock v0.1.1 // indirect
|
||||
github.com/luxfi/pq v1.0.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/miekg/pkcs11 v1.1.1 // indirect
|
||||
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
|
||||
github.com/montanaflynn/stats v0.9.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rs/zerolog v1.35.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
google.golang.org/api v0.275.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/go-sev-guest v0.14.1
|
||||
github.com/luxfi/corona v0.7.6
|
||||
github.com/luxfi/lens v0.1.4
|
||||
github.com/luxfi/magnetar v1.2.0
|
||||
github.com/luxfi/metric v1.5.7
|
||||
github.com/luxfi/mpc v1.14.13
|
||||
github.com/luxfi/pulsar v1.1.1
|
||||
github.com/luxfi/zap v0.8.1
|
||||
)
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
package thresholdd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
coronaThreshold "github.com/luxfi/corona/threshold"
|
||||
|
||||
rlwetee "github.com/luxfi/threshold/protocols/rlwe-tee"
|
||||
)
|
||||
|
||||
// coronaScheme wires luxfi/corona/threshold (Ring-LWE post-quantum
|
||||
@@ -50,15 +46,8 @@ import (
|
||||
type coronaScheme struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*coronaSession
|
||||
|
||||
// teeBackend is the optional institutional-custody R-LWE signer
|
||||
// wired via SetTEEBackend. nil → Sign_TEE refuses.
|
||||
teeBackend *rlwetee.Signer
|
||||
}
|
||||
|
||||
// errCoronaTEEUnwired is returned by Sign_TEE when no TEE backend is registered.
|
||||
var errCoronaTEEUnwired = errors.New("corona tee sign: no TEE backend wired (call SetTEEBackend first)")
|
||||
|
||||
// coronaSession holds the in-process per-party key shares + group key
|
||||
// for a single (pubKeyHex) keygen output.
|
||||
type coronaSession struct {
|
||||
@@ -245,57 +234,3 @@ func (s *coronaScheme) Verify(p verifyParams) (verifyResult, error) {
|
||||
}
|
||||
return verifyResult{OK: coronaThreshold.VerifyBytes(gkBytes, string(msg), sigBytes)}, nil
|
||||
}
|
||||
|
||||
// SetTEEBackend wires a rlwetee.Signer as the institutional-custody
|
||||
// TEE-gated signing path. The default `corona.sign` procedure is
|
||||
// UNAFFECTED — it remains the permissionless trusted-dealer 2-round
|
||||
// threshold path.
|
||||
//
|
||||
// Passing nil clears the backend (subsequent Sign_TEE calls return
|
||||
// errCoronaTEEUnwired).
|
||||
func (s *coronaScheme) SetTEEBackend(b *rlwetee.Signer) {
|
||||
s.mu.Lock()
|
||||
s.teeBackend = b
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sign_TEE is the institutional-custody opt-in signing path. Mirrors
|
||||
// magnetarScheme.Sign_TEE; the inner primitive is corona Ring-LWE
|
||||
// via the rlwetee.Signer.
|
||||
//
|
||||
// Returns the corona-threshold-framed wire signature + the
|
||||
// SignReceipt audit signature bytes.
|
||||
func (s *coronaScheme) Sign_TEE(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
evidenceBytes []byte,
|
||||
rim, hardware, teePub [32]byte,
|
||||
verifyOpts []TEEVerifyOption,
|
||||
jobID [32]byte,
|
||||
msg []byte,
|
||||
) ([]byte, []byte, error) {
|
||||
s.mu.Lock()
|
||||
b := s.teeBackend
|
||||
s.mu.Unlock()
|
||||
if b == nil {
|
||||
return nil, nil, errCoronaTEEUnwired
|
||||
}
|
||||
|
||||
env := &rlwetee.Envelope{
|
||||
Kind: attestKindFromString(kind),
|
||||
EvidenceBytes: append([]byte(nil), evidenceBytes...),
|
||||
RIM: rim,
|
||||
Hardware: hardware,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: teeVerifyOptionsToAttest(verifyOpts),
|
||||
}
|
||||
|
||||
wire, receipt, err := b.Sign(ctx, env, jobID, msg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("corona tee sign: %w", err)
|
||||
}
|
||||
if receipt == nil {
|
||||
return nil, nil, fmt.Errorf("corona tee sign: nil receipt")
|
||||
}
|
||||
return wire, receipt.AuditSignature, nil
|
||||
}
|
||||
|
||||
+37
-378
@@ -2,7 +2,6 @@
|
||||
package thresholdd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
@@ -12,8 +11,6 @@ import (
|
||||
"sync"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
|
||||
slhdsatee "github.com/luxfi/threshold/protocols/slhdsa-tee"
|
||||
)
|
||||
|
||||
// magnetarScheme wires luxfi/magnetar (FIPS 205 SLH-DSA, hash-based
|
||||
@@ -30,96 +27,43 @@ import (
|
||||
// the same (message, group public key) — is pinned upstream by
|
||||
// TestMagnetar_Wire_FIPS205Verifiable.
|
||||
//
|
||||
// Magnetar architectural framing (v0.5+ CHANGELOG):
|
||||
//
|
||||
// - PRIMARY public-BFT primitive: per-validator standalone
|
||||
// SLH-DSA. Each validator holds its OWN keypair (sk_i, pk_i),
|
||||
// produces a single-party FIPS 205 signature σ_i, and the
|
||||
// consensus layer collects N signatures into a
|
||||
// ValidatorAggregateCert. NO DKG, NO dealer, NO aggregator-in-
|
||||
// TCB. This is the canonical v0.5 primary path.
|
||||
// - CUSTODY (TEE-required): magnetar.CombineWithSeedReconstruction
|
||||
// (v0.1 reveal-and-aggregate) — produces one FIPS 205-shaped σ
|
||||
// by reconstructing the master seed in aggregator memory. NOT
|
||||
// public-BFT-safe (the aggregator is in the TCB). Off the bus.
|
||||
//
|
||||
// What this dispatcher exposes:
|
||||
//
|
||||
// - The PRIMARY per-validator standalone path is the dispatcher
|
||||
// surface. Keygen generates `participants` independent SLH-DSA
|
||||
// keypairs; the dispatcher retains them all keyed by the FIRST
|
||||
// validator's MAGG-framed public-key hex; Sign returns the FIRST
|
||||
// validator's MAGS-framed signature.
|
||||
// - The wire bytes are byte-identical to single-party FIPS 205 on
|
||||
// the (msg, pk_0) pair — verifiable by any external party with
|
||||
// a FIPS 205 verifier and the documented MAGG / MAGS frame.
|
||||
// - The N-of-N collected-signatures form (ValidatorAggregateCert)
|
||||
// is not on the dispatcher surface today because the bus shape
|
||||
// (one signature per .sign call) doesn't naturally carry N
|
||||
// parallel signatures. That form is the consensus-layer's job;
|
||||
// embedders that need it call magnetar.BuildAggregateCert /
|
||||
// VerifyAggregateCert directly.
|
||||
// What this dispatcher exposes — the magnetar v0.5 PRIMARY public-BFT
|
||||
// primitive: per-validator standalone SLH-DSA. Each validator holds
|
||||
// its OWN keypair (sk_i, pk_i) and produces a single-party FIPS 205
|
||||
// signature σ_i; NO DKG, NO dealer, NO aggregator-in-TCB. Keygen
|
||||
// generates `participants` independent keypairs; the dispatcher
|
||||
// retains them all keyed by the FIRST validator's MAGG-framed
|
||||
// public-key hex, and Sign returns the FIRST validator's MAGS-framed
|
||||
// signature. The N-of-N collected-signatures form
|
||||
// (ValidatorAggregateCert) is the consensus layer's job; embedders
|
||||
// that need it call magnetar.BuildAggregateCert / VerifyAggregateCert
|
||||
// directly.
|
||||
//
|
||||
// Trust model:
|
||||
//
|
||||
// - On keygen: the dispatcher generates N independent per-
|
||||
// validator keypairs in-process. The N-1 non-signing keypairs
|
||||
// are retained alongside the canonical one so future bus
|
||||
// extensions (e.g. a parallel-slices cert form) can use them
|
||||
// without re-keygen. For the test surface here, the FIRST
|
||||
// validator is the canonical signer.
|
||||
// - On keygen: N independent per-validator keypairs generated
|
||||
// in-process. The N-1 non-signing keypairs are retained alongside
|
||||
// the canonical one so future bus extensions (e.g. a
|
||||
// parallel-slices cert form) can use them without re-keygen.
|
||||
// - On sign: a single FIPS 205 SignDeterministic call under the
|
||||
// first validator's seed (the magnetar.ValidatorSign primary
|
||||
// primitive). Output is byte-identical to circl/slhdsa output
|
||||
// on the same (sk, msg) pair — no ctx is bound (callers needing
|
||||
// ctx must use magnetar.Sign directly with the precompile ctx).
|
||||
// on the same (sk, msg) pair.
|
||||
// - On verify: stateless. magnetar.VerifyBytes(gkBytes, msg,
|
||||
// sigBytes). No per-session state is consulted; the supplied
|
||||
// MAGG bytes are the authority.
|
||||
//
|
||||
// TEE-attested institutional custody (the strict-PQ master-seed
|
||||
// reconstruction path) is the OPTIONAL luxfi/tee extension
|
||||
// (github.com/luxfi/tee/slhdsa-tee). This dispatcher carries no
|
||||
// dependency on it; the strict-PQ chain-profile gate it enforces is
|
||||
// the canonical resolver gate in profile.go (RefuseUnderStrictPQ),
|
||||
// applied once at Sign_Ctx_Profile.
|
||||
type magnetarScheme struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*magnetarSession
|
||||
|
||||
// teeBackend is the optional institutional-custody SLH-DSA
|
||||
// signer wired via SetTEEBackend. When nil, Sign_TEE refuses
|
||||
// with errMagnetarTEEUnwired. The default permissionless path
|
||||
// (Sign) is unaffected by the TEE backend's presence — UNLESS
|
||||
// the chain profile is strict-PQ, in which case Sign itself
|
||||
// refuses (see profile gate below).
|
||||
teeBackend *slhdsatee.Signer
|
||||
|
||||
// pool is the optional t-of-n attested-combiner pool. When the
|
||||
// chain profile is strict-PQ AND the pool is wired, Combine_TEE
|
||||
// routes through the pool's t-of-n agreement. When the pool is
|
||||
// nil but profile is strict-PQ, Combine_TEE refuses with
|
||||
// ErrMagnetarNoTEEAttestation.
|
||||
pool *slhdsatee.CombinerPool
|
||||
|
||||
// profile is the chain-security profile this dispatcher is
|
||||
// bound to. Default is ProfileLegacyCompat (commodity-host
|
||||
// strict-atom Combine is acceptable). Strict-PQ chains MUST
|
||||
// call SetChainSecurityProfile at boot to flip this value;
|
||||
// once flipped, the permissionless Sign path refuses with
|
||||
// ErrMagnetarNoTEEAttestation and only Sign_TEE / Combine_TEE
|
||||
// produce signatures.
|
||||
//
|
||||
// Hickey discipline: profile is ONE value in ONE place. The
|
||||
// gate is the single function magnetarRefuseUnderStrictPQ.
|
||||
// The dispatcher reads it the same way the precompile
|
||||
// contract.RefuseUnderStrictPQ helper reads its
|
||||
// StrictPQReporter: ONE function, ONE place, ONE canonical
|
||||
// refusal sentinel.
|
||||
profile slhdsatee.ChainSecurityProfile
|
||||
}
|
||||
|
||||
// errMagnetarTEEUnwired is returned by Sign_TEE when no TEE backend
|
||||
// has been registered via SetTEEBackend.
|
||||
var errMagnetarTEEUnwired = errors.New("magnetar tee sign: no TEE backend wired (call SetTEEBackend first)")
|
||||
|
||||
// errMagnetarPoolUnwired is returned by Combine_TEE when no combiner
|
||||
// pool has been registered via SetCombinerPool, regardless of profile.
|
||||
var errMagnetarPoolUnwired = errors.New("magnetar combine_tee: no combiner pool wired (call SetCombinerPool first)")
|
||||
|
||||
// magnetarSession holds the in-process per-validator keypairs for
|
||||
// one Keygen output. The canonical PublicKey for the session is the
|
||||
// MAGG-framed wire bytes of `keys[0].pk`; subsequent Sign calls
|
||||
@@ -224,62 +168,6 @@ func (s *magnetarScheme) Keygen(p keygenParams) (keygenResult, error) {
|
||||
return keygenResult{PublicKey: pkHex, Shares: shareIDs}, nil
|
||||
}
|
||||
|
||||
// magnetarRefuseUnderStrictPQ is the profile gate for the magnetar
|
||||
// dispatcher. ONE function, ONE place — mirrors
|
||||
// precompile/contract.RefuseUnderStrictPQ at the precompile layer.
|
||||
//
|
||||
// Returns slhdsatee.ErrMagnetarNoTEEAttestation when the dispatcher
|
||||
// is bound to ProfileStrictPQ. Returns nil otherwise.
|
||||
//
|
||||
// Called at the top of Sign / Sign_Ctx (the permissionless paths)
|
||||
// — under strict-PQ, the permissionless path is hard-refused; only
|
||||
// Sign_TEE / Combine_TEE can produce signatures because only those
|
||||
// paths route the master-seed reconstruction through an attested
|
||||
// TEE.
|
||||
//
|
||||
// Read under the dispatcher's mutex so SetChainSecurityProfile can
|
||||
// flip the gate atomically at boot.
|
||||
func (s *magnetarScheme) magnetarRefuseUnderStrictPQ() error {
|
||||
s.mu.Lock()
|
||||
p := s.profile
|
||||
s.mu.Unlock()
|
||||
if p == slhdsatee.ProfileStrictPQ {
|
||||
return slhdsatee.ErrMagnetarNoTEEAttestation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetChainSecurityProfile binds the dispatcher to one of the
|
||||
// canonical chain-security profiles. Strict-PQ chains MUST call
|
||||
// this at boot with slhdsatee.ProfileStrictPQ; the default value
|
||||
// (slhdsatee.ProfileLegacyCompat) preserves the commodity-host
|
||||
// permissionless Sign path.
|
||||
//
|
||||
// Idempotent — re-issuing the same profile is a no-op. Operators
|
||||
// rotating a chain into strict-PQ MUST follow the cascade:
|
||||
// - flip the chain profile in luxfi/node ChainConfig
|
||||
// - flip this dispatcher's profile via SetChainSecurityProfile
|
||||
// - wire a CombinerPool via SetCombinerPool (>= Threshold attested
|
||||
// members already provisioned).
|
||||
//
|
||||
// Until all three steps complete, the dispatcher will refuse Sign
|
||||
// AND Combine_TEE on the strict-PQ chain — fail-closed under
|
||||
// half-rotated state.
|
||||
func (s *magnetarScheme) SetChainSecurityProfile(p slhdsatee.ChainSecurityProfile) {
|
||||
s.mu.Lock()
|
||||
s.profile = p
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetCombinerPool wires the t-of-n attested-combiner pool. Required
|
||||
// for Combine_TEE under any profile; under strict-PQ, it is the
|
||||
// canonical sign surface.
|
||||
func (s *magnetarScheme) SetCombinerPool(p *slhdsatee.CombinerPool) {
|
||||
s.mu.Lock()
|
||||
s.pool = p
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sign produces a magnetar signature for the message under the
|
||||
// session's canonical (FIRST) validator keypair, frames it in the
|
||||
// MAGS wire codec, and returns the wire bytes as hex. Output is
|
||||
@@ -292,16 +180,7 @@ func (s *magnetarScheme) SetCombinerPool(p *slhdsatee.CombinerPool) {
|
||||
// intentionally omitted here (the bus shape does not carry one);
|
||||
// the published bytes verify under VerifyBytes with the same MAGG
|
||||
// public-key bytes plus the original message.
|
||||
//
|
||||
// Profile gate: when the dispatcher is bound to
|
||||
// slhdsatee.ProfileStrictPQ, Sign refuses with
|
||||
// ErrMagnetarNoTEEAttestation — the permissionless path is hard-
|
||||
// refused on strict-PQ chains. Callers MUST use Sign_TEE
|
||||
// (single-host attested) or Combine_TEE (t-of-n attested pool).
|
||||
func (s *magnetarScheme) Sign(p signParams) (signResult, error) {
|
||||
if err := s.magnetarRefuseUnderStrictPQ(); err != nil {
|
||||
return signResult{}, err
|
||||
}
|
||||
msg, err := hex.DecodeString(p.MessageHex)
|
||||
if err != nil {
|
||||
return signResult{}, fmt.Errorf("messageHex: %w", err)
|
||||
@@ -369,12 +248,6 @@ func (s *magnetarScheme) Sign(p signParams) (signResult, error) {
|
||||
// (or the empty hex string "") to bind the empty ctx — semantically
|
||||
// equivalent to Sign.
|
||||
//
|
||||
// Profile gate: same as Sign — refuses under strict-PQ with
|
||||
// ErrMagnetarNoTEEAttestation when the scheme has been bound to
|
||||
// slhdsatee.ProfileStrictPQ via SetChainSecurityProfile. Callers
|
||||
// MUST use Sign_TEE for ctx-bound institutional-custody signing on
|
||||
// strict-PQ chains.
|
||||
//
|
||||
// NOTE: this method does NOT consult the per-request chain-ID
|
||||
// resolver gate (RefuseUnderStrictPQ in profile.go). Callers that
|
||||
// reach this method via the ZAP dispatcher go through
|
||||
@@ -387,29 +260,15 @@ func (s *magnetarScheme) Sign_Ctx(p signCtxParams) (signResult, error) {
|
||||
|
||||
// Sign_Ctx_Profile is the magnetar profile-aware entry point.
|
||||
// Same shape as pulsar.Sign_Ctx_Profile: the per-request chain-ID
|
||||
// resolver gate fires first, then the scheme-bound slhdsatee gate
|
||||
// (inside signCtxInternal). Two orthogonal axes:
|
||||
// resolver gate (profile.go::RefuseUnderStrictPQ) fires at entry —
|
||||
// on a strict-PQ chain the call is refused with
|
||||
// ErrRefusedUnderStrictPQ, which the ZAP dispatcher surfaces as an
|
||||
// error response with strictPQ=true so the client can errors.Is the
|
||||
// sentinel; on any other profile (or with no resolver / no chain ID)
|
||||
// it falls through to signCtxInternal.
|
||||
//
|
||||
// - resolver gate: "does the request's chain ID resolve to
|
||||
// strict-PQ?" — refuses with ErrRefusedUnderStrictPQ which the
|
||||
// ZAP dispatcher surfaces as an error response with
|
||||
// strictPQ=true so the client can errors.Is the sentinel.
|
||||
// - slhdsatee scheme gate: "is THIS process bound to strict-PQ
|
||||
// via SetChainSecurityProfile?" — refuses with
|
||||
// ErrMagnetarNoTEEAttestation surfaced as a ZapErrCodeInternal
|
||||
// error. Pre-dates this gate; remains for back-compat with
|
||||
// operators who set the scheme profile but did not wire a
|
||||
// resolver.
|
||||
//
|
||||
// One function, one place: profile.go::RefuseUnderStrictPQ owns
|
||||
// the resolver policy; signCtxInternal owns the scheme-bound
|
||||
// policy. They compose without coupling.
|
||||
//
|
||||
// Removal contract: when the magnetar aggregate-cert ctx-bound
|
||||
// path lands AND signCtxInternal swaps to drive that path with NO
|
||||
// single-validator shortcut, the resolver gate here becomes dead
|
||||
// code. Sign_Ctx_Profile then collapses to
|
||||
// `return s.signCtxInternal(p)`.
|
||||
// One function, one place: profile.go::RefuseUnderStrictPQ owns the
|
||||
// strict-PQ policy; this method owns the call site.
|
||||
func (s *magnetarScheme) Sign_Ctx_Profile(p signCtxParams, resolver ChainProfileResolver) (signResult, error) {
|
||||
if err := RefuseUnderStrictPQ(p.ChainID, "magnetar.sign_ctx", resolver); err != nil {
|
||||
return signResult{}, err
|
||||
@@ -417,16 +276,11 @@ func (s *magnetarScheme) Sign_Ctx_Profile(p signCtxParams, resolver ChainProfile
|
||||
return s.signCtxInternal(p)
|
||||
}
|
||||
|
||||
// signCtxInternal is the actual ctx-bound sign path. Runs the
|
||||
// scheme-bound slhdsatee strict-PQ gate first (refuses with
|
||||
// ErrMagnetarNoTEEAttestation under SetChainSecurityProfile
|
||||
// strict-PQ), then drives magnetar.SignCtx on the canonical
|
||||
// single-validator keypair sess.keys[0].sk. Wire shape is
|
||||
// MAGS-framed FIPS 205 ctx-bound bytes.
|
||||
// signCtxInternal is the actual ctx-bound sign path. Drives
|
||||
// magnetar.SignCtx on the canonical single-validator keypair
|
||||
// sess.keys[0].sk. Wire shape is MAGS-framed FIPS 205 ctx-bound
|
||||
// bytes.
|
||||
func (s *magnetarScheme) signCtxInternal(p signCtxParams) (signResult, error) {
|
||||
if err := s.magnetarRefuseUnderStrictPQ(); err != nil {
|
||||
return signResult{}, err
|
||||
}
|
||||
msg, err := hex.DecodeString(p.MessageHex)
|
||||
if err != nil {
|
||||
return signResult{}, fmt.Errorf("messageHex: %w", err)
|
||||
@@ -452,8 +306,8 @@ func (s *magnetarScheme) signCtxInternal(p signCtxParams) (signResult, error) {
|
||||
params := magnetar.MustParamsFor(sess.mode)
|
||||
|
||||
// Deterministic (randomized=false, rng=nil) so the output is
|
||||
// KAT-shaped and byte-stable across retries — mirrors Sign and
|
||||
// Sign_TEE's SignDeterministic discipline.
|
||||
// KAT-shaped and byte-stable across retries — mirrors Sign's
|
||||
// SignDeterministic discipline.
|
||||
sig, err := magnetar.SignCtx(params, sess.keys[0].sk, msg, signCtx, false, nil)
|
||||
if err != nil {
|
||||
return signResult{}, fmt.Errorf("magnetar sign_ctx: %w", err)
|
||||
@@ -497,201 +351,6 @@ func (s *magnetarScheme) Verify(p verifyParams) (verifyResult, error) {
|
||||
return verifyResult{OK: magnetar.VerifyBytes(gkBytes, msg, sigBytes)}, nil
|
||||
}
|
||||
|
||||
// SetTEEBackend wires a slhdsatee.Signer as the institutional-custody
|
||||
// TEE-gated signing path. The default `magnetar.sign` procedure is
|
||||
// UNAFFECTED — it remains the permissionless per-validator standalone
|
||||
// path. Operators that need attested release must call SetTEEBackend
|
||||
// at boot and use Sign_TEE.
|
||||
//
|
||||
// Passing nil clears the backend (subsequent Sign_TEE calls return
|
||||
// errMagnetarTEEUnwired).
|
||||
func (s *magnetarScheme) SetTEEBackend(b *slhdsatee.Signer) {
|
||||
s.mu.Lock()
|
||||
s.teeBackend = b
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sign_TEE is the institutional-custody opt-in signing path. It
|
||||
// chains the supplied attestation evidence + RIM + hardware
|
||||
// fingerprint + TEE pubkey through the slhdsatee.Signer and emits
|
||||
// the MAGS-framed FIPS 205 wire bytes on success.
|
||||
//
|
||||
// Wire payload:
|
||||
//
|
||||
// - kind : attest.Kind ("sev_snp", "tdx", "nras")
|
||||
// - evidenceBytes : the vendor-framed quote / report
|
||||
// - rim : 32-byte operator-asserted RIM digest
|
||||
// - hardware : 32-byte hardware fingerprint
|
||||
// - teePub : 32-byte X25519 TEE public key
|
||||
// - jobID : 32-byte audit-binding identifier
|
||||
// - msg : message bytes
|
||||
// - signCtx : FIPS 205 §10.2 context (nil for empty)
|
||||
//
|
||||
// All inputs are required. The TEE backend (set via SetTEEBackend)
|
||||
// internally calls approval.ApproveIntent, kms.ReleaseGate.Issue /
|
||||
// Release, hsm.Provider.GetKey, and magnetar.Sign. Output bytes are
|
||||
// byte-identical to single-party FIPS 205 SignDeterministic on the
|
||||
// HSM-stored master seed.
|
||||
//
|
||||
// Returns the MAGS-framed wire signature + the SignReceipt's audit
|
||||
// signature bytes for the embedder's audit log; the receipt itself
|
||||
// (epoch, ephemeralPub, etc.) is intentionally not surfaced here —
|
||||
// this dispatcher returns only bytes that participate in verification.
|
||||
func (s *magnetarScheme) Sign_TEE(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
evidenceBytes []byte,
|
||||
rim, hardware, teePub [32]byte,
|
||||
verifyOpts []slhdsateeVerifyOpt,
|
||||
jobID [32]byte,
|
||||
msg []byte,
|
||||
signCtx []byte,
|
||||
) ([]byte, []byte, error) {
|
||||
s.mu.Lock()
|
||||
b := s.teeBackend
|
||||
s.mu.Unlock()
|
||||
if b == nil {
|
||||
return nil, nil, errMagnetarTEEUnwired
|
||||
}
|
||||
|
||||
env := &slhdsatee.Envelope{
|
||||
Kind: attestKindFromString(kind),
|
||||
EvidenceBytes: append([]byte(nil), evidenceBytes...),
|
||||
RIM: rim,
|
||||
Hardware: hardware,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: teeVerifyOptionsToAttest(verifyOpts),
|
||||
}
|
||||
|
||||
wire, receipt, err := b.Sign(ctx, env, jobID, msg, signCtx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("magnetar tee sign: %w", err)
|
||||
}
|
||||
if receipt == nil {
|
||||
return nil, nil, fmt.Errorf("magnetar tee sign: nil receipt")
|
||||
}
|
||||
return wire, receipt.AuditSignature, nil
|
||||
}
|
||||
|
||||
// PoolCombineMember names a single combiner participating in a
|
||||
// Combine_TEE call. The dispatcher carries the per-member attestation
|
||||
// payload through to the slhdsatee.CombinerPool.Combine call.
|
||||
type PoolCombineMember struct {
|
||||
// Name is the pool-registered member identifier
|
||||
// (matches CombinerPool.AddMember(name, ...)).
|
||||
Name string
|
||||
|
||||
// Kind / EvidenceBytes / RIM / Hardware / TEEPub mirror the
|
||||
// Sign_TEE payload shape. One PoolCombineMember per member
|
||||
// participating in the t-of-n quorum.
|
||||
Kind string
|
||||
EvidenceBytes []byte
|
||||
RIM [32]byte
|
||||
Hardware [32]byte
|
||||
TEEPub [32]byte
|
||||
}
|
||||
|
||||
// Combine_TEE is the canonical strict-PQ signing surface. It drives
|
||||
// the wired CombinerPool's t-of-n attested-combiner Sign and returns
|
||||
// the agreed wire bytes + per-member audit signatures.
|
||||
//
|
||||
// Hard requirements (no caveat path):
|
||||
//
|
||||
// - Pool MUST be wired via SetCombinerPool. errMagnetarPoolUnwired
|
||||
// otherwise.
|
||||
// - At least pool.Threshold() members MUST appear in `members` AND
|
||||
// pass the pool's freshness gate. Otherwise: slhdsatee.
|
||||
// ErrMagnetarInsufficientQuorum / ErrMagnetarStaleAttestation.
|
||||
// - All selected members MUST produce byte-identical wire output.
|
||||
// Divergence: slhdsatee.ErrMagnetarSignatureDivergence.
|
||||
//
|
||||
// Output: the consolidated wire signature (byte-equal across the
|
||||
// quorum) + the per-member audit-signature bytes concatenated as a
|
||||
// list. Embedders that need the full SignReceipt list call the pool
|
||||
// directly via SetCombinerPool / pool.Combine — the dispatcher
|
||||
// surface emits only the bytes that participate in verification
|
||||
// (the FIPS 205 wire) + the audit trail for control-plane logging.
|
||||
func (s *magnetarScheme) Combine_TEE(
|
||||
ctx context.Context,
|
||||
members []PoolCombineMember,
|
||||
verifyOpts []slhdsateeVerifyOpt,
|
||||
jobID [32]byte,
|
||||
msg []byte,
|
||||
signCtx []byte,
|
||||
) ([]byte, [][]byte, error) {
|
||||
s.mu.Lock()
|
||||
pool := s.pool
|
||||
s.mu.Unlock()
|
||||
if pool == nil {
|
||||
return nil, nil, errMagnetarPoolUnwired
|
||||
}
|
||||
|
||||
envs := make(map[string]*slhdsatee.Envelope, len(members))
|
||||
for _, m := range members {
|
||||
if m.Name == "" {
|
||||
return nil, nil, fmt.Errorf("magnetar combine_tee: member with empty Name")
|
||||
}
|
||||
envs[m.Name] = &slhdsatee.Envelope{
|
||||
Kind: attestKindFromString(m.Kind),
|
||||
EvidenceBytes: append([]byte(nil), m.EvidenceBytes...),
|
||||
RIM: m.RIM,
|
||||
Hardware: m.Hardware,
|
||||
TEEPub: m.TEEPub,
|
||||
VerifyOpts: teeVerifyOptionsToAttest(verifyOpts),
|
||||
}
|
||||
}
|
||||
|
||||
wire, receipts, err := pool.Combine(ctx, envs, jobID, msg, signCtx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("magnetar combine_tee: %w", err)
|
||||
}
|
||||
if len(receipts) == 0 {
|
||||
return nil, nil, fmt.Errorf("magnetar combine_tee: pool returned no receipts")
|
||||
}
|
||||
audits := make([][]byte, 0, len(receipts))
|
||||
for _, r := range receipts {
|
||||
if r == nil {
|
||||
return nil, nil, fmt.Errorf("magnetar combine_tee: pool returned nil receipt")
|
||||
}
|
||||
audits = append(audits, r.AuditSignature)
|
||||
}
|
||||
return wire, audits, nil
|
||||
}
|
||||
|
||||
// AttestCombinerMember refreshes one pool member's attestation
|
||||
// freshness state. Called by the operator's control plane out-of-
|
||||
// band of any sign call; the Combine_TEE path then reads the
|
||||
// freshness state without performing any KDS / PCS / NRAS network
|
||||
// roundtrip.
|
||||
//
|
||||
// The dispatcher proxies straight through to CombinerPool.Attest;
|
||||
// the call returns slhdsatee sentinels (ErrAttestationRequired etc.)
|
||||
// untouched so callers can errors.Is them.
|
||||
func (s *magnetarScheme) AttestCombinerMember(
|
||||
ctx context.Context,
|
||||
memberName string,
|
||||
kind string,
|
||||
evidenceBytes []byte,
|
||||
rim, hardware, teePub [32]byte,
|
||||
verifyOpts []slhdsateeVerifyOpt,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
pool := s.pool
|
||||
s.mu.Unlock()
|
||||
if pool == nil {
|
||||
return errMagnetarPoolUnwired
|
||||
}
|
||||
env := &slhdsatee.Envelope{
|
||||
Kind: attestKindFromString(kind),
|
||||
EvidenceBytes: append([]byte(nil), evidenceBytes...),
|
||||
RIM: rim,
|
||||
Hardware: hardware,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: teeVerifyOptionsToAttest(verifyOpts),
|
||||
}
|
||||
return pool.Attest(ctx, memberName, env)
|
||||
}
|
||||
|
||||
// magnetarSeededReader is a tiny SHA-256-counter deterministic byte
|
||||
// stream used to seed per-validator keygen inside the dispatcher.
|
||||
// It is NOT a CSPRNG; it is the dispatcher's internal mechanism for
|
||||
|
||||
@@ -1,548 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package thresholdd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
|
||||
slhdsatee "github.com/luxfi/threshold/protocols/slhdsa-tee"
|
||||
)
|
||||
|
||||
// magnetar_tee_gate_test.go exercises the strict-PQ chain profile
|
||||
// gate added at 2026-06-01 to close the magnetar v1.1 strict-atom
|
||||
// transient-SHAKE-bytes residual.
|
||||
//
|
||||
// The gate enforces:
|
||||
// 1. Default profile (legacy-compat): Sign / Sign_Ctx work as before;
|
||||
// Sign_TEE works when backend is wired; Combine_TEE works when
|
||||
// pool is wired.
|
||||
// 2. Strict-PQ profile: Sign / Sign_Ctx hard-refuse with
|
||||
// ErrMagnetarNoTEEAttestation; only Sign_TEE (single-host attested)
|
||||
// and Combine_TEE (t-of-n attested pool) produce signatures.
|
||||
// 3. Pool freshness: combiners outside rotation window are refused
|
||||
// with ErrMagnetarStaleAttestation.
|
||||
// 4. Pool quorum: at least Threshold attested combiners MUST produce
|
||||
// byte-identical output; insufficient or divergent → hard refusal.
|
||||
//
|
||||
// Fixture sharing: reuses sevSnpAttestationMilanDispatch /
|
||||
// sevSnpVcekMilanDispatch + dispatchKDSReplay() / dispatchFixedNow()
|
||||
// from tee_dispatcher_test.go (same package).
|
||||
|
||||
// gateMakeSigner constructs a single slhdsatee.Signer ready to drive
|
||||
// real SEV-SNP attestation against the committed Milan fixture.
|
||||
//
|
||||
// Shared across the pool tests: each pool member holds an independently
|
||||
// provisioned Signer (independent wrapped seed), so the pool-level
|
||||
// byte-equality check pins that two combiners with the SAME provisioned
|
||||
// seed produce the SAME signature.
|
||||
func gateMakeSigner(t *testing.T, seedBytes []byte) (*slhdsatee.Signer, *magnetar.PublicKey, [32]byte, [32]byte) {
|
||||
t.Helper()
|
||||
rim := realRIM()
|
||||
hw := realHardware()
|
||||
gate := dispatchMakeGate(t, rim, hw)
|
||||
hsmP := dispatchMakeFileHSM(t)
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed the wrapped master seed so Provision is deterministic
|
||||
// across pool members. Members holding the SAME seed produce
|
||||
// byte-identical signatures under SLH-DSA SignDeterministic.
|
||||
if len(seedBytes) != magnetar.MustParamsFor(magnetar.ModeM192s).SeedSize {
|
||||
t.Fatalf("gateMakeSigner: seed length %d != %d", len(seedBytes), magnetar.MustParamsFor(magnetar.ModeM192s).SeedSize)
|
||||
}
|
||||
if err := hsmP.StoreKey(context.Background(), "master-seed", seedBytes); err != nil {
|
||||
t.Fatalf("StoreKey master-seed: %v", err)
|
||||
}
|
||||
|
||||
cfg := slhdsatee.Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: false,
|
||||
}
|
||||
signer, err := slhdsatee.New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("slhdsatee.New: %v", err)
|
||||
}
|
||||
// Derive the published group public key from the same seed via
|
||||
// signer.PublicKey (HSM-backed) so the test's verification path
|
||||
// uses the canonical pk.
|
||||
pub, err := signer.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("signer.PublicKey: %v", err)
|
||||
}
|
||||
return signer, pub, rim, hw
|
||||
}
|
||||
|
||||
// gateMakeSharedSeed returns deterministic seed bytes so multiple pool
|
||||
// members can be constructed with the same master seed (pool-level
|
||||
// byte-equality check requires identical seed across members).
|
||||
func gateMakeSharedSeed(t *testing.T, label byte) []byte {
|
||||
t.Helper()
|
||||
params := magnetar.MustParamsFor(magnetar.ModeM192s)
|
||||
seed := make([]byte, params.SeedSize)
|
||||
for i := range seed {
|
||||
seed[i] = label ^ byte(i)
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
// TestMagnetarCombine_StrictPQProfile_RequiresTEE pins the central
|
||||
// gate: a dispatcher whose chain profile is strict-PQ MUST refuse the
|
||||
// permissionless Sign and Sign_Ctx paths with the canonical sentinel,
|
||||
// regardless of session state.
|
||||
func TestMagnetarCombine_StrictPQProfile_RequiresTEE(t *testing.T) {
|
||||
sch := newMagnetarScheme()
|
||||
|
||||
// Set up a Keygen session first so the gate is the only thing
|
||||
// that can refuse — otherwise an unknown-session error would
|
||||
// muddle the failure mode.
|
||||
kg, err := sch.Keygen(keygenParams{Threshold: 2, Participants: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("Keygen: %v", err)
|
||||
}
|
||||
msg := hex.EncodeToString([]byte("strict-pq-gate"))
|
||||
|
||||
// Permissive default profile — Sign succeeds.
|
||||
if _, err := sch.Sign(signParams{MessageHex: msg, PubKeyHex: kg.PublicKey}); err != nil {
|
||||
t.Fatalf("Sign under default profile: %v", err)
|
||||
}
|
||||
|
||||
// Flip to strict-PQ. Now both Sign and Sign_Ctx MUST refuse.
|
||||
sch.SetChainSecurityProfile(slhdsatee.ProfileStrictPQ)
|
||||
|
||||
_, err = sch.Sign(signParams{MessageHex: msg, PubKeyHex: kg.PublicKey})
|
||||
if !errors.Is(err, slhdsatee.ErrMagnetarNoTEEAttestation) {
|
||||
t.Fatalf("Sign under strict-PQ: expected ErrMagnetarNoTEEAttestation, got %v", err)
|
||||
}
|
||||
_, err = sch.Sign_Ctx(signCtxParams{MessageHex: msg, PubKeyHex: kg.PublicKey, CtxHex: hex.EncodeToString([]byte("lux-evm-precompile-slhdsa-v1"))})
|
||||
if !errors.Is(err, slhdsatee.ErrMagnetarNoTEEAttestation) {
|
||||
t.Fatalf("Sign_Ctx under strict-PQ: expected ErrMagnetarNoTEEAttestation, got %v", err)
|
||||
}
|
||||
|
||||
// Combine_TEE without wired pool: errMagnetarPoolUnwired.
|
||||
_, _, err = sch.Combine_TEE(context.Background(), nil, nil, [32]byte{}, []byte("x"), nil)
|
||||
if !errors.Is(err, errMagnetarPoolUnwired) {
|
||||
t.Fatalf("Combine_TEE unwired: expected errMagnetarPoolUnwired, got %v", err)
|
||||
}
|
||||
|
||||
// Flip back: Sign works again. Demonstrates profile is idempotent
|
||||
// + reversible (consensus rotation can flip back if needed).
|
||||
sch.SetChainSecurityProfile(slhdsatee.ProfileLegacyCompat)
|
||||
if _, err := sch.Sign(signParams{MessageHex: msg, PubKeyHex: kg.PublicKey}); err != nil {
|
||||
t.Fatalf("Sign after profile reset: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMagnetarCombine_AttestationVerified_AllowsSign drives the full
|
||||
// strict-PQ Combine_TEE path end-to-end against the committed AMD
|
||||
// Milan SEV-SNP fixture. Asserts:
|
||||
// - pool with t-of-n (2-of-3) members + fresh attestations succeeds
|
||||
// - output verifies under magnetar.VerifyBytes against the canonical
|
||||
// group public key (no caller awareness of the pool's existence)
|
||||
// - per-member audit signatures are non-empty
|
||||
func TestMagnetarCombine_AttestationVerified_AllowsSign(t *testing.T) {
|
||||
sharedSeed := gateMakeSharedSeed(t, 0xA1)
|
||||
|
||||
signer1, pub, rim, hw := gateMakeSigner(t, sharedSeed)
|
||||
signer2, _, _, _ := gateMakeSigner(t, sharedSeed)
|
||||
signer3, _, _, _ := gateMakeSigner(t, sharedSeed)
|
||||
|
||||
now := dispatchFixedNow()
|
||||
pool, err := slhdsatee.NewCombinerPool(slhdsatee.CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: 60 * time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1a", signer1); err != nil {
|
||||
t.Fatalf("AddMember 1a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1b", signer2); err != nil {
|
||||
t.Fatalf("AddMember 1b: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1c", signer3); err != nil {
|
||||
t.Fatalf("AddMember 1c: %v", err)
|
||||
}
|
||||
|
||||
sch := newMagnetarScheme()
|
||||
sch.SetChainSecurityProfile(slhdsatee.ProfileStrictPQ)
|
||||
sch.SetCombinerPool(pool)
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(now),
|
||||
}
|
||||
|
||||
// Attest all three combiner members.
|
||||
for _, name := range []string{"us-east-1a", "us-east-1b", "us-east-1c"} {
|
||||
if err := sch.AttestCombinerMember(context.Background(), name, "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("AttestCombinerMember %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if pool.FreshMemberCount() != 3 {
|
||||
t.Fatalf("FreshMemberCount = %d, want 3", pool.FreshMemberCount())
|
||||
}
|
||||
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("strict-pq-combine-verified")
|
||||
|
||||
members := []PoolCombineMember{
|
||||
{Name: "us-east-1a", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x21}},
|
||||
{Name: "us-east-1b", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x22}},
|
||||
{Name: "us-east-1c", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x23}},
|
||||
}
|
||||
|
||||
wire, audits, err := sch.Combine_TEE(context.Background(), members, verifyOpts, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Combine_TEE: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Combine_TEE returned empty wire")
|
||||
}
|
||||
if len(audits) < 2 {
|
||||
t.Fatalf("Combine_TEE returned %d audits, want >= 2", len(audits))
|
||||
}
|
||||
for i, a := range audits {
|
||||
if len(a) == 0 {
|
||||
t.Fatalf("Combine_TEE audit[%d] is empty", i)
|
||||
}
|
||||
}
|
||||
|
||||
// External verify path — no awareness of the pool's existence.
|
||||
gkBytes, err := magnetar.MarshalGroupKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalGroupKey: %v", err)
|
||||
}
|
||||
if !magnetar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("Combine_TEE output failed external VerifyBytes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMagnetarCombine_StaleAttestation_RejectsSign pins the freshness
|
||||
// gate: a pool member whose last attestation lies outside the rotation
|
||||
// window MUST NOT be selected, and the overall Combine_TEE MUST
|
||||
// refuse with ErrMagnetarStaleAttestation when insufficient fresh
|
||||
// members exist.
|
||||
func TestMagnetarCombine_StaleAttestation_RejectsSign(t *testing.T) {
|
||||
sharedSeed := gateMakeSharedSeed(t, 0xB2)
|
||||
|
||||
signer1, _, rim, hw := gateMakeSigner(t, sharedSeed)
|
||||
signer2, _, _, _ := gateMakeSigner(t, sharedSeed)
|
||||
signer3, _, _, _ := gateMakeSigner(t, sharedSeed)
|
||||
|
||||
// Use a mutable clock so we can advance time to age the
|
||||
// freshness state.
|
||||
clock := &mutableClock{now: dispatchFixedNow()}
|
||||
pool, err := slhdsatee.NewCombinerPool(slhdsatee.CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: 30 * time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: clock.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1a", signer1); err != nil {
|
||||
t.Fatalf("AddMember 1a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1b", signer2); err != nil {
|
||||
t.Fatalf("AddMember 1b: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1c", signer3); err != nil {
|
||||
t.Fatalf("AddMember 1c: %v", err)
|
||||
}
|
||||
|
||||
sch := newMagnetarScheme()
|
||||
sch.SetChainSecurityProfile(slhdsatee.ProfileStrictPQ)
|
||||
sch.SetCombinerPool(pool)
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(dispatchFixedNow()), // pin to fixture window
|
||||
}
|
||||
|
||||
// Attest all three at clock=now0.
|
||||
for _, name := range []string{"us-east-1a", "us-east-1b", "us-east-1c"} {
|
||||
if err := sch.AttestCombinerMember(context.Background(), name, "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("AttestCombinerMember %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the clock past the rotation window. All three members
|
||||
// are now stale; pool MUST refuse.
|
||||
clock.Advance(45 * time.Second)
|
||||
if pool.FreshMemberCount() != 0 {
|
||||
t.Fatalf("FreshMemberCount = %d after rotation, want 0", pool.FreshMemberCount())
|
||||
}
|
||||
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("strict-pq-stale-rejected")
|
||||
|
||||
members := []PoolCombineMember{
|
||||
{Name: "us-east-1a", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x21}},
|
||||
{Name: "us-east-1b", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x22}},
|
||||
{Name: "us-east-1c", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x23}},
|
||||
}
|
||||
|
||||
_, _, err = sch.Combine_TEE(context.Background(), members, verifyOpts, jobID, msg, nil)
|
||||
if !errors.Is(err, slhdsatee.ErrMagnetarStaleAttestation) {
|
||||
t.Fatalf("Combine_TEE on stale pool: expected ErrMagnetarStaleAttestation, got %v", err)
|
||||
}
|
||||
|
||||
// Re-attest two of the three members — that suffices for t=2 to
|
||||
// produce a signature. Demonstrates partial-rotation recovery.
|
||||
if err := sch.AttestCombinerMember(context.Background(), "us-east-1a", "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("Re-attest 1a: %v", err)
|
||||
}
|
||||
if err := sch.AttestCombinerMember(context.Background(), "us-east-1b", "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("Re-attest 1b: %v", err)
|
||||
}
|
||||
if pool.FreshMemberCount() != 2 {
|
||||
t.Fatalf("FreshMemberCount post-rotation = %d, want 2", pool.FreshMemberCount())
|
||||
}
|
||||
|
||||
// Now Combine_TEE succeeds with two fresh members (third stale).
|
||||
wire, _, err := sch.Combine_TEE(context.Background(), members, verifyOpts, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Combine_TEE after partial rotation: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Combine_TEE wire empty after partial rotation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMagnetarCombine_TwoOfThreeAttestedCombiners_MatchSig pins the
|
||||
// quorum robustness: a pool with 3 members but only 2 fresh attestations
|
||||
// MUST surface a signature when (a) the 2 fresh members agree
|
||||
// byte-for-byte and (b) the third stale member is not in the selected
|
||||
// quorum.
|
||||
func TestMagnetarCombine_TwoOfThreeAttestedCombiners_MatchSig(t *testing.T) {
|
||||
sharedSeed := gateMakeSharedSeed(t, 0xC3)
|
||||
|
||||
signer1, pub, rim, hw := gateMakeSigner(t, sharedSeed)
|
||||
signer2, _, _, _ := gateMakeSigner(t, sharedSeed)
|
||||
signer3, _, _, _ := gateMakeSigner(t, sharedSeed)
|
||||
|
||||
now := dispatchFixedNow()
|
||||
pool, err := slhdsatee.NewCombinerPool(slhdsatee.CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: 60 * time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1a", signer1); err != nil {
|
||||
t.Fatalf("AddMember 1a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1b", signer2); err != nil {
|
||||
t.Fatalf("AddMember 1b: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("us-east-1c", signer3); err != nil {
|
||||
t.Fatalf("AddMember 1c: %v", err)
|
||||
}
|
||||
|
||||
sch := newMagnetarScheme()
|
||||
sch.SetChainSecurityProfile(slhdsatee.ProfileStrictPQ)
|
||||
sch.SetCombinerPool(pool)
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(now),
|
||||
}
|
||||
|
||||
// Attest only 1a + 1b. 1c stays un-attested.
|
||||
for _, name := range []string{"us-east-1a", "us-east-1b"} {
|
||||
if err := sch.AttestCombinerMember(context.Background(), name, "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("AttestCombinerMember %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if pool.FreshMemberCount() != 2 {
|
||||
t.Fatalf("FreshMemberCount = %d, want 2", pool.FreshMemberCount())
|
||||
}
|
||||
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("strict-pq-2-of-3-attested-match")
|
||||
|
||||
members := []PoolCombineMember{
|
||||
{Name: "us-east-1a", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x21}},
|
||||
{Name: "us-east-1b", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x22}},
|
||||
{Name: "us-east-1c", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x23}},
|
||||
}
|
||||
|
||||
wire, audits, err := sch.Combine_TEE(context.Background(), members, verifyOpts, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Combine_TEE 2-of-3 attested: %v", err)
|
||||
}
|
||||
if len(audits) != 2 {
|
||||
t.Fatalf("audits count = %d, want 2 (only 1a + 1b attested)", len(audits))
|
||||
}
|
||||
|
||||
// Verifies under the canonical pub.
|
||||
gkBytes, err := magnetar.MarshalGroupKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalGroupKey: %v", err)
|
||||
}
|
||||
if !magnetar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("Combine_TEE 2-of-3 output failed external VerifyBytes")
|
||||
}
|
||||
|
||||
// Drop the threshold further by un-attesting 1b. Now only 1a is
|
||||
// fresh; quorum=2 must refuse.
|
||||
clock2 := &mutableClock{now: now}
|
||||
pool2, _ := slhdsatee.NewCombinerPool(slhdsatee.CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: 60 * time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: clock2.Now,
|
||||
})
|
||||
if _, err := pool2.AddMember("us-east-1a", signer1); err != nil {
|
||||
t.Fatalf("AddMember 1a (pool2): %v", err)
|
||||
}
|
||||
if _, err := pool2.AddMember("us-east-1b", signer2); err != nil {
|
||||
t.Fatalf("AddMember 1b (pool2): %v", err)
|
||||
}
|
||||
if _, err := pool2.AddMember("us-east-1c", signer3); err != nil {
|
||||
t.Fatalf("AddMember 1c (pool2): %v", err)
|
||||
}
|
||||
sch2 := newMagnetarScheme()
|
||||
sch2.SetChainSecurityProfile(slhdsatee.ProfileStrictPQ)
|
||||
sch2.SetCombinerPool(pool2)
|
||||
if err := sch2.AttestCombinerMember(context.Background(), "us-east-1a", "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("AttestCombinerMember pool2 1a: %v", err)
|
||||
}
|
||||
// With 1-of-3 attested and 2-of-3 NEVER attested, the pool surfaces
|
||||
// the freshness-class refusal (stale) because un-attested members
|
||||
// have LastIssuedAt == zero, which the pool treats as outside any
|
||||
// rotation window. Stale wins over insufficient when ANY would-be
|
||||
// quorum member's freshness has lapsed (or never started). Both
|
||||
// sentinels would have been acceptable; we pin the actual surface.
|
||||
_, _, err = sch2.Combine_TEE(context.Background(), members, verifyOpts, jobID, msg, nil)
|
||||
if !errors.Is(err, slhdsatee.ErrMagnetarStaleAttestation) && !errors.Is(err, slhdsatee.ErrMagnetarInsufficientQuorum) {
|
||||
t.Fatalf("Combine_TEE 1-of-3 attested: expected stale or insufficient-quorum sentinel, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMagnetarCombine_SignatureDivergence_HardRefusal pins the
|
||||
// byte-equality discipline: if two attested combiners disagree on the
|
||||
// produced bytes (corruption or compromise), the pool MUST refuse with
|
||||
// ErrMagnetarSignatureDivergence — no silent winner-picking.
|
||||
//
|
||||
// We exercise this by registering two members with DIFFERENT seeds —
|
||||
// SLH-DSA SignDeterministic over different seeds produces different
|
||||
// bytes, which the pool's byte-equality compare must reject.
|
||||
func TestMagnetarCombine_SignatureDivergence_HardRefusal(t *testing.T) {
|
||||
seedA := gateMakeSharedSeed(t, 0xD4)
|
||||
seedB := gateMakeSharedSeed(t, 0xE5)
|
||||
|
||||
signer1, _, rim, hw := gateMakeSigner(t, seedA)
|
||||
signer2, _, _, _ := gateMakeSigner(t, seedB) // DIFFERENT seed
|
||||
signer3, _, _, _ := gateMakeSigner(t, seedA)
|
||||
|
||||
now := dispatchFixedNow()
|
||||
pool, err := slhdsatee.NewCombinerPool(slhdsatee.CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: 60 * time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("alpha", signer1); err != nil {
|
||||
t.Fatalf("AddMember alpha: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("beta", signer2); err != nil {
|
||||
t.Fatalf("AddMember beta: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("gamma", signer3); err != nil {
|
||||
t.Fatalf("AddMember gamma: %v", err)
|
||||
}
|
||||
|
||||
sch := newMagnetarScheme()
|
||||
sch.SetChainSecurityProfile(slhdsatee.ProfileStrictPQ)
|
||||
sch.SetCombinerPool(pool)
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(now),
|
||||
}
|
||||
|
||||
// Attest alpha + beta only — Threshold=2, first 2 in name order
|
||||
// will be selected for the quorum. They hold different seeds, so
|
||||
// their wire output diverges.
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
if err := sch.AttestCombinerMember(context.Background(), name, "sev_snp",
|
||||
sevSnpAttestationMilanDispatch, rim, hw, [32]byte{0x11}, verifyOpts); err != nil {
|
||||
t.Fatalf("AttestCombinerMember %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("divergence-refused")
|
||||
|
||||
members := []PoolCombineMember{
|
||||
{Name: "alpha", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x21}},
|
||||
{Name: "beta", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x22}},
|
||||
{Name: "gamma", Kind: "sev_snp", EvidenceBytes: sevSnpAttestationMilanDispatch, RIM: rim, Hardware: hw, TEEPub: [32]byte{0x23}},
|
||||
}
|
||||
|
||||
_, _, err = sch.Combine_TEE(context.Background(), members, verifyOpts, jobID, msg, nil)
|
||||
if !errors.Is(err, slhdsatee.ErrMagnetarSignatureDivergence) {
|
||||
t.Fatalf("Combine_TEE on divergent quorum: expected ErrMagnetarSignatureDivergence, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mutableClock is a manually-advanced wall-clock used to age the
|
||||
// pool's freshness state.
|
||||
type mutableClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *mutableClock) Now() time.Time { return c.now }
|
||||
|
||||
func (c *mutableClock) Advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// dispatchMakeFileHSM_consolidated is referenced by the older
|
||||
// dispatcher tests via dispatchMakeFileHSM; included here for the
|
||||
// hsm.Provider import alias to satisfy the new test file's references.
|
||||
var _ hsm.Provider = (hsm.Provider)(nil)
|
||||
@@ -2,18 +2,14 @@
|
||||
package thresholdd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
|
||||
mldsatee "github.com/luxfi/threshold/protocols/mldsa-tee"
|
||||
)
|
||||
|
||||
// pulsarScheme wires luxfi/pulsar (Module-LWE FIPS 204 ML-DSA-65
|
||||
@@ -81,15 +77,8 @@ import (
|
||||
type pulsarScheme struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*pulsarSession
|
||||
|
||||
// teeBackend is the optional institutional-custody ML-DSA signer
|
||||
// wired via SetTEEBackend. nil → Sign_TEE refuses.
|
||||
teeBackend *mldsatee.Signer
|
||||
}
|
||||
|
||||
// errPulsarTEEUnwired is returned by Sign_TEE when no TEE backend is registered.
|
||||
var errPulsarTEEUnwired = errors.New("pulsar tee sign: no TEE backend wired (call SetTEEBackend first)")
|
||||
|
||||
// pulsarSession holds the in-process per-party state for a single
|
||||
// v0.3 algebraic-aggregate keygen output.
|
||||
type pulsarSession struct {
|
||||
@@ -323,9 +312,6 @@ func (s *pulsarScheme) Sign(p signParams) (signResult, error) {
|
||||
// quantified) master sk. The historical dealerKey single-party
|
||||
// shortcut has been deleted.
|
||||
//
|
||||
// Compare to Sign_TEE: same ctx semantics, HSM-held sk. Both
|
||||
// produce wire bytes verifiable under the same PULG-framed group key.
|
||||
//
|
||||
// signCtx is the FIPS 204 ctx octet string (0..255 bytes). Pass nil
|
||||
// (or the empty hex string "") to bind the empty ctx — backwards
|
||||
// compatible with v0.3 OrchestrateV03Sign byte-for-byte under the
|
||||
@@ -477,58 +463,3 @@ func (s *pulsarScheme) Verify(p verifyParams) (verifyResult, error) {
|
||||
}
|
||||
return verifyResult{OK: pulsar.VerifyBytes(gkBytes, msg, sigBytes)}, nil
|
||||
}
|
||||
|
||||
// SetTEEBackend wires a mldsatee.Signer as the institutional-custody
|
||||
// TEE-gated signing path. The default `pulsar.sign` procedure is
|
||||
// UNAFFECTED — it remains the permissionless v0.3 algebraic-aggregate
|
||||
// path.
|
||||
//
|
||||
// Passing nil clears the backend (subsequent Sign_TEE calls return
|
||||
// errPulsarTEEUnwired).
|
||||
func (s *pulsarScheme) SetTEEBackend(b *mldsatee.Signer) {
|
||||
s.mu.Lock()
|
||||
s.teeBackend = b
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sign_TEE is the institutional-custody opt-in signing path. Mirrors
|
||||
// magnetarScheme.Sign_TEE; the inner primitive is FIPS 204 ML-DSA via
|
||||
// the mldsatee.Signer.
|
||||
//
|
||||
// Returns the PULS-framed wire signature + the SignReceipt audit
|
||||
// signature bytes.
|
||||
func (s *pulsarScheme) Sign_TEE(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
evidenceBytes []byte,
|
||||
rim, hardware, teePub [32]byte,
|
||||
verifyOpts []TEEVerifyOption,
|
||||
jobID [32]byte,
|
||||
msg []byte,
|
||||
signCtx []byte,
|
||||
) ([]byte, []byte, error) {
|
||||
s.mu.Lock()
|
||||
b := s.teeBackend
|
||||
s.mu.Unlock()
|
||||
if b == nil {
|
||||
return nil, nil, errPulsarTEEUnwired
|
||||
}
|
||||
|
||||
env := &mldsatee.Envelope{
|
||||
Kind: attestKindFromString(kind),
|
||||
EvidenceBytes: append([]byte(nil), evidenceBytes...),
|
||||
RIM: rim,
|
||||
Hardware: hardware,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: teeVerifyOptionsToAttest(verifyOpts),
|
||||
}
|
||||
|
||||
wire, receipt, err := b.Sign(ctx, env, jobID, msg, signCtx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("pulsar tee sign: %w", err)
|
||||
}
|
||||
if receipt == nil {
|
||||
return nil, nil, fmt.Errorf("pulsar tee sign: nil receipt")
|
||||
}
|
||||
return wire, receipt.AuditSignature, nil
|
||||
}
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package thresholdd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sevtest "github.com/google/go-sev-guest/testing"
|
||||
"github.com/google/go-sev-guest/verify/trust"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
|
||||
coronaThreshold "github.com/luxfi/corona/threshold"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
|
||||
mldsatee "github.com/luxfi/threshold/protocols/mldsa-tee"
|
||||
rlwetee "github.com/luxfi/threshold/protocols/rlwe-tee"
|
||||
slhdsatee "github.com/luxfi/threshold/protocols/slhdsa-tee"
|
||||
)
|
||||
|
||||
// sevSnpAttestationMilan and sevSnpVcekMilan — committed AMD Milan
|
||||
// fixtures (same bytes as the lux/mpc cc/attest test corpus).
|
||||
//
|
||||
//go:embed testdata/sev_snp_attestation_milan.bin
|
||||
var sevSnpAttestationMilanDispatch []byte
|
||||
|
||||
//go:embed testdata/sev_snp_vcek_milan.cer
|
||||
var sevSnpVcekMilanDispatch []byte
|
||||
|
||||
func dispatchKDSReplay() trust.HTTPSGetter {
|
||||
return sevtest.SimpleGetter(map[string][]byte{
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/cert_chain": trust.AskArkMilanVcekBytes,
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/3ac3fe21e13fb0990eb28a802e3fb6a29483a6b0753590c951bdd3b8e53786184ca39e359669a2b76a1936776b564ea464cdce40c05f63c9b610c5068b006b5d?blSPL=2&teeSPL=0&snpSPL=5&ucodeSPL=68": sevSnpVcekMilanDispatch,
|
||||
})
|
||||
}
|
||||
|
||||
func dispatchFixedNow() time.Time {
|
||||
return time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func dispatchMakeFileHSM(t *testing.T) hsm.Provider {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p, err := hsm.NewFileProvider(&hsm.FileConfig{
|
||||
BasePath: dir,
|
||||
HexEncoded: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileProvider: %v", err)
|
||||
}
|
||||
var ed25519Seed [32]byte
|
||||
if _, err := rand.Read(ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("ed25519 seed: %v", err)
|
||||
}
|
||||
if err := p.StoreKey(context.Background(), "audit-key", ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("store audit: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = p.Close()
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
func dispatchMakeGate(t *testing.T, rim, hw [32]byte) *kms.LocalReleaseGate {
|
||||
t.Helper()
|
||||
policy := kms.NewReleasePolicy([][32]byte{rim}, [][32]byte{hw})
|
||||
policy.RequireSEVSNP = true
|
||||
var rootKey [32]byte
|
||||
if _, err := rand.Read(rootKey[:]); err != nil {
|
||||
t.Fatalf("rootKey: %v", err)
|
||||
}
|
||||
gate, err := kms.NewLocalReleaseGate(policy, kms.NewMemoryNonceStore(), rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalReleaseGate: %v", err)
|
||||
}
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
gate.SetReplayWindow(5 * time.Second)
|
||||
return gate
|
||||
}
|
||||
|
||||
// MPC_LOCAL_APPROVAL=true is exported by the package's existing
|
||||
// TestMain (thresholdd_test.go).
|
||||
|
||||
// realRIM / realHardware mirror the per-package helpers.
|
||||
func realRIM() [32]byte {
|
||||
return sha256.Sum256(sevSnpAttestationMilanDispatch[0x90 : 0x90+48])
|
||||
}
|
||||
|
||||
func realHardware() [32]byte {
|
||||
return sha256.Sum256(sevSnpAttestationMilanDispatch[0x1A0 : 0x1A0+64])
|
||||
}
|
||||
|
||||
// TestMagnetar_Sign_TEE_Dispatch wires a real slhdsatee.Signer into
|
||||
// the magnetar dispatcher, drives Sign_TEE end-to-end, and asserts
|
||||
// the returned wire bytes verify under magnetar.VerifyBytes against
|
||||
// the in-memory public key.
|
||||
func TestMagnetar_Sign_TEE_Dispatch(t *testing.T) {
|
||||
rim := realRIM()
|
||||
hw := realHardware()
|
||||
gate := dispatchMakeGate(t, rim, hw)
|
||||
hsmP := dispatchMakeFileHSM(t)
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev: %v", err)
|
||||
}
|
||||
|
||||
cfg := slhdsatee.Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: false, // single-flow test
|
||||
}
|
||||
signer, err := slhdsatee.New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("slhdsatee.New: %v", err)
|
||||
}
|
||||
pub, err := signer.Provision(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
|
||||
sch := newMagnetarScheme()
|
||||
|
||||
// Unwired Sign_TEE must refuse.
|
||||
_, _, err = sch.Sign_TEE(context.Background(), "sev_snp", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, [32]byte{}, nil, [32]byte{}, []byte("x"), nil)
|
||||
if !errors.Is(err, errMagnetarTEEUnwired) {
|
||||
t.Fatalf("expected errMagnetarTEEUnwired, got %v", err)
|
||||
}
|
||||
|
||||
sch.SetTEEBackend(signer)
|
||||
|
||||
var teePub [32]byte
|
||||
for i := range teePub {
|
||||
teePub[i] = byte(i + 17)
|
||||
}
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("dispatcher-tee-magnetar")
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(dispatchFixedNow()),
|
||||
}
|
||||
wire, audit, err := sch.Sign_TEE(context.Background(), "sev_snp", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, teePub, verifyOpts, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign_TEE: %v", err)
|
||||
}
|
||||
if len(wire) == 0 || len(audit) == 0 {
|
||||
t.Fatal("Sign_TEE returned empty payload")
|
||||
}
|
||||
|
||||
gkBytes, err := magnetar.MarshalGroupKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalGroupKey: %v", err)
|
||||
}
|
||||
if !magnetar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("Sign_TEE output failed external VerifyBytes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPulsar_Sign_TEE_Dispatch — same shape, FIPS 204 backend.
|
||||
func TestPulsar_Sign_TEE_Dispatch(t *testing.T) {
|
||||
rim := realRIM()
|
||||
hw := realHardware()
|
||||
gate := dispatchMakeGate(t, rim, hw)
|
||||
hsmP := dispatchMakeFileHSM(t)
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev: %v", err)
|
||||
}
|
||||
|
||||
cfg := mldsatee.Config{
|
||||
Mode: pulsar.ModeP65,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: false,
|
||||
}
|
||||
signer, err := mldsatee.New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("mldsatee.New: %v", err)
|
||||
}
|
||||
pub, err := signer.Provision(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
|
||||
sch := newPulsarScheme()
|
||||
|
||||
// Unwired refuse.
|
||||
_, _, err = sch.Sign_TEE(context.Background(), "sev_snp", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, [32]byte{}, nil, [32]byte{}, []byte("x"), nil)
|
||||
if !errors.Is(err, errPulsarTEEUnwired) {
|
||||
t.Fatalf("expected errPulsarTEEUnwired, got %v", err)
|
||||
}
|
||||
|
||||
sch.SetTEEBackend(signer)
|
||||
|
||||
var teePub [32]byte
|
||||
for i := range teePub {
|
||||
teePub[i] = byte(i + 17)
|
||||
}
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("dispatcher-tee-pulsar")
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(dispatchFixedNow()),
|
||||
}
|
||||
wire, audit, err := sch.Sign_TEE(context.Background(), "sev_snp", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, teePub, verifyOpts, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign_TEE: %v", err)
|
||||
}
|
||||
if len(wire) == 0 || len(audit) == 0 {
|
||||
t.Fatal("Sign_TEE returned empty payload")
|
||||
}
|
||||
gkBytes, err := pub.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("pub.MarshalBinary: %v", err)
|
||||
}
|
||||
if !pulsar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("Sign_TEE output failed external VerifyBytes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorona_Sign_TEE_Dispatch — same shape, corona Ring-LWE backend.
|
||||
func TestCorona_Sign_TEE_Dispatch(t *testing.T) {
|
||||
rim := realRIM()
|
||||
hw := realHardware()
|
||||
gate := dispatchMakeGate(t, rim, hw)
|
||||
hsmP := dispatchMakeFileHSM(t)
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev: %v", err)
|
||||
}
|
||||
|
||||
cfg := rlwetee.Config{
|
||||
Threshold: 2,
|
||||
Participants: 3,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-key",
|
||||
ApprovalRequired: false,
|
||||
}
|
||||
signer, err := rlwetee.New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("rlwetee.New: %v", err)
|
||||
}
|
||||
gk, err := signer.Provision(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
|
||||
sch := newCoronaScheme()
|
||||
|
||||
_, _, err = sch.Sign_TEE(context.Background(), "sev_snp", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, [32]byte{}, nil, [32]byte{}, []byte("x"))
|
||||
if !errors.Is(err, errCoronaTEEUnwired) {
|
||||
t.Fatalf("expected errCoronaTEEUnwired, got %v", err)
|
||||
}
|
||||
|
||||
sch.SetTEEBackend(signer)
|
||||
|
||||
var teePub [32]byte
|
||||
for i := range teePub {
|
||||
teePub[i] = byte(i + 17)
|
||||
}
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
msg := []byte("dispatcher-tee-corona")
|
||||
|
||||
verifyOpts := []TEEVerifyOption{
|
||||
WithKDSGetter(dispatchKDSReplay()),
|
||||
WithNow(dispatchFixedNow()),
|
||||
}
|
||||
wire, audit, err := sch.Sign_TEE(context.Background(), "sev_snp", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, teePub, verifyOpts, jobID, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign_TEE: %v", err)
|
||||
}
|
||||
if len(wire) == 0 || len(audit) == 0 {
|
||||
t.Fatal("Sign_TEE returned empty payload")
|
||||
}
|
||||
gkBytes, err := gk.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("gk.MarshalBinary: %v", err)
|
||||
}
|
||||
if !coronaThreshold.VerifyBytes(gkBytes, string(msg), wire) {
|
||||
t.Fatal("Sign_TEE output failed external VerifyBytes")
|
||||
}
|
||||
|
||||
// Defence: passing a custom kind not in our enum maps through to
|
||||
// cc/attest.Dispatch which returns ErrUnsupportedKind. The release
|
||||
// gate's VerifyEvidence step calls Dispatch; rlwetee.ErrPolicyRefused
|
||||
// is the canonical wrap so we assert that sentinel. The inner
|
||||
// ErrUnsupportedKind appears in err.Error() but isn't surfaced via
|
||||
// errors.Is because the gate joins via %v not %w (kms upstream API).
|
||||
var jobID2 [32]byte
|
||||
if _, err := rand.Read(jobID2[:]); err != nil {
|
||||
t.Fatalf("jobID2: %v", err)
|
||||
}
|
||||
_, _, err = sch.Sign_TEE(context.Background(), "unknown-kind", sevSnpAttestationMilanDispatch,
|
||||
rim, hw, teePub, verifyOpts, jobID2, msg)
|
||||
if err == nil {
|
||||
t.Fatal("Sign_TEE: expected error on unknown kind")
|
||||
}
|
||||
if !errors.Is(err, rlwetee.ErrPolicyRefused) {
|
||||
t.Errorf("Sign_TEE: err = %v, want wrapped rlwetee.ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package thresholdd
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-sev-guest/verify/trust"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
)
|
||||
|
||||
// Shared TEE-only options + helpers consumed by every scheme's
|
||||
// Sign_TEE method. Decomplecting: every TEE scheme (slhdsa, mldsa,
|
||||
// rlwe) consumes the same set of cc/attest.Option values, so we name
|
||||
// them once here rather than duplicate across each dispatcher file.
|
||||
|
||||
// slhdsateeVerifyOpt is the dispatcher-side option carrier used by
|
||||
// Sign_TEE. We expose this concrete type rather than the cc/attest
|
||||
// internal config so embedders that drive the dispatcher do not need
|
||||
// to import luxfi/mpc/cc/attest directly.
|
||||
//
|
||||
// The name is shared across slhdsa-tee / mldsa-tee / rlwe-tee
|
||||
// dispatcher entry points; the type-safe distinction the caller
|
||||
// needs is the value-builder (NewKDSReplay, WithNow, etc.), not the
|
||||
// scheme name.
|
||||
type slhdsateeVerifyOpt struct {
|
||||
// nowOverride pins the verifier wall clock. Tests use this to
|
||||
// stay inside a committed VCEK's validity window.
|
||||
nowOverride time.Time
|
||||
// kdsGetter installs a custom HTTPSGetter for SEV-SNP / TDX
|
||||
// chain fetch. Tests use this to replay pre-fetched bytes.
|
||||
kdsGetter trust.HTTPSGetter
|
||||
// expectedReportData pins the byte-level REPORT_DATA / REPORTDATA
|
||||
// field of the evidence (kind-specific 64-byte field on SEV/TDX,
|
||||
// JWT claim on NRAS). Production callers MUST bind their
|
||||
// gate-issued nonce here.
|
||||
expectedReportData []byte
|
||||
// expectedMeasurement pins a known-good launch digest. Tests
|
||||
// usually omit so the membership-check via the gate's
|
||||
// RequiredRIM allowlist is the authority.
|
||||
expectedMeasurement []byte
|
||||
}
|
||||
|
||||
// TEEVerifyOption is the public alias for the dispatcher's verify
|
||||
// option carrier. Embedders construct values via WithNow /
|
||||
// WithKDSGetter / WithExpectedReportData / WithExpectedMeasurement.
|
||||
type TEEVerifyOption = slhdsateeVerifyOpt
|
||||
|
||||
// WithNow pins the verification clock. Tests use this to stay inside
|
||||
// a committed VCEK / TDX-quote validity window.
|
||||
func WithNow(t time.Time) TEEVerifyOption {
|
||||
return slhdsateeVerifyOpt{nowOverride: t}
|
||||
}
|
||||
|
||||
// WithKDSGetter installs an HTTPSGetter override for the AMD KDS
|
||||
// (SEV-SNP) / Intel PCS (TDX) / NRAS JWKS endpoints.
|
||||
func WithKDSGetter(g trust.HTTPSGetter) TEEVerifyOption {
|
||||
return slhdsateeVerifyOpt{kdsGetter: g}
|
||||
}
|
||||
|
||||
// WithExpectedReportData pins the byte-level REPORT_DATA field of
|
||||
// the evidence. Production callers MUST bind their gate-issued
|
||||
// nonce here.
|
||||
func WithExpectedReportData(want []byte) TEEVerifyOption {
|
||||
buf := make([]byte, len(want))
|
||||
copy(buf, want)
|
||||
return slhdsateeVerifyOpt{expectedReportData: buf}
|
||||
}
|
||||
|
||||
// WithExpectedMeasurement pins a known-good launch digest beyond the
|
||||
// gate's RIM allowlist.
|
||||
func WithExpectedMeasurement(want []byte) TEEVerifyOption {
|
||||
buf := make([]byte, len(want))
|
||||
copy(buf, want)
|
||||
return slhdsateeVerifyOpt{expectedMeasurement: buf}
|
||||
}
|
||||
|
||||
// teeVerifyOptionsToAttest converts the dispatcher's options into the
|
||||
// underlying cc/attest.Option list. Splitting this out keeps the
|
||||
// per-scheme Sign_TEE implementations identical.
|
||||
func teeVerifyOptionsToAttest(opts []slhdsateeVerifyOpt) []attest.Option {
|
||||
out := make([]attest.Option, 0, len(opts))
|
||||
for _, o := range opts {
|
||||
if !o.nowOverride.IsZero() {
|
||||
out = append(out, attest.WithNow(o.nowOverride))
|
||||
}
|
||||
if o.kdsGetter != nil {
|
||||
out = append(out, attest.WithKDSGetter(o.kdsGetter))
|
||||
}
|
||||
if o.expectedReportData != nil {
|
||||
out = append(out, attest.WithExpectedReportData(o.expectedReportData))
|
||||
}
|
||||
if o.expectedMeasurement != nil {
|
||||
out = append(out, attest.WithExpectedMeasurement(o.expectedMeasurement))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attestKindFromString parses the wire-stable evidence-kind string
|
||||
// into the cc/attest.Kind enum. Unknown kinds map to the empty kind
|
||||
// which Dispatch rejects with ErrUnsupportedKind — the dispatcher
|
||||
// surface then surfaces that as a hard refusal.
|
||||
func attestKindFromString(s string) attest.Kind {
|
||||
switch s {
|
||||
case "sev_snp":
|
||||
return attest.KindSEVSNP
|
||||
case "tdx":
|
||||
return attest.KindTDX
|
||||
case "nras":
|
||||
return attest.KindNRAS
|
||||
default:
|
||||
return attest.Kind(s)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -1,40 +0,0 @@
|
||||
# mldsa-tee
|
||||
|
||||
Operator-controlled ML-DSA threshold signing via TEE-gated master-seed
|
||||
reconstruction.
|
||||
|
||||
## What this is
|
||||
|
||||
Sibling of `protocols/slhdsa-tee` for the FIPS 204 (ML-DSA / Dilithium)
|
||||
primitive. Output is byte-identical to single-party
|
||||
`pulsar.SignDeterministic` on the same seed-derived `sk`.
|
||||
|
||||
## When to use
|
||||
|
||||
- Institutional custody of ML-DSA signing keys with HSM-resident wrap.
|
||||
- IAM signing-as-a-service that requires attested release per operation.
|
||||
- Bridge oracles whose signing key is governed by an executive approval flow.
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Permissionless threshold custody: use `pulsar.OrchestrateV03Sign`
|
||||
(v0.3 AlgebraicAggregate) — no party ever holds the master sk.
|
||||
- Single-party / dev: use `pulsar.GenerateKey` + `pulsar.Sign`.
|
||||
|
||||
## Layering
|
||||
|
||||
```
|
||||
caller
|
||||
└── mldsatee.Signer.Sign(ctx, env, jobID, msg, signCtx)
|
||||
├── approval.ApprovalProvider.ApproveIntent
|
||||
├── kms.ReleaseGate.Issue / Release
|
||||
│ └── cc/attest.Dispatch
|
||||
├── hsm.Provider.GetKey
|
||||
├── pulsar.KeyFromSeed → pulsar.Sign
|
||||
└── hsm.Provider.Sign (audit)
|
||||
```
|
||||
|
||||
Each step is independently complete and replaceable. The default
|
||||
permissionless path (`pulsar.OrchestrateV03Sign`) remains the
|
||||
canonical surface; this TEE-extension is opt-in via the threshold
|
||||
dispatcher's `Sign_TEE` method.
|
||||
@@ -1,97 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mldsatee
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
)
|
||||
|
||||
// Config carries the operator-side policy + provider configuration.
|
||||
//
|
||||
// Every field is required; New refuses on any zero value. There is
|
||||
// no "default-friendly" path — institutional custody never starts
|
||||
// with an empty allowlist or an unset KMS root.
|
||||
type Config struct {
|
||||
// Mode pins the FIPS 204 parameter set. Production institutional
|
||||
// custody uses ModeP65 (recommended, NIST PQ category 3) or
|
||||
// ModeP87 for the most conservative posture. Mode is bound into
|
||||
// Signer.params at construction and into the keypair derived
|
||||
// from the unwrapped master seed.
|
||||
Mode pulsar.Mode
|
||||
|
||||
// RequiredRIM is the set of acceptable Reference-Integrity-Manifest
|
||||
// digests for the worker that holds the wrapped master seed.
|
||||
RequiredRIM map[[32]byte]struct{}
|
||||
|
||||
// AllowedHardware is the set of acceptable hardware-fingerprint
|
||||
// digests.
|
||||
AllowedHardware map[[32]byte]struct{}
|
||||
|
||||
// RequireSEVSNP / RequireTDX / RequireNVNRAS mirror
|
||||
// kms.ReleasePolicy.Require* — at least one MUST be true.
|
||||
RequireSEVSNP bool
|
||||
RequireTDX bool
|
||||
RequireNVNRAS bool
|
||||
|
||||
// KMSKeyID is the HSM key identifier used for the audit
|
||||
// signature over (jobID || msgDigest || epoch || RIM).
|
||||
KMSKeyID string
|
||||
|
||||
// WrappedSeedKeyID is the HSM-stored blob identifier for the
|
||||
// wrapped 32-byte master ML-DSA seed.
|
||||
WrappedSeedKeyID string
|
||||
|
||||
// ApprovalRequired determines whether ApprovalProvider must
|
||||
// produce a non-deny ApprovalSignature before Issue() is called.
|
||||
ApprovalRequired bool
|
||||
|
||||
// ApproverID is the canonical identifier (email, DID, KMS ARN)
|
||||
// whose approval is required.
|
||||
ApproverID string
|
||||
}
|
||||
|
||||
// Errors surfaced by Config.Validate and the Sign flow.
|
||||
var (
|
||||
ErrInvalidMode = errors.New("mldsa-tee: invalid pulsar mode")
|
||||
ErrEmptyRIM = errors.New("mldsa-tee: RequiredRIM must be non-empty (default-deny posture)")
|
||||
ErrEmptyHardware = errors.New("mldsa-tee: AllowedHardware must be non-empty (default-deny posture)")
|
||||
ErrNoRequireFlag = errors.New("mldsa-tee: at least one Require* TEE flag must be true")
|
||||
ErrMissingKMSKeyID = errors.New("mldsa-tee: KMSKeyID required for audit signature")
|
||||
ErrMissingSeedKeyID = errors.New("mldsa-tee: WrappedSeedKeyID required for HSM seed storage")
|
||||
ErrApproverMissing = errors.New("mldsa-tee: ApproverID required when ApprovalRequired is true")
|
||||
ErrApprovalDenied = errors.New("mldsa-tee: approval provider denied or returned mismatched signature")
|
||||
ErrAttestationRequired = errors.New("mldsa-tee: attestation envelope required")
|
||||
ErrPolicyRefused = errors.New("mldsa-tee: release gate refused")
|
||||
ErrKMSReleaseUnreachable = errors.New("mldsa-tee: release gate unreachable")
|
||||
ErrHSMUnreachable = errors.New("mldsa-tee: HSM provider unreachable")
|
||||
ErrCorruptWrappedSeed = errors.New("mldsa-tee: wrapped seed blob fails authenticated decryption")
|
||||
)
|
||||
|
||||
// Validate reports the first structural error in cfg.
|
||||
func (cfg *Config) Validate() error {
|
||||
if _, err := pulsar.ParamsFor(cfg.Mode); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidMode, err)
|
||||
}
|
||||
if len(cfg.RequiredRIM) == 0 {
|
||||
return ErrEmptyRIM
|
||||
}
|
||||
if len(cfg.AllowedHardware) == 0 {
|
||||
return ErrEmptyHardware
|
||||
}
|
||||
if !cfg.RequireSEVSNP && !cfg.RequireTDX && !cfg.RequireNVNRAS {
|
||||
return ErrNoRequireFlag
|
||||
}
|
||||
if cfg.KMSKeyID == "" {
|
||||
return ErrMissingKMSKeyID
|
||||
}
|
||||
if cfg.WrappedSeedKeyID == "" {
|
||||
return ErrMissingSeedKeyID
|
||||
}
|
||||
if cfg.ApprovalRequired && cfg.ApproverID == "" {
|
||||
return ErrApproverMissing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mldsatee
|
||||
|
||||
import "golang.org/x/crypto/curve25519"
|
||||
|
||||
// curve25519BasepointMul is a test-only helper.
|
||||
func curve25519BasepointMul(priv []byte) ([]byte, error) {
|
||||
return curve25519.X25519(priv, curve25519.Basepoint)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package mldsatee implements operator-controlled ML-DSA threshold
|
||||
// signing via TEE-gated master-seed reconstruction.
|
||||
//
|
||||
// This is NOT a public-BFT primitive. Pulsar v0.3 AlgebraicAggregate
|
||||
// is the canonical permissionless threshold path; THIS package is the
|
||||
// institutional-custody-only extension that binds key release to:
|
||||
//
|
||||
// 1. a verifiable hardware TEE attestation (AMD SEV-SNP / Intel TDX /
|
||||
// NVIDIA NRAS) chain-validated by github.com/luxfi/mpc/cc/attest;
|
||||
// 2. a KMS release-gate (github.com/luxfi/mpc/pkg/kms.ReleaseGate)
|
||||
// that pins the worker's RIM digest + hardware fingerprint and
|
||||
// binds a single-use challenge nonce per-request;
|
||||
// 3. an out-of-band human / programmatic approval signature
|
||||
// (github.com/luxfi/mpc/pkg/approval.ApprovalProvider);
|
||||
// 4. an HSM-resident wrap-key store
|
||||
// (github.com/luxfi/mpc/pkg/hsm.Provider) so the master ML-DSA
|
||||
// seed (32 bytes, FIPS 204) lives sealed-at-rest and is only
|
||||
// ever unwrapped inside the attested TEE.
|
||||
//
|
||||
// The sign call returns bytes byte-identical to single-party FIPS 204
|
||||
// ML-DSA SignDeterministic on (master_seed → KeyFromSeed → Sign(msg,
|
||||
// ctx)). Any caller holding the published PULG-framed group public
|
||||
// key can verify with pulsar.VerifyBytes (or Verify) — no awareness
|
||||
// of the threshold or TEE substrate is required.
|
||||
//
|
||||
// Threat model:
|
||||
//
|
||||
// - Adversary controls the operator process (compromised binary,
|
||||
// malicious operator) outside the TEE. Without a valid attestation
|
||||
// that chains to the pinned vendor root AND a fresh approval that
|
||||
// matches the RIM/hardware policy, no sign is possible. The HSM
|
||||
// never releases the master seed in plaintext — only the AEAD
|
||||
// ciphertext sealed to the gate-issued ephemeral pubkey can leave
|
||||
// the gate.
|
||||
// - Adversary recovers an old sealed key. AAD binds (epoch, jobID,
|
||||
// teePub, issuedNonce); replay across epoch or jobID is refused.
|
||||
// - Adversary forges an attestation envelope whose Verify(nonce)
|
||||
// returns true but whose evidence does not chain to the vendor
|
||||
// root. ReleaseGate.Release calls CompositeAttestation.VerifyEvidence
|
||||
// which dispatches every blob through cc/attest.Dispatch and
|
||||
// refuses on chain-invalid.
|
||||
//
|
||||
// What this package is NOT:
|
||||
//
|
||||
// - NOT a no-trusted-dealer DKG. The master seed is generated once
|
||||
// under TEE attestation; subsequent signs only release the wrapped
|
||||
// seed under the same attestation policy. The permissionless DKG
|
||||
// construction for ML-DSA is pulsar v1.0.23 AlgebraicAggregate —
|
||||
// see pulsar/ref/go/pkg/pulsar/threshold.go.
|
||||
//
|
||||
// - NOT a substitute for pulsar.OrchestrateV03Sign on the public-BFT
|
||||
// surface. Use this ONLY when the threat model permits "trusted
|
||||
// custody with attested release" (e.g. M-Chain bridge custody,
|
||||
// A-Chain confidential compute oracle, IAM signing-as-a-service).
|
||||
//
|
||||
// Wire compatibility: output is a pulsar.Signature (mode default
|
||||
// ModeP65) — the same wire form the dispatcher emits today. The SDK /
|
||||
// verifier path is unchanged.
|
||||
package mldsatee
|
||||
@@ -1,95 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mldsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Envelope is the minimal kms.CompositeAttestation implementation
|
||||
// consumed by this package. Mirrors slhdsa-tee.Envelope in shape so
|
||||
// embedders that compose both primitives can share the same plumbing.
|
||||
type Envelope struct {
|
||||
Kind attest.Kind
|
||||
EvidenceBytes []byte
|
||||
ExpectedNonce [32]byte
|
||||
RIM [32]byte
|
||||
Hardware [32]byte
|
||||
TEEPub [32]byte
|
||||
VerifyOpts []attest.Option
|
||||
}
|
||||
|
||||
var _ kms.CompositeAttestation = (*Envelope)(nil)
|
||||
|
||||
// Verify implements kms.CompositeAttestation.Verify.
|
||||
func (e *Envelope) Verify(expectedNonce [32]byte) (bool, error) {
|
||||
if subtle.ConstantTimeCompare(e.ExpectedNonce[:], expectedNonce[:]) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyEvidence implements kms.CompositeAttestation.VerifyEvidence.
|
||||
//
|
||||
// Dispatches the single evidence blob to cc/attest, returns the
|
||||
// verified report on success, surfaces any chain / signature /
|
||||
// policy failure verbatim. Additionally pins the operator-asserted
|
||||
// RIM to sha256(measurement) of the verified report.
|
||||
func (e *Envelope) VerifyEvidence(ctx context.Context, opts ...attest.Option) ([]*attest.VerifiedReport, error) {
|
||||
if len(e.EvidenceBytes) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty evidence", attest.ErrInvalidEvidence)
|
||||
}
|
||||
allOpts := append([]attest.Option{}, e.VerifyOpts...)
|
||||
allOpts = append(allOpts, opts...)
|
||||
rep, err := attest.Dispatch(ctx, e.Kind, e.EvidenceBytes, allOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := defaultRIMCheck(rep, e.RIM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*attest.VerifiedReport{rep}, nil
|
||||
}
|
||||
|
||||
// RIMDigest implements kms.CompositeAttestation.RIMDigest.
|
||||
func (e *Envelope) RIMDigest() [32]byte { return e.RIM }
|
||||
|
||||
// HardwareFingerprint implements kms.CompositeAttestation.HardwareFingerprint.
|
||||
func (e *Envelope) HardwareFingerprint() [32]byte { return e.Hardware }
|
||||
|
||||
// TEEPublicKey implements kms.CompositeAttestation.TEEPublicKey.
|
||||
func (e *Envelope) TEEPublicKey() [32]byte { return e.TEEPub }
|
||||
|
||||
// EvidenceIssuers implements kms.CompositeAttestation.EvidenceIssuers.
|
||||
func (e *Envelope) EvidenceIssuers() []string {
|
||||
switch e.Kind {
|
||||
case attest.KindSEVSNP:
|
||||
return []string{kms.IssuerSEVSNP}
|
||||
case attest.KindTDX:
|
||||
return []string{kms.IssuerTDX}
|
||||
case attest.KindNRAS:
|
||||
return []string{kms.IssuerNVNRAS}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRIMCheck verifies the cc/attest verified-report Measurement
|
||||
// matches the operator-asserted RIM digest under sha256-folding.
|
||||
func defaultRIMCheck(rep *attest.VerifiedReport, expected [32]byte) error {
|
||||
if rep == nil {
|
||||
return errors.New("mldsa-tee: defaultRIMCheck: nil verified report")
|
||||
}
|
||||
got := sha256.Sum256(rep.Measurement)
|
||||
if subtle.ConstantTimeCompare(got[:], expected[:]) != 1 {
|
||||
return fmt.Errorf("%w: report measurement does not fold to operator-asserted RIM", attest.ErrPolicy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mldsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
)
|
||||
|
||||
// Sign produces a FIPS 204 ML-DSA signature on msg, gated by the
|
||||
// supplied attestation Envelope. Same flow as slhdsa-tee.Signer.Sign;
|
||||
// differs only in the inner primitive (pulsar instead of magnetar).
|
||||
//
|
||||
// Output is the PULS-framed wire bytes (via Signature.MarshalBinary)
|
||||
// — byte-identical to single-party FIPS 204 SignDeterministic on the
|
||||
// same (seed-derived sk, msg, ctx).
|
||||
func (s *Signer) Sign(ctx context.Context, env *Envelope, jobID [32]byte, msg []byte, signCtx []byte) ([]byte, *SignReceipt, error) {
|
||||
if env == nil {
|
||||
return nil, nil, ErrAttestationRequired
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
return nil, nil, fmt.Errorf("mldsa-tee: empty message")
|
||||
}
|
||||
|
||||
sealed, err := s.auditedRelease(ctx, env, jobID, msg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
raw, err := s.hsmP.GetKey(ctx, s.cfg.WrappedSeedKeyID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: HSM GetKey: %v", ErrHSMUnreachable, err)
|
||||
}
|
||||
defer zeroize(raw)
|
||||
|
||||
if len(raw) != pulsar.SeedSize {
|
||||
return nil, nil, fmt.Errorf("%w: HSM-stored seed length %d does not match pulsar.SeedSize %d",
|
||||
ErrCorruptWrappedSeed, len(raw), pulsar.SeedSize)
|
||||
}
|
||||
var seed [pulsar.SeedSize]byte
|
||||
copy(seed[:], raw)
|
||||
defer zeroizeArr(&seed)
|
||||
|
||||
sk, err := pulsar.KeyFromSeed(s.params, seed)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: KeyFromSeed: %v", ErrCorruptWrappedSeed, err)
|
||||
}
|
||||
defer zeroize(sk.Bytes)
|
||||
defer zeroizeArr(&sk.Seed)
|
||||
|
||||
sig, err := pulsar.Sign(s.params, sk, msg, signCtx, false /*deterministic*/, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("mldsa-tee: pulsar.Sign: %w", err)
|
||||
}
|
||||
|
||||
// Self-verify safety belt.
|
||||
if err := pulsar.Verify(s.params, sk.Pub, msg, sig); err != nil {
|
||||
return nil, nil, fmt.Errorf("mldsa-tee: self-verify failed (kernel bug): %w", err)
|
||||
}
|
||||
|
||||
wire, err := sig.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("mldsa-tee: sig.MarshalBinary: %w", err)
|
||||
}
|
||||
|
||||
audit, err := s.auditSignature(ctx, jobID, msg, sealed.Epoch, env.RIM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("mldsa-tee: audit signature: %w", err)
|
||||
}
|
||||
|
||||
recv := &SignReceipt{
|
||||
JobID: jobID,
|
||||
Epoch: sealed.Epoch,
|
||||
IssuedNonce: sealed.IssuedNonce,
|
||||
EphemeralPub: sealed.EphemeralPub,
|
||||
EvidenceKind: string(env.Kind),
|
||||
EvidenceIssuer: evidenceIssuerString(env),
|
||||
AuditSignature: audit,
|
||||
}
|
||||
return wire, recv, nil
|
||||
}
|
||||
|
||||
// SignReceipt is the audit blob returned alongside the FIPS 204
|
||||
// signature.
|
||||
type SignReceipt struct {
|
||||
JobID [32]byte
|
||||
Epoch uint64
|
||||
IssuedNonce [32]byte
|
||||
EphemeralPub [32]byte
|
||||
EvidenceKind string
|
||||
EvidenceIssuer string
|
||||
AuditSignature []byte
|
||||
}
|
||||
|
||||
func (s *Signer) auditSignature(ctx context.Context, jobID [32]byte, msg []byte, epoch uint64, rim [32]byte) ([]byte, error) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("LUX-MLDSA-TEE-AUDIT-V1"))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(jobID[:])
|
||||
h.Write(epochBytes(epoch))
|
||||
h.Write(rim[:])
|
||||
d := sha256.Sum256(msg)
|
||||
h.Write(d[:])
|
||||
auditDigest := h.Sum(nil)
|
||||
return s.hsmP.Sign(ctx, s.cfg.KMSKeyID, auditDigest)
|
||||
}
|
||||
|
||||
func epochBytes(e uint64) []byte {
|
||||
return []byte{
|
||||
byte(e >> 56), byte(e >> 48), byte(e >> 40), byte(e >> 32),
|
||||
byte(e >> 24), byte(e >> 16), byte(e >> 8), byte(e),
|
||||
}
|
||||
}
|
||||
|
||||
func evidenceIssuerString(env *Envelope) string {
|
||||
switch is := env.EvidenceIssuers(); len(is) {
|
||||
case 0:
|
||||
return ""
|
||||
default:
|
||||
return is[0]
|
||||
}
|
||||
}
|
||||
|
||||
// signIntent satisfies approval.CanonicalIntent for the (jobID, msg,
|
||||
// envelope-summary) tuple.
|
||||
type signIntent struct {
|
||||
jobID [32]byte
|
||||
msg []byte
|
||||
env envelopeSummary
|
||||
}
|
||||
|
||||
type envelopeSummary struct {
|
||||
Kind string
|
||||
RIM [32]byte
|
||||
Hardware [32]byte
|
||||
TEEPub [32]byte
|
||||
}
|
||||
|
||||
func newSignIntent(jobID [32]byte, msg []byte, env *Envelope) *signIntent {
|
||||
return &signIntent{
|
||||
jobID: jobID,
|
||||
msg: append([]byte(nil), msg...),
|
||||
env: envelopeSummary{
|
||||
Kind: string(env.Kind),
|
||||
RIM: env.RIM,
|
||||
Hardware: env.Hardware,
|
||||
TEEPub: env.TEEPub,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Digest implements approval.CanonicalIntent.
|
||||
func (si *signIntent) Digest() [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("LUX-MLDSA-TEE-INTENT-V1"))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(si.jobID[:])
|
||||
mdigest := sha256.Sum256(si.msg)
|
||||
h.Write(mdigest[:])
|
||||
h.Write([]byte(si.env.Kind))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(si.env.RIM[:])
|
||||
h.Write(si.env.Hardware[:])
|
||||
h.Write(si.env.TEEPub[:])
|
||||
var out [32]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
// Bytes implements approval.CanonicalIntent.
|
||||
func (si *signIntent) Bytes() []byte {
|
||||
out := make([]byte, 0, 32+32+len(si.env.Kind)+1+32+32+32+32)
|
||||
out = append(out, []byte("LUX-MLDSA-TEE-INTENT-V1")...)
|
||||
out = append(out, 0x00)
|
||||
out = append(out, si.jobID[:]...)
|
||||
mdigest := sha256.Sum256(si.msg)
|
||||
out = append(out, mdigest[:]...)
|
||||
out = append(out, []byte(si.env.Kind)...)
|
||||
out = append(out, 0x00)
|
||||
out = append(out, si.env.RIM[:]...)
|
||||
out = append(out, si.env.Hardware[:]...)
|
||||
out = append(out, si.env.TEEPub[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
var _ approval.CanonicalIntent = (*signIntent)(nil)
|
||||
|
||||
// FreshJobID returns 32 bytes of crypto/rand.
|
||||
func FreshJobID() ([32]byte, error) {
|
||||
var out [32]byte
|
||||
if _, err := rand.Read(out[:]); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mldsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Signer is the institutional-custody ML-DSA signer.
|
||||
//
|
||||
// Composition (not inheritance):
|
||||
//
|
||||
// - gate : kms.ReleaseGate — the trust root.
|
||||
// - hsmP : hsm.Provider — wraps the master seed at rest.
|
||||
// - appr : approval.ApprovalProvider — out-of-band approval gate.
|
||||
// - cfg : Config — policy: RIM, hardware, mode, key IDs.
|
||||
//
|
||||
// Safe for concurrent Sign calls.
|
||||
type Signer struct {
|
||||
gate kms.ReleaseGate
|
||||
hsmP hsm.Provider
|
||||
appr approval.ApprovalProvider
|
||||
cfg Config
|
||||
|
||||
params *pulsar.Params
|
||||
|
||||
mu sync.Mutex // reserved for future per-Signer rate-limit state
|
||||
}
|
||||
|
||||
// New builds a Signer from the supplied dependencies.
|
||||
func New(gate kms.ReleaseGate, hsmP hsm.Provider, appr approval.ApprovalProvider, cfg Config) (*Signer, error) {
|
||||
if gate == nil {
|
||||
return nil, errors.New("mldsa-tee: nil release gate")
|
||||
}
|
||||
if hsmP == nil {
|
||||
return nil, errors.New("mldsa-tee: nil HSM provider")
|
||||
}
|
||||
if cfg.ApprovalRequired && appr == nil {
|
||||
return nil, errors.New("mldsa-tee: nil approval provider but ApprovalRequired is true")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, err := pulsar.ParamsFor(cfg.Mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mldsa-tee: ParamsFor: %w", err)
|
||||
}
|
||||
return &Signer{
|
||||
gate: gate,
|
||||
hsmP: hsmP,
|
||||
appr: appr,
|
||||
cfg: cfg,
|
||||
params: params,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Provision wraps a fresh master seed under the HSM provider for
|
||||
// later release-gated signing. Generates pulsar.SeedSize (=32) bytes
|
||||
// of entropy and stores via hsmP.StoreKey under cfg.WrappedSeedKeyID.
|
||||
// Returns the pulsar.PublicKey derived from the provisioned seed.
|
||||
func (s *Signer) Provision(ctx context.Context) (*pulsar.PublicKey, error) {
|
||||
var seed [pulsar.SeedSize]byte
|
||||
defer zeroizeArr(&seed)
|
||||
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
return nil, fmt.Errorf("mldsa-tee: provision: entropy: %w", err)
|
||||
}
|
||||
|
||||
if err := s.hsmP.StoreKey(ctx, s.cfg.WrappedSeedKeyID, seed[:]); err != nil {
|
||||
return nil, fmt.Errorf("mldsa-tee: provision: HSM StoreKey: %w", err)
|
||||
}
|
||||
|
||||
sk, err := pulsar.KeyFromSeed(s.params, seed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mldsa-tee: provision: KeyFromSeed: %w", err)
|
||||
}
|
||||
pub := sk.Public()
|
||||
zeroize(sk.Bytes)
|
||||
zeroizeArr(&sk.Seed)
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// PublicKey reads the master seed via the HSM provider and derives
|
||||
// the pulsar PublicKey deterministically. RELEASE-GATE FREE — only
|
||||
// the at-rest HSM material is read.
|
||||
func (s *Signer) PublicKey(ctx context.Context) (*pulsar.PublicKey, error) {
|
||||
raw, err := s.hsmP.GetKey(ctx, s.cfg.WrappedSeedKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mldsa-tee: PublicKey: HSM GetKey: %w", err)
|
||||
}
|
||||
defer zeroize(raw)
|
||||
if len(raw) != pulsar.SeedSize {
|
||||
return nil, fmt.Errorf("mldsa-tee: PublicKey: seed length %d does not match pulsar.SeedSize %d", len(raw), pulsar.SeedSize)
|
||||
}
|
||||
var seed [pulsar.SeedSize]byte
|
||||
copy(seed[:], raw)
|
||||
defer zeroizeArr(&seed)
|
||||
sk, err := pulsar.KeyFromSeed(s.params, seed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mldsa-tee: PublicKey: KeyFromSeed: %w", err)
|
||||
}
|
||||
defer zeroize(sk.Bytes)
|
||||
defer zeroizeArr(&sk.Seed)
|
||||
return sk.Public(), nil
|
||||
}
|
||||
|
||||
// Mode reports the FIPS 204 parameter set this signer is bound to.
|
||||
func (s *Signer) Mode() pulsar.Mode { return s.cfg.Mode }
|
||||
|
||||
// Params returns the pulsar Params for this signer's mode.
|
||||
func (s *Signer) Params() *pulsar.Params { return s.params }
|
||||
|
||||
// auditedRelease drives the full Issue → approval → composite envelope
|
||||
// → Release flow.
|
||||
func (s *Signer) auditedRelease(ctx context.Context, env *Envelope, jobID [32]byte, msg []byte) (kms.SealedSessionKey, error) {
|
||||
if env == nil {
|
||||
return kms.SealedSessionKey{}, ErrAttestationRequired
|
||||
}
|
||||
|
||||
if s.cfg.ApprovalRequired {
|
||||
intent := newSignIntent(jobID, msg, env)
|
||||
sig, err := s.appr.ApproveIntent(ctx, s.cfg.ApproverID, intent)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: %v", ErrApprovalDenied, err)
|
||||
}
|
||||
ok, err := s.appr.VerifyApproval(ctx, intent, sig)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: verify: %v", ErrApprovalDenied, err)
|
||||
}
|
||||
if !ok {
|
||||
return kms.SealedSessionKey{}, ErrApprovalDenied
|
||||
}
|
||||
}
|
||||
|
||||
nonce, epoch, err := s.gate.Issue(jobID)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: gate.Issue: %v", ErrKMSReleaseUnreachable, err)
|
||||
}
|
||||
env.ExpectedNonce = nonce
|
||||
|
||||
sealed, err := s.gate.Release(kms.ReleaseRequest{
|
||||
JobID: jobID,
|
||||
Epoch: epoch,
|
||||
Nonce: nonce,
|
||||
Attestation: env,
|
||||
Ctx: ctx,
|
||||
})
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: %v", ErrPolicyRefused, err)
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// zeroize clears a byte slice in place.
|
||||
func zeroize(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// zeroizeArr clears a 32-byte array in place.
|
||||
func zeroizeArr(a *[pulsar.SeedSize]byte) {
|
||||
for i := range a {
|
||||
a[i] = 0
|
||||
}
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mldsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sevtest "github.com/google/go-sev-guest/testing"
|
||||
"github.com/google/go-sev-guest/verify/trust"
|
||||
|
||||
pulsar "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Real AMD Milan SEV-SNP attestation fixtures (same bytes as the
|
||||
// lux/mpc cc/attest test corpus).
|
||||
//
|
||||
//go:embed testdata/sev_snp_attestation_milan.bin
|
||||
var sevSnpAttestationMilan []byte
|
||||
|
||||
//go:embed testdata/sev_snp_vcek_milan.cer
|
||||
var sevSnpVcekMilan []byte
|
||||
|
||||
func newKDSReplay() trust.HTTPSGetter {
|
||||
return sevtest.SimpleGetter(map[string][]byte{
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/cert_chain": trust.AskArkMilanVcekBytes,
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/3ac3fe21e13fb0990eb28a802e3fb6a29483a6b0753590c951bdd3b8e53786184ca39e359669a2b76a1936776b564ea464cdce40c05f63c9b610c5068b006b5d?blSPL=2&teeSPL=0&snpSPL=5&ucodeSPL=68": sevSnpVcekMilan,
|
||||
})
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func realMeasurement() []byte { return sevSnpAttestationMilan[0x90 : 0x90+48] }
|
||||
func realChipID() []byte { return sevSnpAttestationMilan[0x1A0 : 0x1A0+64] }
|
||||
|
||||
func makeRIM(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
return sha256.Sum256(realMeasurement())
|
||||
}
|
||||
|
||||
func makeHardware(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
return sha256.Sum256(realChipID())
|
||||
}
|
||||
|
||||
func makeTEEPub(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
var priv [32]byte
|
||||
for i := range priv {
|
||||
priv[i] = byte(i + 1)
|
||||
}
|
||||
priv[0] &= 248
|
||||
priv[31] &= 127
|
||||
priv[31] |= 64
|
||||
pub, err := curve25519BasepointMul(priv[:])
|
||||
if err != nil {
|
||||
t.Fatalf("makeTEEPub: %v", err)
|
||||
}
|
||||
var out [32]byte
|
||||
copy(out[:], pub)
|
||||
return out
|
||||
}
|
||||
|
||||
func newTestFileHSM(t *testing.T) hsm.Provider {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := &hsm.FileConfig{
|
||||
BasePath: dir,
|
||||
HexEncoded: true, // unambiguous on-disk for raw bytes
|
||||
}
|
||||
p, err := hsm.NewFileProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestFileHSM: %v", err)
|
||||
}
|
||||
var ed25519Seed [32]byte
|
||||
if _, err := rand.Read(ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("ed25519 seed: %v", err)
|
||||
}
|
||||
if err := p.StoreKey(context.Background(), "audit-key", ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("store audit key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = p.Close()
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
func newTestApprovalProvider(t *testing.T) approval.ApprovalProvider {
|
||||
t.Helper()
|
||||
// MPC_LOCAL_APPROVAL=true is set process-wide by TestMain so that
|
||||
// parallel tests can share the LocalDevProvider without
|
||||
// t.Setenv-imposed serialization.
|
||||
p, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestApprovalProvider: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestMain enables the LocalDevProvider for the lifetime of this
|
||||
// test binary.
|
||||
func TestMain(m *testing.M) {
|
||||
_ = os.Setenv("MPC_LOCAL_APPROVAL", "true")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// denyApprovalProvider models a user-cancel verdict from a real
|
||||
// WebAuthn/Ledger device. NOT a stub interface — it satisfies the
|
||||
// full approval.ApprovalProvider contract.
|
||||
type denyApprovalProvider struct{}
|
||||
|
||||
func (denyApprovalProvider) Provider() string { return "deny-test" }
|
||||
|
||||
func (denyApprovalProvider) GetPublicIdentity(_ context.Context, approverID string) (approval.PublicIdentity, error) {
|
||||
return approval.PublicIdentity{
|
||||
ApproverID: approverID,
|
||||
Provider: "deny-test",
|
||||
PublicKey: make([]byte, 32),
|
||||
Algorithm: approval.AlgorithmEd25519,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (denyApprovalProvider) ApproveIntent(_ context.Context, approverID string, intent approval.CanonicalIntent) (approval.ApprovalSignature, error) {
|
||||
return approval.ApprovalSignature{}, errors.New("deny-test: user cancelled")
|
||||
}
|
||||
|
||||
func (denyApprovalProvider) VerifyApproval(_ context.Context, intent approval.CanonicalIntent, sig approval.ApprovalSignature) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newTestGate(t *testing.T, rim, hw [32]byte) (*kms.LocalReleaseGate, kms.NonceStore) {
|
||||
t.Helper()
|
||||
policy := kms.NewReleasePolicy([][32]byte{rim}, [][32]byte{hw})
|
||||
policy.RequireSEVSNP = true
|
||||
|
||||
var rootKey [32]byte
|
||||
if _, err := rand.Read(rootKey[:]); err != nil {
|
||||
t.Fatalf("rootKey: %v", err)
|
||||
}
|
||||
store := kms.NewMemoryNonceStore()
|
||||
gate, err := kms.NewLocalReleaseGate(policy, store, rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalReleaseGate: %v", err)
|
||||
}
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
gate.SetReplayWindow(5 * time.Second)
|
||||
return gate, store
|
||||
}
|
||||
|
||||
func newTestSigner(t *testing.T, approvalRequired bool) (*Signer, *kms.LocalReleaseGate, hsm.Provider, [32]byte, [32]byte) {
|
||||
t.Helper()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
|
||||
gate, _ := newTestGate(t, rim, hw)
|
||||
hsmP := newTestFileHSM(t)
|
||||
appr := newTestApprovalProvider(t)
|
||||
|
||||
cfg := Config{
|
||||
Mode: pulsar.ModeP65,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: approvalRequired,
|
||||
ApproverID: "test@lux.network",
|
||||
}
|
||||
s, err := New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := s.Provision(context.Background()); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
return s, gate, hsmP, rim, hw
|
||||
}
|
||||
|
||||
func envelopeFromTestdata(t *testing.T, rim, hw, teePub [32]byte) *Envelope {
|
||||
t.Helper()
|
||||
// Cache mutation removed; hot cert cache is correctness-equivalent.
|
||||
return &Envelope{
|
||||
Kind: attest.KindSEVSNP,
|
||||
EvidenceBytes: append([]byte(nil), sevSnpAttestationMilan...),
|
||||
RIM: rim,
|
||||
Hardware: hw,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: []attest.Option{
|
||||
attest.WithKDSGetter(newKDSReplay()),
|
||||
attest.WithNow(fixedNow()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 1: full chain E2E
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_SEVSNP_E2E(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, true)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
jobID, err := FreshJobID()
|
||||
if err != nil {
|
||||
t.Fatalf("FreshJobID: %v", err)
|
||||
}
|
||||
msg := []byte("LUX-MLDSA-TEE: institutional-custody E2E test")
|
||||
|
||||
wire, receipt, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Sign returned empty wire bytes")
|
||||
}
|
||||
if receipt == nil {
|
||||
t.Fatal("Sign returned nil receipt")
|
||||
}
|
||||
if receipt.JobID != jobID {
|
||||
t.Errorf("receipt.JobID = %x, want %x", receipt.JobID, jobID)
|
||||
}
|
||||
if receipt.EvidenceKind != string(attest.KindSEVSNP) {
|
||||
t.Errorf("receipt.EvidenceKind = %q", receipt.EvidenceKind)
|
||||
}
|
||||
if receipt.EvidenceIssuer != kms.IssuerSEVSNP {
|
||||
t.Errorf("receipt.EvidenceIssuer = %q", receipt.EvidenceIssuer)
|
||||
}
|
||||
if len(receipt.AuditSignature) == 0 {
|
||||
t.Error("receipt.AuditSignature empty")
|
||||
}
|
||||
|
||||
pub, err := s.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
gkBytes, err := pub.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("pub.MarshalBinary: %v", err)
|
||||
}
|
||||
if !pulsar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("external VerifyBytes refused the signature; not FIPS 204 byte-identical")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 2: rejects corrupt attestation
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_RejectsBadAttestation(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
// Flip a bit inside the SEV signature region.
|
||||
env.EvidenceBytes[0x2A0+0x10] ^= 0x01
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("reject-bad-evidence")
|
||||
_, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Sign: expected refusal on tampered evidence, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPolicyRefused) {
|
||||
t.Errorf("Sign: err = %v, want wrapped ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 3: rejects RIM mismatch
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_RejectsRIMMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, _, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
|
||||
wrongRIM := sha256.Sum256([]byte("not-the-real-measurement"))
|
||||
env := envelopeFromTestdata(t, wrongRIM, hw, teePub)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("reject-wrong-rim")
|
||||
_, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Sign: expected refusal on RIM mismatch, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPolicyRefused) {
|
||||
t.Errorf("Sign: err = %v, want wrapped ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 4: rejects expired nonce / wrong epoch
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_RejectsExpiredNonce(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, gate, _, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
gate.SetIssueTTL(10 * time.Millisecond)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
nonce, epoch, err := gate.Issue(jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
env.ExpectedNonce = nonce
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
_, releaseErr := gate.Release(kms.ReleaseRequest{
|
||||
JobID: jobID, Epoch: epoch, Nonce: nonce, Attestation: env, Ctx: context.Background(),
|
||||
})
|
||||
if releaseErr == nil {
|
||||
t.Fatal("gate.Release: expected expiry refusal, got nil")
|
||||
}
|
||||
if !errors.Is(releaseErr, kms.ErrPolicyRefused) {
|
||||
t.Errorf("releaseErr = %v, want wrapped kms.ErrPolicyRefused", releaseErr)
|
||||
}
|
||||
if !errors.Is(releaseErr, kms.ErrExpired) {
|
||||
t.Errorf("releaseErr = %v, want wrapped kms.ErrExpired", releaseErr)
|
||||
}
|
||||
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
_ = gate.Rotate()
|
||||
freshJob, _ := FreshJobID()
|
||||
freshEnv := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
msg := []byte("post-rotation-sign")
|
||||
if _, _, err := s.Sign(context.Background(), freshEnv, freshJob, msg, nil); err != nil {
|
||||
t.Fatalf("Sign post-rotation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 5: AWS KMS backend
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_HSMSign_AWS_KMS(t *testing.T) {
|
||||
endpoint := os.Getenv("AWS_ENDPOINT_URL_KMS")
|
||||
keyARN := os.Getenv("AWS_KMS_TEST_KEY_ARN")
|
||||
if endpoint == "" || keyARN == "" {
|
||||
t.Skip("AWS_ENDPOINT_URL_KMS and AWS_KMS_TEST_KEY_ARN not set; localstack KMS not available — see test comment for setup. Skipped per spec rationale: unit CI must not require real AWS credentials. File provider path is exercised by TestSigner_Sign_HSMSign_File and all chain-verify tests.")
|
||||
}
|
||||
|
||||
awsCfg := &hsm.AWSConfig{
|
||||
Region: os.Getenv("AWS_REGION"),
|
||||
KeyARN: keyARN,
|
||||
Profile: os.Getenv("AWS_PROFILE"),
|
||||
}
|
||||
awsP, err := hsm.NewAWSProvider(awsCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAWSProvider: %v", err)
|
||||
}
|
||||
defer awsP.Close()
|
||||
|
||||
digest := sha256.Sum256([]byte("aws-kms-audit-probe"))
|
||||
sig, err := awsP.Sign(context.Background(), keyARN, digest[:])
|
||||
if err != nil {
|
||||
t.Fatalf("AWS KMS Sign: %v", err)
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
t.Fatal("AWS KMS Sign returned empty signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 6: File-backed HSM end-to-end
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_HSMSign_File(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, true)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("file-hsm-e2e")
|
||||
wire, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
pub, err := s.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
gkBytes, err := pub.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("pub.MarshalBinary: %v", err)
|
||||
}
|
||||
if !pulsar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("file-hsm-e2e: VerifyBytes refused FIPS 204 signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 7: WebAuthn-style approval required
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_ApprovalRequired_DenyAndAllow(t *testing.T) {
|
||||
t.Parallel()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
gate, store := newTestGate(t, rim, hw)
|
||||
fileP := newTestFileHSM(t)
|
||||
|
||||
cfg := Config{
|
||||
Mode: pulsar.ModeP65,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: true,
|
||||
ApproverID: "ceo@lux.network",
|
||||
}
|
||||
|
||||
denyS, err := New(gate, fileP, denyApprovalProvider{}, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New(deny): %v", err)
|
||||
}
|
||||
if _, err := denyS.Provision(context.Background()); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("approval-deny-test")
|
||||
_, _, err = denyS.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Sign(deny): expected ErrApprovalDenied, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrApprovalDenied) {
|
||||
t.Errorf("Sign(deny): err = %v, want wrapped ErrApprovalDenied", err)
|
||||
}
|
||||
if _, lookupErr := store.Lookup(jobID); !errors.Is(lookupErr, kms.ErrNonceUnknown) {
|
||||
t.Errorf("deny path leaked a gate-issued nonce: %v", lookupErr)
|
||||
}
|
||||
|
||||
// MPC_LOCAL_APPROVAL already exported by TestMain.
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev provider: %v", err)
|
||||
}
|
||||
allowS, err := New(gate, fileP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New(allow): %v", err)
|
||||
}
|
||||
allowEnv := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
allowJob, _ := FreshJobID()
|
||||
if _, _, err := allowS.Sign(context.Background(), allowEnv, allowJob, msg, nil); err != nil {
|
||||
t.Fatalf("Sign(allow): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extra: byte-identity with FIPS 204
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_ByteIdentityWithFIPS204(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, hsmP, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("byte-identity-probe")
|
||||
|
||||
wire, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
raw, err := hsmP.GetKey(context.Background(), "master-seed")
|
||||
if err != nil {
|
||||
t.Fatalf("HSM GetKey: %v", err)
|
||||
}
|
||||
var seed [pulsar.SeedSize]byte
|
||||
copy(seed[:], raw)
|
||||
params := pulsar.MustParamsFor(pulsar.ModeP65)
|
||||
sk, err := pulsar.KeyFromSeed(params, seed)
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromSeed: %v", err)
|
||||
}
|
||||
directSig, err := pulsar.Sign(params, sk, msg, nil, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("direct Sign: %v", err)
|
||||
}
|
||||
directWire, err := directSig.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("direct MarshalBinary: %v", err)
|
||||
}
|
||||
if string(wire) != string(directWire) {
|
||||
t.Fatalf("Sign output not byte-identical to single-party FIPS 204")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config validation
|
||||
// ============================================================================
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
rim := [32]byte{1}
|
||||
hw := [32]byte{2}
|
||||
good := Config{
|
||||
Mode: pulsar.ModeP65,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "k",
|
||||
WrappedSeedKeyID: "s",
|
||||
}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Fatalf("good: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
want error
|
||||
}{
|
||||
{"emptyRIM", func(c *Config) { c.RequiredRIM = nil }, ErrEmptyRIM},
|
||||
{"emptyHardware", func(c *Config) { c.AllowedHardware = nil }, ErrEmptyHardware},
|
||||
{"noRequireFlag", func(c *Config) { c.RequireSEVSNP = false }, ErrNoRequireFlag},
|
||||
{"missingKMSKeyID", func(c *Config) { c.KMSKeyID = "" }, ErrMissingKMSKeyID},
|
||||
{"missingSeedKeyID", func(c *Config) { c.WrappedSeedKeyID = "" }, ErrMissingSeedKeyID},
|
||||
{"approverMissing", func(c *Config) { c.ApprovalRequired = true; c.ApproverID = "" }, ErrApproverMissing},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := good
|
||||
c.RequiredRIM = map[[32]byte]struct{}{rim: {}}
|
||||
c.AllowedHardware = map[[32]byte]struct{}{hw: {}}
|
||||
tc.mutate(&c)
|
||||
err := c.Validate()
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigner_ModeAndParams(t *testing.T) {
|
||||
s, _, _, _, _ := newTestSigner(t, false)
|
||||
if s.Mode() != pulsar.ModeP65 {
|
||||
t.Errorf("Mode = %v, want ModeP65", s.Mode())
|
||||
}
|
||||
if s.Params() == nil {
|
||||
t.Fatal("Params returned nil")
|
||||
}
|
||||
if s.Params().Mode != pulsar.ModeP65 {
|
||||
t.Errorf("Params.Mode = %v, want ModeP65", s.Params().Mode)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,49 +0,0 @@
|
||||
# rlwe-tee
|
||||
|
||||
Operator-controlled Ring-LWE threshold signing via TEE-gated
|
||||
trusted-dealer-key reconstruction.
|
||||
|
||||
## What this is
|
||||
|
||||
Sibling of `protocols/slhdsa-tee` and `protocols/mldsa-tee` for the
|
||||
corona Ring-LWE primitive. The master trusted-dealer key (32 bytes)
|
||||
lives sealed-at-rest in the HSM; per sign call the TEE attestation
|
||||
authorizes release, then the dealer's `GenerateKeys` + the n-party
|
||||
Round1/Round2/Finalize all run inside the attested process. The
|
||||
resulting `corona.threshold.Signature` is structurally identical to
|
||||
a permissionless corona threshold signature on the same `(GroupKey,
|
||||
message)`.
|
||||
|
||||
## When to use
|
||||
|
||||
- Foundation HSM ceremonies that need a single-operator dealer with
|
||||
attested release.
|
||||
- Bridge custody operators that hold a corona PQ threshold signing
|
||||
key and require executive approval per signature.
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Permissionless threshold custody: use
|
||||
`corona.keyera.BootstrapPedersen` — no party ever holds the master
|
||||
trusted-dealer key.
|
||||
- Single-operator dev: use `corona.threshold.GenerateKeys` directly.
|
||||
|
||||
## Layering
|
||||
|
||||
```
|
||||
caller
|
||||
└── rlwetee.Signer.Sign(ctx, env, jobID, msg)
|
||||
├── approval.ApprovalProvider.ApproveIntent
|
||||
├── kms.ReleaseGate.Issue / Release
|
||||
│ └── cc/attest.Dispatch
|
||||
├── hsm.Provider.GetKey
|
||||
├── corona.threshold.GenerateKeys → Round1 → Round2 → Finalize
|
||||
└── hsm.Provider.Sign (audit)
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
Wire output is `corona.threshold.Signature.MarshalBinary`. Verifiers
|
||||
holding the published GroupKey bytes validate via
|
||||
`corona.threshold.VerifyBytes(gkBytes, msg, sigBytes)` — no
|
||||
awareness of the TEE substrate required.
|
||||
@@ -1,98 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rlwetee
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/luxfi/corona/sign"
|
||||
)
|
||||
|
||||
// Config carries the operator-side policy + provider configuration.
|
||||
//
|
||||
// Every field is required; New refuses on any zero value.
|
||||
type Config struct {
|
||||
// Threshold is the corona t in t-of-n. corona requires 1 <= t < n.
|
||||
Threshold int
|
||||
|
||||
// Participants is the corona n. Each Sign call drives all n
|
||||
// parties in-process under the attested TEE; the dispatcher
|
||||
// surface remains single-shot.
|
||||
Participants int
|
||||
|
||||
// RequiredRIM is the set of acceptable RIM digests.
|
||||
RequiredRIM map[[32]byte]struct{}
|
||||
|
||||
// AllowedHardware is the set of acceptable hardware fingerprints.
|
||||
AllowedHardware map[[32]byte]struct{}
|
||||
|
||||
// Require* flags mirror kms.ReleasePolicy.Require*.
|
||||
RequireSEVSNP bool
|
||||
RequireTDX bool
|
||||
RequireNVNRAS bool
|
||||
|
||||
// KMSKeyID is the HSM key for the audit signature over
|
||||
// (jobID || msgDigest || epoch || RIM).
|
||||
KMSKeyID string
|
||||
|
||||
// WrappedSeedKeyID is the HSM-stored blob identifier for the
|
||||
// wrapped 32-byte master trusted-dealer key.
|
||||
WrappedSeedKeyID string
|
||||
|
||||
// ApprovalRequired determines whether ApprovalProvider must
|
||||
// produce a non-deny ApprovalSignature before Issue().
|
||||
ApprovalRequired bool
|
||||
|
||||
// ApproverID is the canonical identifier whose approval is
|
||||
// required.
|
||||
ApproverID string
|
||||
}
|
||||
|
||||
// Errors surfaced by Config.Validate and the Sign flow.
|
||||
var (
|
||||
ErrInvalidThreshold = errors.New("rlwe-tee: invalid threshold (requires 1 <= Threshold < Participants)")
|
||||
ErrEmptyRIM = errors.New("rlwe-tee: RequiredRIM must be non-empty (default-deny posture)")
|
||||
ErrEmptyHardware = errors.New("rlwe-tee: AllowedHardware must be non-empty (default-deny posture)")
|
||||
ErrNoRequireFlag = errors.New("rlwe-tee: at least one Require* TEE flag must be true")
|
||||
ErrMissingKMSKeyID = errors.New("rlwe-tee: KMSKeyID required for audit signature")
|
||||
ErrMissingSeedKeyID = errors.New("rlwe-tee: WrappedSeedKeyID required for HSM seed storage")
|
||||
ErrApproverMissing = errors.New("rlwe-tee: ApproverID required when ApprovalRequired is true")
|
||||
ErrApprovalDenied = errors.New("rlwe-tee: approval provider denied or returned mismatched signature")
|
||||
ErrAttestationRequired = errors.New("rlwe-tee: attestation envelope required")
|
||||
ErrPolicyRefused = errors.New("rlwe-tee: release gate refused")
|
||||
ErrKMSReleaseUnreachable = errors.New("rlwe-tee: release gate unreachable")
|
||||
ErrHSMUnreachable = errors.New("rlwe-tee: HSM provider unreachable")
|
||||
ErrCorruptWrappedSeed = errors.New("rlwe-tee: wrapped seed blob fails authenticated decryption")
|
||||
ErrCoronaProtocol = errors.New("rlwe-tee: corona threshold protocol failed")
|
||||
)
|
||||
|
||||
// MasterKeySize is the corona trusted-dealer key length the HSM
|
||||
// stores. Exported so embedders can size buffers consistently with
|
||||
// the corona kernel.
|
||||
const MasterKeySize = sign.KeySize // 32
|
||||
|
||||
// Validate reports the first structural error in cfg.
|
||||
func (cfg *Config) Validate() error {
|
||||
if cfg.Threshold < 1 || cfg.Participants < 2 || cfg.Threshold >= cfg.Participants {
|
||||
return ErrInvalidThreshold
|
||||
}
|
||||
if len(cfg.RequiredRIM) == 0 {
|
||||
return ErrEmptyRIM
|
||||
}
|
||||
if len(cfg.AllowedHardware) == 0 {
|
||||
return ErrEmptyHardware
|
||||
}
|
||||
if !cfg.RequireSEVSNP && !cfg.RequireTDX && !cfg.RequireNVNRAS {
|
||||
return ErrNoRequireFlag
|
||||
}
|
||||
if cfg.KMSKeyID == "" {
|
||||
return ErrMissingKMSKeyID
|
||||
}
|
||||
if cfg.WrappedSeedKeyID == "" {
|
||||
return ErrMissingSeedKeyID
|
||||
}
|
||||
if cfg.ApprovalRequired && cfg.ApproverID == "" {
|
||||
return ErrApproverMissing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rlwetee
|
||||
|
||||
import "golang.org/x/crypto/curve25519"
|
||||
|
||||
// curve25519BasepointMul is a test-only helper.
|
||||
func curve25519BasepointMul(priv []byte) ([]byte, error) {
|
||||
return curve25519.X25519(priv, curve25519.Basepoint)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package rlwetee implements operator-controlled Ring-LWE threshold
|
||||
// signing via TEE-gated trusted-dealer-key reconstruction.
|
||||
//
|
||||
// This is NOT a public-BFT primitive. Corona v0.7.3 BootstrapPedersen
|
||||
// is the canonical permissionless DKG path (no party ever holds the
|
||||
// master); THIS package is the institutional-custody-only extension
|
||||
// that binds key release to:
|
||||
//
|
||||
// 1. a verifiable hardware TEE attestation (AMD SEV-SNP / Intel TDX /
|
||||
// NVIDIA NRAS) chain-validated by github.com/luxfi/mpc/cc/attest;
|
||||
// 2. a KMS release-gate (github.com/luxfi/mpc/pkg/kms.ReleaseGate)
|
||||
// that pins the worker's RIM digest + hardware fingerprint and
|
||||
// binds a single-use challenge nonce per-request;
|
||||
// 3. an out-of-band human / programmatic approval signature
|
||||
// (github.com/luxfi/mpc/pkg/approval.ApprovalProvider);
|
||||
// 4. an HSM-resident wrap-key store
|
||||
// (github.com/luxfi/mpc/pkg/hsm.Provider) so the master 32-byte
|
||||
// trusted-dealer key (sign.KeySize) lives sealed-at-rest and is
|
||||
// only ever unwrapped inside the attested TEE.
|
||||
//
|
||||
// Per-sign flow:
|
||||
//
|
||||
// 1. Verify attestation envelope chain validity + RIM + hardware
|
||||
// allowlist + approval.
|
||||
// 2. Release the wrapped master trusted-dealer key from the HSM.
|
||||
// 3. Inside the TEE: deterministically regenerate the n key shares
|
||||
// and the GroupKey by re-running corona.threshold.GenerateKeys
|
||||
// with the master key as PRNG seed.
|
||||
// 4. Drive Round1 → Round2 → Finalize across all n parties in the
|
||||
// same TEE process. The resulting Signature is structurally
|
||||
// identical to a permissionless corona threshold signature on
|
||||
// the same message and group public key.
|
||||
// 5. Zeroize the master key and per-party shares.
|
||||
//
|
||||
// Verification surface is corona.threshold.Verify(gk, msg, sig). Any
|
||||
// caller holding the published GroupKey wire bytes can validate with
|
||||
// corona.threshold.VerifyBytes — no awareness of the TEE substrate is
|
||||
// required.
|
||||
//
|
||||
// Threat model:
|
||||
//
|
||||
// - Adversary outside TEE: cannot produce a valid attestation, so
|
||||
// gate refuses release; no signing possible.
|
||||
// - Forged attestation: cc/attest chain refuses; ErrAttestationChain.
|
||||
// - Replay of old sealed key: AAD-binding (epoch, jobID, teePub,
|
||||
// issuedNonce) refuses cross-epoch / cross-job.
|
||||
//
|
||||
// What this package is NOT:
|
||||
//
|
||||
// - NOT a substitute for corona.keyera.BootstrapPedersen on the
|
||||
// public-BFT surface. The Pedersen-DKG path (no trusted dealer)
|
||||
// is the canonical permissionless construction. Use rlwe-tee
|
||||
// ONLY when the threat model permits "trusted dealer inside an
|
||||
// attested TEE" (foundation HSM ceremonies, single-operator
|
||||
// custody).
|
||||
//
|
||||
// - NOT a distributed protocol. All n parties run in the same
|
||||
// attested process; the network surface is exactly the
|
||||
// attestation envelope, the gate release request, and the
|
||||
// resulting wire-form signature.
|
||||
package rlwetee
|
||||
@@ -1,82 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rlwetee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Envelope is the minimal kms.CompositeAttestation implementation
|
||||
// consumed by this package. Mirrors slhdsa-tee.Envelope so embedders
|
||||
// that compose multiple primitives share the same plumbing.
|
||||
type Envelope struct {
|
||||
Kind attest.Kind
|
||||
EvidenceBytes []byte
|
||||
ExpectedNonce [32]byte
|
||||
RIM [32]byte
|
||||
Hardware [32]byte
|
||||
TEEPub [32]byte
|
||||
VerifyOpts []attest.Option
|
||||
}
|
||||
|
||||
var _ kms.CompositeAttestation = (*Envelope)(nil)
|
||||
|
||||
// Verify implements kms.CompositeAttestation.Verify.
|
||||
func (e *Envelope) Verify(expectedNonce [32]byte) (bool, error) {
|
||||
if subtle.ConstantTimeCompare(e.ExpectedNonce[:], expectedNonce[:]) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyEvidence implements kms.CompositeAttestation.VerifyEvidence.
|
||||
func (e *Envelope) VerifyEvidence(ctx context.Context, opts ...attest.Option) ([]*attest.VerifiedReport, error) {
|
||||
if len(e.EvidenceBytes) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty evidence", attest.ErrInvalidEvidence)
|
||||
}
|
||||
allOpts := append([]attest.Option{}, e.VerifyOpts...)
|
||||
allOpts = append(allOpts, opts...)
|
||||
rep, err := attest.Dispatch(ctx, e.Kind, e.EvidenceBytes, allOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := defaultRIMCheck(rep, e.RIM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*attest.VerifiedReport{rep}, nil
|
||||
}
|
||||
|
||||
func (e *Envelope) RIMDigest() [32]byte { return e.RIM }
|
||||
func (e *Envelope) HardwareFingerprint() [32]byte { return e.Hardware }
|
||||
func (e *Envelope) TEEPublicKey() [32]byte { return e.TEEPub }
|
||||
|
||||
func (e *Envelope) EvidenceIssuers() []string {
|
||||
switch e.Kind {
|
||||
case attest.KindSEVSNP:
|
||||
return []string{kms.IssuerSEVSNP}
|
||||
case attest.KindTDX:
|
||||
return []string{kms.IssuerTDX}
|
||||
case attest.KindNRAS:
|
||||
return []string{kms.IssuerNVNRAS}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRIMCheck(rep *attest.VerifiedReport, expected [32]byte) error {
|
||||
if rep == nil {
|
||||
return errors.New("rlwe-tee: defaultRIMCheck: nil verified report")
|
||||
}
|
||||
got := sha256.Sum256(rep.Measurement)
|
||||
if subtle.ConstantTimeCompare(got[:], expected[:]) != 1 {
|
||||
return fmt.Errorf("%w: report measurement does not fold to operator-asserted RIM", attest.ErrPolicy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rlwetee
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
coronaThreshold "github.com/luxfi/corona/threshold"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
)
|
||||
|
||||
// Sign produces a Ring-LWE corona threshold signature on msg, gated
|
||||
// by the supplied attestation Envelope.
|
||||
//
|
||||
// Flow:
|
||||
//
|
||||
// 1. approval gate → gate.Issue → env.Verify → env.VerifyEvidence →
|
||||
// gate.Release (same as slhdsa-tee / mldsa-tee).
|
||||
// 2. Inside the TEE: read the wrapped master trusted-dealer key,
|
||||
// deterministically regenerate all n key shares + GroupKey via
|
||||
// corona.threshold.GenerateKeys, then drive Round1 → Round2 →
|
||||
// Finalize across all n parties in-process.
|
||||
// 3. Zeroize the master key + per-party share state.
|
||||
// 4. Return the wire-form corona Signature.
|
||||
//
|
||||
// Output is a valid corona threshold signature on (gk, msg) that
|
||||
// any verifier holding the published GroupKey bytes can validate
|
||||
// via corona.threshold.VerifyBytes.
|
||||
func (s *Signer) Sign(ctx context.Context, env *Envelope, jobID [32]byte, msg []byte) ([]byte, *SignReceipt, error) {
|
||||
if env == nil {
|
||||
return nil, nil, ErrAttestationRequired
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
return nil, nil, fmt.Errorf("rlwe-tee: empty message")
|
||||
}
|
||||
|
||||
sealed, err := s.auditedRelease(ctx, env, jobID, msg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
key, err := s.hsmP.GetKey(ctx, s.cfg.WrappedSeedKeyID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: HSM GetKey: %v", ErrHSMUnreachable, err)
|
||||
}
|
||||
defer zeroize(key)
|
||||
|
||||
if len(key) != MasterKeySize {
|
||||
return nil, nil, fmt.Errorf("%w: HSM-stored key length %d does not match MasterKeySize %d",
|
||||
ErrCorruptWrappedSeed, len(key), MasterKeySize)
|
||||
}
|
||||
|
||||
// Re-derive every key share + GroupKey deterministically from
|
||||
// the master key. corona's GenerateKeys reads sign.KeySize bytes
|
||||
// from randSource; bytes.NewReader pins the run reproducibly.
|
||||
// The whole GenerateKeys + Round1 + Round2 + Finalize sequence
|
||||
// holds coronaSerializer because corona's threshold globals
|
||||
// (sign.K, sign.Threshold) and per-party internal state are
|
||||
// written by every party in this sequence.
|
||||
coronaSerializer.Lock()
|
||||
shares, gk, err := coronaThreshold.GenerateKeys(s.cfg.Threshold, s.cfg.Participants, bytes.NewReader(key))
|
||||
if err != nil {
|
||||
coronaSerializer.Unlock()
|
||||
return nil, nil, fmt.Errorf("%w: GenerateKeys: %v", ErrCorruptWrappedSeed, err)
|
||||
}
|
||||
|
||||
sigBytes, err := s.runCoronaThresholdSign(jobID, msg, shares, gk)
|
||||
coronaSerializer.Unlock()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: %v", ErrCoronaProtocol, err)
|
||||
}
|
||||
|
||||
audit, err := s.auditSignature(ctx, jobID, msg, sealed.Epoch, env.RIM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("rlwe-tee: audit signature: %w", err)
|
||||
}
|
||||
|
||||
recv := &SignReceipt{
|
||||
JobID: jobID,
|
||||
Epoch: sealed.Epoch,
|
||||
IssuedNonce: sealed.IssuedNonce,
|
||||
EphemeralPub: sealed.EphemeralPub,
|
||||
EvidenceKind: string(env.Kind),
|
||||
EvidenceIssuer: evidenceIssuerString(env),
|
||||
AuditSignature: audit,
|
||||
}
|
||||
return sigBytes, recv, nil
|
||||
}
|
||||
|
||||
// runCoronaThresholdSign drives Round1 → Round2 → Finalize for all
|
||||
// n signers in-process. The canonical signer set is [0..n) — corona
|
||||
// requires distinct integer indices and we honour that. The PRF key
|
||||
// is sha256(GroupKey bytes || jobID) so distinct sign calls bind
|
||||
// distinct sessions.
|
||||
func (s *Signer) runCoronaThresholdSign(jobID [32]byte, msg []byte, shares []*coronaThreshold.KeyShare, gk *coronaThreshold.GroupKey) ([]byte, error) {
|
||||
n := len(shares)
|
||||
signerIDs := make([]int, n)
|
||||
for i := range signerIDs {
|
||||
signerIDs[i] = i
|
||||
}
|
||||
|
||||
gkBytes, err := gk.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gk.MarshalBinary: %w", err)
|
||||
}
|
||||
prfDigest := sha256.New()
|
||||
prfDigest.Write(gkBytes)
|
||||
prfDigest.Write(jobID[:])
|
||||
prfKey := prfDigest.Sum(nil)
|
||||
|
||||
sessionID := sessionIDFromJobID(jobID)
|
||||
|
||||
signers := make([]*coronaThreshold.Signer, n)
|
||||
for i := 0; i < n; i++ {
|
||||
signers[i] = coronaThreshold.NewSigner(shares[i])
|
||||
}
|
||||
|
||||
r1Data := make(map[int]*coronaThreshold.Round1Data, n)
|
||||
for _, signer := range signers {
|
||||
r1 := signer.Round1(sessionID, prfKey, signerIDs)
|
||||
r1Data[r1.PartyID] = r1
|
||||
}
|
||||
|
||||
msgStr := string(msg)
|
||||
r2Data := make(map[int]*coronaThreshold.Round2Data, n)
|
||||
for _, signer := range signers {
|
||||
r2, err := signer.Round2(sessionID, msgStr, prfKey, signerIDs, r1Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("round2: %w", err)
|
||||
}
|
||||
r2Data[r2.PartyID] = r2
|
||||
}
|
||||
|
||||
sig, err := signers[0].Finalize(r2Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finalize: %w", err)
|
||||
}
|
||||
|
||||
if !coronaThreshold.Verify(gk, msgStr, sig) {
|
||||
return nil, fmt.Errorf("self-verify failed (kernel bug)")
|
||||
}
|
||||
|
||||
wire, err := sig.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sig.MarshalBinary: %w", err)
|
||||
}
|
||||
return wire, nil
|
||||
}
|
||||
|
||||
// SignReceipt is the audit blob returned alongside the corona
|
||||
// threshold signature.
|
||||
type SignReceipt struct {
|
||||
JobID [32]byte
|
||||
Epoch uint64
|
||||
IssuedNonce [32]byte
|
||||
EphemeralPub [32]byte
|
||||
EvidenceKind string
|
||||
EvidenceIssuer string
|
||||
AuditSignature []byte
|
||||
}
|
||||
|
||||
func (s *Signer) auditSignature(ctx context.Context, jobID [32]byte, msg []byte, epoch uint64, rim [32]byte) ([]byte, error) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("LUX-RLWE-TEE-AUDIT-V1"))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(jobID[:])
|
||||
h.Write(epochBytes(epoch))
|
||||
h.Write(rim[:])
|
||||
d := sha256.Sum256(msg)
|
||||
h.Write(d[:])
|
||||
auditDigest := h.Sum(nil)
|
||||
return s.hsmP.Sign(ctx, s.cfg.KMSKeyID, auditDigest)
|
||||
}
|
||||
|
||||
func epochBytes(e uint64) []byte {
|
||||
return []byte{
|
||||
byte(e >> 56), byte(e >> 48), byte(e >> 40), byte(e >> 32),
|
||||
byte(e >> 24), byte(e >> 16), byte(e >> 8), byte(e),
|
||||
}
|
||||
}
|
||||
|
||||
func evidenceIssuerString(env *Envelope) string {
|
||||
switch is := env.EvidenceIssuers(); len(is) {
|
||||
case 0:
|
||||
return ""
|
||||
default:
|
||||
return is[0]
|
||||
}
|
||||
}
|
||||
|
||||
// signIntent satisfies approval.CanonicalIntent.
|
||||
type signIntent struct {
|
||||
jobID [32]byte
|
||||
msg []byte
|
||||
env envelopeSummary
|
||||
}
|
||||
|
||||
type envelopeSummary struct {
|
||||
Kind string
|
||||
RIM [32]byte
|
||||
Hardware [32]byte
|
||||
TEEPub [32]byte
|
||||
}
|
||||
|
||||
func newSignIntent(jobID [32]byte, msg []byte, env *Envelope) *signIntent {
|
||||
return &signIntent{
|
||||
jobID: jobID,
|
||||
msg: append([]byte(nil), msg...),
|
||||
env: envelopeSummary{
|
||||
Kind: string(env.Kind),
|
||||
RIM: env.RIM,
|
||||
Hardware: env.Hardware,
|
||||
TEEPub: env.TEEPub,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (si *signIntent) Digest() [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("LUX-RLWE-TEE-INTENT-V1"))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(si.jobID[:])
|
||||
mdigest := sha256.Sum256(si.msg)
|
||||
h.Write(mdigest[:])
|
||||
h.Write([]byte(si.env.Kind))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(si.env.RIM[:])
|
||||
h.Write(si.env.Hardware[:])
|
||||
h.Write(si.env.TEEPub[:])
|
||||
var out [32]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
func (si *signIntent) Bytes() []byte {
|
||||
out := make([]byte, 0, 32+32+len(si.env.Kind)+1+32+32+32+32)
|
||||
out = append(out, []byte("LUX-RLWE-TEE-INTENT-V1")...)
|
||||
out = append(out, 0x00)
|
||||
out = append(out, si.jobID[:]...)
|
||||
mdigest := sha256.Sum256(si.msg)
|
||||
out = append(out, mdigest[:]...)
|
||||
out = append(out, []byte(si.env.Kind)...)
|
||||
out = append(out, 0x00)
|
||||
out = append(out, si.env.RIM[:]...)
|
||||
out = append(out, si.env.Hardware[:]...)
|
||||
out = append(out, si.env.TEEPub[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
var _ approval.CanonicalIntent = (*signIntent)(nil)
|
||||
|
||||
// FreshJobID returns 32 bytes of crypto/rand.
|
||||
func FreshJobID() ([32]byte, error) {
|
||||
var out [32]byte
|
||||
if _, err := rand.Read(out[:]); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rlwetee
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
coronaThreshold "github.com/luxfi/corona/threshold"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// coronaSerializer serializes every call into corona.threshold.
|
||||
//
|
||||
// corona.threshold.GenerateKeys writes package-level globals
|
||||
// (sign.K, sign.Threshold) — see corona/threshold/threshold.go:122-124.
|
||||
// Two concurrent callers stomp on those globals and produce
|
||||
// out-of-bounds slice errors deep in primitives.ShamirSecretSharing.
|
||||
//
|
||||
// Until corona makes its threshold API stateless we wrap every entry
|
||||
// point with this mutex. This is an upstream-constraint workaround,
|
||||
// not a security boundary; the mutex is package-level because the
|
||||
// shared state is package-level.
|
||||
var coronaSerializer sync.Mutex
|
||||
|
||||
// Signer is the institutional-custody Ring-LWE signer.
|
||||
//
|
||||
// Composition:
|
||||
//
|
||||
// - gate : kms.ReleaseGate — trust root.
|
||||
// - hsmP : hsm.Provider — wraps the master trusted-dealer key.
|
||||
// - appr : approval.ApprovalProvider — out-of-band approval gate.
|
||||
// - cfg : Config — policy: RIM, hardware, t-of-n, key IDs.
|
||||
//
|
||||
// Safe for concurrent Sign calls.
|
||||
type Signer struct {
|
||||
gate kms.ReleaseGate
|
||||
hsmP hsm.Provider
|
||||
appr approval.ApprovalProvider
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex // reserved for future per-Signer rate-limit state
|
||||
}
|
||||
|
||||
// New builds a Signer.
|
||||
func New(gate kms.ReleaseGate, hsmP hsm.Provider, appr approval.ApprovalProvider, cfg Config) (*Signer, error) {
|
||||
if gate == nil {
|
||||
return nil, errors.New("rlwe-tee: nil release gate")
|
||||
}
|
||||
if hsmP == nil {
|
||||
return nil, errors.New("rlwe-tee: nil HSM provider")
|
||||
}
|
||||
if cfg.ApprovalRequired && appr == nil {
|
||||
return nil, errors.New("rlwe-tee: nil approval provider but ApprovalRequired is true")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Signer{
|
||||
gate: gate,
|
||||
hsmP: hsmP,
|
||||
appr: appr,
|
||||
cfg: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Provision wraps a fresh MasterKeySize-byte trusted-dealer key under
|
||||
// the HSM provider for later release-gated signing.
|
||||
//
|
||||
// The master key is generated from crypto/rand and stored via
|
||||
// hsmP.StoreKey under cfg.WrappedSeedKeyID. We then derive the
|
||||
// GroupKey once to return to the caller — embedders register that as
|
||||
// the canonical group public key on the wire.
|
||||
func (s *Signer) Provision(ctx context.Context) (*coronaThreshold.GroupKey, error) {
|
||||
key := make([]byte, MasterKeySize)
|
||||
defer zeroize(key)
|
||||
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("rlwe-tee: provision: entropy: %w", err)
|
||||
}
|
||||
|
||||
if err := s.hsmP.StoreKey(ctx, s.cfg.WrappedSeedKeyID, key); err != nil {
|
||||
return nil, fmt.Errorf("rlwe-tee: provision: HSM StoreKey: %w", err)
|
||||
}
|
||||
|
||||
// Materialize the GroupKey by re-running the deterministic dealer
|
||||
// path with the fresh master key seeding the entropy. corona's
|
||||
// GenerateKeys reads sign.KeySize bytes from randSource — pass a
|
||||
// bytes.Reader so the run is reproducible. coronaSerializer
|
||||
// guards the corona-internal global writes.
|
||||
coronaSerializer.Lock()
|
||||
_, gk, err := coronaThreshold.GenerateKeys(s.cfg.Threshold, s.cfg.Participants, bytes.NewReader(key))
|
||||
coronaSerializer.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rlwe-tee: provision: GenerateKeys: %w", err)
|
||||
}
|
||||
return gk, nil
|
||||
}
|
||||
|
||||
// PublicKey reads the master key via the HSM provider and derives
|
||||
// the corona GroupKey deterministically. RELEASE-GATE FREE — only
|
||||
// the at-rest HSM material is read.
|
||||
func (s *Signer) PublicKey(ctx context.Context) (*coronaThreshold.GroupKey, error) {
|
||||
key, err := s.hsmP.GetKey(ctx, s.cfg.WrappedSeedKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rlwe-tee: PublicKey: HSM GetKey: %w", err)
|
||||
}
|
||||
defer zeroize(key)
|
||||
if len(key) != MasterKeySize {
|
||||
return nil, fmt.Errorf("rlwe-tee: PublicKey: key length %d does not match MasterKeySize %d", len(key), MasterKeySize)
|
||||
}
|
||||
coronaSerializer.Lock()
|
||||
_, gk, err := coronaThreshold.GenerateKeys(s.cfg.Threshold, s.cfg.Participants, bytes.NewReader(key))
|
||||
coronaSerializer.Unlock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rlwe-tee: PublicKey: GenerateKeys: %w", err)
|
||||
}
|
||||
return gk, nil
|
||||
}
|
||||
|
||||
// Threshold reports the corona t.
|
||||
func (s *Signer) Threshold() int { return s.cfg.Threshold }
|
||||
|
||||
// Participants reports the corona n.
|
||||
func (s *Signer) Participants() int { return s.cfg.Participants }
|
||||
|
||||
// auditedRelease drives the full Issue → approval → composite envelope
|
||||
// → Release flow.
|
||||
func (s *Signer) auditedRelease(ctx context.Context, env *Envelope, jobID [32]byte, msg []byte) (kms.SealedSessionKey, error) {
|
||||
if env == nil {
|
||||
return kms.SealedSessionKey{}, ErrAttestationRequired
|
||||
}
|
||||
|
||||
if s.cfg.ApprovalRequired {
|
||||
intent := newSignIntent(jobID, msg, env)
|
||||
sig, err := s.appr.ApproveIntent(ctx, s.cfg.ApproverID, intent)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: %v", ErrApprovalDenied, err)
|
||||
}
|
||||
ok, err := s.appr.VerifyApproval(ctx, intent, sig)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: verify: %v", ErrApprovalDenied, err)
|
||||
}
|
||||
if !ok {
|
||||
return kms.SealedSessionKey{}, ErrApprovalDenied
|
||||
}
|
||||
}
|
||||
|
||||
nonce, epoch, err := s.gate.Issue(jobID)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: gate.Issue: %v", ErrKMSReleaseUnreachable, err)
|
||||
}
|
||||
env.ExpectedNonce = nonce
|
||||
|
||||
sealed, err := s.gate.Release(kms.ReleaseRequest{
|
||||
JobID: jobID,
|
||||
Epoch: epoch,
|
||||
Nonce: nonce,
|
||||
Attestation: env,
|
||||
Ctx: ctx,
|
||||
})
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: %v", ErrPolicyRefused, err)
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
func zeroize(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// sessionIDFromJobID folds a 32-byte jobID into the small int the
|
||||
// corona kernel's session-ID field needs. corona.threshold's session
|
||||
// ID is `int`, so we take the first 31 bits of sha256(jobID) — strips
|
||||
// the sign bit to stay in non-negative int range on all platforms.
|
||||
func sessionIDFromJobID(jobID [32]byte) int {
|
||||
d := sha256.Sum256(jobID[:])
|
||||
v := int(d[0])<<23 | int(d[1])<<15 | int(d[2])<<7 | int(d[3]>>1)
|
||||
return v
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rlwetee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sevtest "github.com/google/go-sev-guest/testing"
|
||||
"github.com/google/go-sev-guest/verify/trust"
|
||||
|
||||
coronaThreshold "github.com/luxfi/corona/threshold"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Real AMD Milan SEV-SNP attestation fixtures (same bytes as the
|
||||
// lux/mpc cc/attest test corpus).
|
||||
//
|
||||
//go:embed testdata/sev_snp_attestation_milan.bin
|
||||
var sevSnpAttestationMilan []byte
|
||||
|
||||
//go:embed testdata/sev_snp_vcek_milan.cer
|
||||
var sevSnpVcekMilan []byte
|
||||
|
||||
func newKDSReplay() trust.HTTPSGetter {
|
||||
return sevtest.SimpleGetter(map[string][]byte{
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/cert_chain": trust.AskArkMilanVcekBytes,
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/3ac3fe21e13fb0990eb28a802e3fb6a29483a6b0753590c951bdd3b8e53786184ca39e359669a2b76a1936776b564ea464cdce40c05f63c9b610c5068b006b5d?blSPL=2&teeSPL=0&snpSPL=5&ucodeSPL=68": sevSnpVcekMilan,
|
||||
})
|
||||
}
|
||||
|
||||
func fixedNow() time.Time {
|
||||
return time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func realMeasurement() []byte { return sevSnpAttestationMilan[0x90 : 0x90+48] }
|
||||
func realChipID() []byte { return sevSnpAttestationMilan[0x1A0 : 0x1A0+64] }
|
||||
|
||||
func makeRIM(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
return sha256.Sum256(realMeasurement())
|
||||
}
|
||||
|
||||
func makeHardware(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
return sha256.Sum256(realChipID())
|
||||
}
|
||||
|
||||
func makeTEEPub(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
var priv [32]byte
|
||||
for i := range priv {
|
||||
priv[i] = byte(i + 1)
|
||||
}
|
||||
priv[0] &= 248
|
||||
priv[31] &= 127
|
||||
priv[31] |= 64
|
||||
pub, err := curve25519BasepointMul(priv[:])
|
||||
if err != nil {
|
||||
t.Fatalf("makeTEEPub: %v", err)
|
||||
}
|
||||
var out [32]byte
|
||||
copy(out[:], pub)
|
||||
return out
|
||||
}
|
||||
|
||||
func newTestFileHSM(t *testing.T) hsm.Provider {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := &hsm.FileConfig{
|
||||
BasePath: dir,
|
||||
HexEncoded: true,
|
||||
}
|
||||
p, err := hsm.NewFileProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestFileHSM: %v", err)
|
||||
}
|
||||
var ed25519Seed [32]byte
|
||||
if _, err := rand.Read(ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("ed25519 seed: %v", err)
|
||||
}
|
||||
if err := p.StoreKey(context.Background(), "audit-key", ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("store audit key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = p.Close()
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
func newTestApprovalProvider(t *testing.T) approval.ApprovalProvider {
|
||||
t.Helper()
|
||||
p, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestApprovalProvider: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestMain enables the LocalDevProvider for the lifetime of this
|
||||
// test binary.
|
||||
func TestMain(m *testing.M) {
|
||||
_ = os.Setenv("MPC_LOCAL_APPROVAL", "true")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// denyApprovalProvider models user-cancel from a real WebAuthn/Ledger.
|
||||
type denyApprovalProvider struct{}
|
||||
|
||||
func (denyApprovalProvider) Provider() string { return "deny-test" }
|
||||
func (denyApprovalProvider) GetPublicIdentity(_ context.Context, approverID string) (approval.PublicIdentity, error) {
|
||||
return approval.PublicIdentity{
|
||||
ApproverID: approverID,
|
||||
Provider: "deny-test",
|
||||
PublicKey: make([]byte, 32),
|
||||
Algorithm: approval.AlgorithmEd25519,
|
||||
}, nil
|
||||
}
|
||||
func (denyApprovalProvider) ApproveIntent(_ context.Context, approverID string, intent approval.CanonicalIntent) (approval.ApprovalSignature, error) {
|
||||
return approval.ApprovalSignature{}, errors.New("deny-test: user cancelled")
|
||||
}
|
||||
func (denyApprovalProvider) VerifyApproval(_ context.Context, intent approval.CanonicalIntent, sig approval.ApprovalSignature) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func newTestGate(t *testing.T, rim, hw [32]byte) (*kms.LocalReleaseGate, kms.NonceStore) {
|
||||
t.Helper()
|
||||
policy := kms.NewReleasePolicy([][32]byte{rim}, [][32]byte{hw})
|
||||
policy.RequireSEVSNP = true
|
||||
|
||||
var rootKey [32]byte
|
||||
if _, err := rand.Read(rootKey[:]); err != nil {
|
||||
t.Fatalf("rootKey: %v", err)
|
||||
}
|
||||
store := kms.NewMemoryNonceStore()
|
||||
gate, err := kms.NewLocalReleaseGate(policy, store, rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalReleaseGate: %v", err)
|
||||
}
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
gate.SetReplayWindow(5 * time.Second)
|
||||
return gate, store
|
||||
}
|
||||
|
||||
func newTestSigner(t *testing.T, approvalRequired bool) (*Signer, *kms.LocalReleaseGate, hsm.Provider, [32]byte, [32]byte) {
|
||||
t.Helper()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
|
||||
gate, _ := newTestGate(t, rim, hw)
|
||||
hsmP := newTestFileHSM(t)
|
||||
appr := newTestApprovalProvider(t)
|
||||
|
||||
cfg := Config{
|
||||
Threshold: 2,
|
||||
Participants: 3,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-key",
|
||||
ApprovalRequired: approvalRequired,
|
||||
ApproverID: "test@lux.network",
|
||||
}
|
||||
s, err := New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := s.Provision(context.Background()); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
return s, gate, hsmP, rim, hw
|
||||
}
|
||||
|
||||
func envelopeFromTestdata(t *testing.T, rim, hw, teePub [32]byte) *Envelope {
|
||||
t.Helper()
|
||||
return &Envelope{
|
||||
Kind: attest.KindSEVSNP,
|
||||
EvidenceBytes: append([]byte(nil), sevSnpAttestationMilan...),
|
||||
RIM: rim,
|
||||
Hardware: hw,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: []attest.Option{
|
||||
attest.WithKDSGetter(newKDSReplay()),
|
||||
attest.WithNow(fixedNow()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 1: full chain E2E
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_SEVSNP_E2E(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, true)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
jobID, err := FreshJobID()
|
||||
if err != nil {
|
||||
t.Fatalf("FreshJobID: %v", err)
|
||||
}
|
||||
msg := []byte("LUX-RLWE-TEE: institutional-custody E2E test")
|
||||
|
||||
wire, receipt, err := s.Sign(context.Background(), env, jobID, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Sign returned empty wire bytes")
|
||||
}
|
||||
if receipt == nil {
|
||||
t.Fatal("Sign returned nil receipt")
|
||||
}
|
||||
if receipt.JobID != jobID {
|
||||
t.Errorf("receipt.JobID = %x, want %x", receipt.JobID, jobID)
|
||||
}
|
||||
if receipt.EvidenceKind != string(attest.KindSEVSNP) {
|
||||
t.Errorf("receipt.EvidenceKind = %q", receipt.EvidenceKind)
|
||||
}
|
||||
if receipt.EvidenceIssuer != kms.IssuerSEVSNP {
|
||||
t.Errorf("receipt.EvidenceIssuer = %q", receipt.EvidenceIssuer)
|
||||
}
|
||||
if len(receipt.AuditSignature) == 0 {
|
||||
t.Error("receipt.AuditSignature empty")
|
||||
}
|
||||
|
||||
gk, err := s.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
gkBytes, err := gk.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("gk.MarshalBinary: %v", err)
|
||||
}
|
||||
if !coronaThreshold.VerifyBytes(gkBytes, string(msg), wire) {
|
||||
t.Fatal("external VerifyBytes refused the corona threshold signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 2: rejects corrupt attestation
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_RejectsBadAttestation(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
env.EvidenceBytes[0x2A0+0x10] ^= 0x01
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("reject-bad-evidence")
|
||||
_, _, err := s.Sign(context.Background(), env, jobID, msg)
|
||||
if err == nil {
|
||||
t.Fatal("Sign: expected refusal on tampered evidence, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPolicyRefused) {
|
||||
t.Errorf("Sign: err = %v, want wrapped ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 3: rejects RIM mismatch
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_RejectsRIMMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, _, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
|
||||
wrongRIM := sha256.Sum256([]byte("not-the-real-measurement"))
|
||||
env := envelopeFromTestdata(t, wrongRIM, hw, teePub)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("reject-wrong-rim")
|
||||
_, _, err := s.Sign(context.Background(), env, jobID, msg)
|
||||
if err == nil {
|
||||
t.Fatal("Sign: expected refusal on RIM mismatch, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPolicyRefused) {
|
||||
t.Errorf("Sign: err = %v, want wrapped ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 4: rejects expired nonce / wrong epoch
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_RejectsExpiredNonce(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, gate, _, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
gate.SetIssueTTL(10 * time.Millisecond)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
nonce, epoch, err := gate.Issue(jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
env.ExpectedNonce = nonce
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
_, releaseErr := gate.Release(kms.ReleaseRequest{
|
||||
JobID: jobID, Epoch: epoch, Nonce: nonce, Attestation: env, Ctx: context.Background(),
|
||||
})
|
||||
if releaseErr == nil {
|
||||
t.Fatal("gate.Release: expected expiry refusal, got nil")
|
||||
}
|
||||
if !errors.Is(releaseErr, kms.ErrPolicyRefused) {
|
||||
t.Errorf("releaseErr = %v, want wrapped kms.ErrPolicyRefused", releaseErr)
|
||||
}
|
||||
if !errors.Is(releaseErr, kms.ErrExpired) {
|
||||
t.Errorf("releaseErr = %v, want wrapped kms.ErrExpired", releaseErr)
|
||||
}
|
||||
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
_ = gate.Rotate()
|
||||
freshJob, _ := FreshJobID()
|
||||
freshEnv := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
msg := []byte("post-rotation-sign")
|
||||
if _, _, err := s.Sign(context.Background(), freshEnv, freshJob, msg); err != nil {
|
||||
t.Fatalf("Sign post-rotation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 5: AWS KMS backend
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_HSMSign_AWS_KMS(t *testing.T) {
|
||||
endpoint := os.Getenv("AWS_ENDPOINT_URL_KMS")
|
||||
keyARN := os.Getenv("AWS_KMS_TEST_KEY_ARN")
|
||||
if endpoint == "" || keyARN == "" {
|
||||
t.Skip("AWS_ENDPOINT_URL_KMS and AWS_KMS_TEST_KEY_ARN not set; localstack KMS not available — see test comment for setup. Skipped per spec rationale: unit CI must not require real AWS credentials. File provider path is exercised by TestSigner_Sign_HSMSign_File and all chain-verify tests.")
|
||||
}
|
||||
|
||||
awsCfg := &hsm.AWSConfig{
|
||||
Region: os.Getenv("AWS_REGION"),
|
||||
KeyARN: keyARN,
|
||||
Profile: os.Getenv("AWS_PROFILE"),
|
||||
}
|
||||
awsP, err := hsm.NewAWSProvider(awsCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAWSProvider: %v", err)
|
||||
}
|
||||
defer awsP.Close()
|
||||
|
||||
digest := sha256.Sum256([]byte("aws-kms-audit-probe"))
|
||||
sig, err := awsP.Sign(context.Background(), keyARN, digest[:])
|
||||
if err != nil {
|
||||
t.Fatalf("AWS KMS Sign: %v", err)
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
t.Fatal("AWS KMS Sign returned empty signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 6: File-backed HSM end-to-end
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_HSMSign_File(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, true)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("file-hsm-e2e")
|
||||
wire, _, err := s.Sign(context.Background(), env, jobID, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
gk, err := s.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
gkBytes, err := gk.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("gk.MarshalBinary: %v", err)
|
||||
}
|
||||
if !coronaThreshold.VerifyBytes(gkBytes, string(msg), wire) {
|
||||
t.Fatal("file-hsm-e2e: VerifyBytes refused corona threshold signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 7: WebAuthn-style approval required
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_Sign_ApprovalRequired_DenyAndAllow(t *testing.T) {
|
||||
t.Parallel()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
gate, store := newTestGate(t, rim, hw)
|
||||
fileP := newTestFileHSM(t)
|
||||
|
||||
cfg := Config{
|
||||
Threshold: 2,
|
||||
Participants: 3,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-key",
|
||||
ApprovalRequired: true,
|
||||
ApproverID: "ceo@lux.network",
|
||||
}
|
||||
|
||||
denyS, err := New(gate, fileP, denyApprovalProvider{}, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New(deny): %v", err)
|
||||
}
|
||||
if _, err := denyS.Provision(context.Background()); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("approval-deny-test")
|
||||
_, _, err = denyS.Sign(context.Background(), env, jobID, msg)
|
||||
if err == nil {
|
||||
t.Fatal("Sign(deny): expected ErrApprovalDenied, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrApprovalDenied) {
|
||||
t.Errorf("Sign(deny): err = %v, want wrapped ErrApprovalDenied", err)
|
||||
}
|
||||
if _, lookupErr := store.Lookup(jobID); !errors.Is(lookupErr, kms.ErrNonceUnknown) {
|
||||
t.Errorf("deny path leaked a gate-issued nonce: %v", lookupErr)
|
||||
}
|
||||
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev provider: %v", err)
|
||||
}
|
||||
allowS, err := New(gate, fileP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New(allow): %v", err)
|
||||
}
|
||||
allowEnv := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
allowJob, _ := FreshJobID()
|
||||
if _, _, err := allowS.Sign(context.Background(), allowEnv, allowJob, msg); err != nil {
|
||||
t.Fatalf("Sign(allow): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config validation
|
||||
// ============================================================================
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
rim := [32]byte{1}
|
||||
hw := [32]byte{2}
|
||||
good := Config{
|
||||
Threshold: 2,
|
||||
Participants: 3,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "k",
|
||||
WrappedSeedKeyID: "s",
|
||||
}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Fatalf("good: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
want error
|
||||
}{
|
||||
{"thresholdZero", func(c *Config) { c.Threshold = 0 }, ErrInvalidThreshold},
|
||||
{"thresholdEqualParticipants", func(c *Config) { c.Threshold = 3 }, ErrInvalidThreshold},
|
||||
{"participantsTooLow", func(c *Config) { c.Threshold = 1; c.Participants = 1 }, ErrInvalidThreshold},
|
||||
{"emptyRIM", func(c *Config) { c.RequiredRIM = nil }, ErrEmptyRIM},
|
||||
{"emptyHardware", func(c *Config) { c.AllowedHardware = nil }, ErrEmptyHardware},
|
||||
{"noRequireFlag", func(c *Config) { c.RequireSEVSNP = false }, ErrNoRequireFlag},
|
||||
{"missingKMSKeyID", func(c *Config) { c.KMSKeyID = "" }, ErrMissingKMSKeyID},
|
||||
{"missingSeedKeyID", func(c *Config) { c.WrappedSeedKeyID = "" }, ErrMissingSeedKeyID},
|
||||
{"approverMissing", func(c *Config) { c.ApprovalRequired = true; c.ApproverID = "" }, ErrApproverMissing},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := good
|
||||
c.RequiredRIM = map[[32]byte]struct{}{rim: {}}
|
||||
c.AllowedHardware = map[[32]byte]struct{}{hw: {}}
|
||||
tc.mutate(&c)
|
||||
err := c.Validate()
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigner_ThresholdAndParticipants(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, _, _ := newTestSigner(t, false)
|
||||
if got := s.Threshold(); got != 2 {
|
||||
t.Errorf("Threshold = %d, want 2", got)
|
||||
}
|
||||
if got := s.Participants(); got != 3 {
|
||||
t.Errorf("Participants = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,132 +0,0 @@
|
||||
# slhdsa-tee
|
||||
|
||||
Operator-controlled SLH-DSA threshold signing via TEE-gated master-seed
|
||||
reconstruction.
|
||||
|
||||
## What this is
|
||||
|
||||
A composable Go package that produces FIPS 205 SLH-DSA signatures
|
||||
gated on:
|
||||
|
||||
1. A verifiable hardware TEE attestation (AMD SEV-SNP, Intel TDX, NVIDIA NRAS)
|
||||
chain-validated by `github.com/luxfi/mpc/cc/attest`.
|
||||
2. A KMS release-gate (`github.com/luxfi/mpc/pkg/kms.ReleaseGate`) that pins
|
||||
the worker's RIM digest + hardware fingerprint and binds a single-use
|
||||
challenge nonce per request.
|
||||
3. An out-of-band human or programmatic approval signature
|
||||
(`github.com/luxfi/mpc/pkg/approval.ApprovalProvider`).
|
||||
4. An HSM-resident wrap-key store (`github.com/luxfi/mpc/pkg/hsm.Provider`)
|
||||
so the master SLH-DSA seed lives sealed-at-rest and is only ever
|
||||
unwrapped inside the attested TEE.
|
||||
|
||||
Output is byte-identical to single-party FIPS 205 `SignDeterministic` on
|
||||
the same `(seed-derived sk, msg, ctx)` tuple. Any verifier holding the
|
||||
published MAGG-framed group public key validates with
|
||||
`magnetar.VerifyBytes` — no awareness of the threshold or TEE substrate
|
||||
is required.
|
||||
|
||||
## When to use this
|
||||
|
||||
- **M-Chain bridge custody**: an operator holds the bridge's signing key
|
||||
inside a SEV-SNP / TDX TEE. Per-redemption sign calls require an
|
||||
attestation envelope plus an executive approval. The master seed
|
||||
never leaves the HSM in plaintext outside the attested TEE context.
|
||||
- **A-Chain confidential-compute oracle**: an oracle node produces
|
||||
SLH-DSA attestations over confidential model outputs. The output is
|
||||
gated on the worker's RIM matching a known-good measurement
|
||||
allowlist.
|
||||
|
||||
## When NOT to use this
|
||||
|
||||
- **Public-BFT consensus**: use the per-validator standalone
|
||||
`magnetar.ValidatorSign` path (canonical v0.5+). No DKG, no dealer,
|
||||
no aggregator-in-TCB.
|
||||
- **Permissionless threshold custody**: use `magnetar.Combine`
|
||||
(THBS-SE, v0.1 reveal-and-aggregate) — no party ever holds the master
|
||||
seed, so there is no TCB to attest.
|
||||
- **Test / dev with no TEE hardware available**: use
|
||||
`magnetar.GenerateKey` + `magnetar.Sign` directly.
|
||||
|
||||
## Decision matrix
|
||||
|
||||
| Use case | Primitive |
|
||||
|----------|-----------|
|
||||
| Public-BFT validator | `magnetar.ValidatorSign` (PRIMARY) |
|
||||
| Permissionless N-of-N custody, no party holds seed | `magnetar.Combine` (THBS-SE) |
|
||||
| Institutional custody with attested release | `slhdsa-tee.Signer.Sign` (THIS) |
|
||||
| Single-party / dev | `magnetar.Sign` |
|
||||
|
||||
## Layering
|
||||
|
||||
```
|
||||
caller (operator daemon, bridge node, custody orchestrator)
|
||||
└── slhdsatee.Signer.Sign(ctx, env, jobID, msg, signCtx)
|
||||
├── approval.ApprovalProvider.ApproveIntent (out-of-band gate)
|
||||
├── kms.ReleaseGate.Issue / Release (TEE-gated wrap key)
|
||||
│ └── cc/attest.Dispatch (vendor chain verify)
|
||||
├── hsm.Provider.GetKey (wrapped seed at rest)
|
||||
├── magnetar.KeyFromSeed → magnetar.Sign (FIPS 205 emit)
|
||||
└── hsm.Provider.Sign (audit signature)
|
||||
```
|
||||
|
||||
Each step is independently complete and replaceable.
|
||||
|
||||
## Threat model
|
||||
|
||||
- **Compromised operator binary outside TEE**: refused — no valid
|
||||
attestation possible.
|
||||
- **Forged attestation envelope (chain-invalid)**: refused at
|
||||
`cc/attest.Dispatch` → `kms.ErrAttestationChain`.
|
||||
- **Replay of an old sealed key**: refused — AAD binds (epoch, jobID,
|
||||
teePub, issuedNonce).
|
||||
- **Coerced approver**: detectable in the receipt's audit signature
|
||||
trail.
|
||||
- **Stolen master seed at rest**: protected by HSM root-of-trust (AWS
|
||||
KMS, Azure Key Vault, GCP Cloud KMS, YubiHSM, Zymbit, file with
|
||||
age-encryption).
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
import (
|
||||
slhdsatee "github.com/luxfi/threshold/protocols/slhdsa-tee"
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
)
|
||||
|
||||
policy := kms.NewReleasePolicyStrict([][32]byte{knownRIM}, [][32]byte{knownHW})
|
||||
store := kms.NewMemoryNonceStore() // or DatabaseNonceStore in prod
|
||||
gate, _ := kms.NewLocalReleaseGate(policy, store, rootKey)
|
||||
hsmP, _ := hsm.NewAWSProvider(awsCfg)
|
||||
appr, _ := approval.NewProvider("webauthn", webauthnCfg)
|
||||
|
||||
cfg := slhdsatee.Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: policy.RequiredRIM,
|
||||
AllowedHardware: policy.AllowedHardware,
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "arn:aws:kms:us-east-1:...:key/...",
|
||||
WrappedSeedKeyID: "lux-custody-master-seed",
|
||||
ApprovalRequired: true,
|
||||
ApproverID: "custody-ceo@org.example",
|
||||
}
|
||||
signer, _ := slhdsatee.New(gate, hsmP, appr, cfg)
|
||||
|
||||
env := &slhdsatee.Envelope{
|
||||
Kind: attest.KindSEVSNP,
|
||||
EvidenceBytes: liveAttestationBytes,
|
||||
RIM: knownRIM,
|
||||
Hardware: knownHW,
|
||||
TEEPub: workerTEEPub,
|
||||
}
|
||||
jobID, _ := slhdsatee.FreshJobID()
|
||||
sig, receipt, err := signer.Sign(ctx, env, jobID, msg, nil)
|
||||
```
|
||||
|
||||
## Dispatcher wiring
|
||||
|
||||
The `pkg/thresholdd` JSON-RPC dispatcher gains a `Sign_TEE` method on
|
||||
the magnetar scheme that calls this package. The default `Sign` method
|
||||
remains permissionless (per-validator standalone).
|
||||
@@ -1,123 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
)
|
||||
|
||||
// Config carries the operator-side policy + provider configuration.
|
||||
//
|
||||
// Every field is required; New refuses on any zero value. There is no
|
||||
// "default-friendly" path — institutional custody never starts with an
|
||||
// empty allowlist or an unset KMS root.
|
||||
type Config struct {
|
||||
// Mode pins the FIPS 205 parameter set. Production institutional
|
||||
// custody uses ModeM192s (recommended) or ModeM256s for the most
|
||||
// conservative posture. Mode is bound into Signer.params at
|
||||
// construction and into the keypair derived from the unwrapped
|
||||
// master seed.
|
||||
Mode magnetar.Mode
|
||||
|
||||
// RequiredRIM is the set of acceptable Reference-Integrity-Manifest
|
||||
// digests for the worker that holds the wrapped master seed. A
|
||||
// release-gate refusal here means the worker binary or the TEE
|
||||
// firmware does not match a known-good measurement.
|
||||
//
|
||||
// Wire bytes: SHA-256 of the canonical RIM document. Mirrors
|
||||
// luxfi/mpc/pkg/kms.ReleasePolicy.RequiredRIM.
|
||||
RequiredRIM map[[32]byte]struct{}
|
||||
|
||||
// AllowedHardware is the set of acceptable hardware-fingerprint
|
||||
// digests (sha256(model || driver || vbios) for GPU paths,
|
||||
// platform/chip ID for CPU-only).
|
||||
AllowedHardware map[[32]byte]struct{}
|
||||
|
||||
// RequireSEVSNP / RequireTDX / RequireNVNRAS mirror
|
||||
// kms.ReleasePolicy.Require* — at least one MUST be true for any
|
||||
// non-test deployment. The composite envelope produced here will
|
||||
// surface its evidence issuers via the kms.CompositeAttestation
|
||||
// interface so the gate's default-deny posture fires correctly.
|
||||
RequireSEVSNP bool
|
||||
RequireTDX bool
|
||||
RequireNVNRAS bool
|
||||
|
||||
// KMSKeyID is the HSM key identifier under which the wrapped
|
||||
// master seed is stored. The HSM provider's Sign API is not used
|
||||
// for the SLH-DSA inner-sign (FIPS 205 has no native HSM offload
|
||||
// today); we use it to delegate ANCILLARY ECDSA-P256 audit
|
||||
// signatures over the (jobID, msg, sealedKey.Epoch, RIM) tuple so
|
||||
// every release is independently auditable. The master seed
|
||||
// itself is stored as raw bytes via HSM Provider.GetKey /
|
||||
// StoreKey under WrappedSeedKeyID.
|
||||
KMSKeyID string
|
||||
|
||||
// WrappedSeedKeyID is the HSM-stored blob identifier for the
|
||||
// AEAD-wrapped master seed. The blob is opened ONLY inside the
|
||||
// TEE after gate.Release returns a sealed session key. The HSM
|
||||
// holds ciphertext; the TEE holds plaintext for the duration of
|
||||
// one sign call; the host process never sees plaintext.
|
||||
WrappedSeedKeyID string
|
||||
|
||||
// ApprovalRequired determines whether ApprovalProvider must
|
||||
// produce a non-deny ApprovalSignature before Issue() is called.
|
||||
// Production institutional custody MUST set this true. Test mode
|
||||
// may set false to exercise the chain-verify + release path in
|
||||
// isolation.
|
||||
ApprovalRequired bool
|
||||
|
||||
// ApproverID is the canonical identifier (email, DID, KMS ARN)
|
||||
// whose approval is required. Used as the lookup key against the
|
||||
// configured ApprovalProvider. Empty rejects when
|
||||
// ApprovalRequired is true.
|
||||
ApproverID string
|
||||
}
|
||||
|
||||
// Errors surfaced by Config.Validate. Distinguished by errors.Is so
|
||||
// callers (operator boot scripts, helm chart smoke tests) can switch.
|
||||
var (
|
||||
ErrInvalidMode = errors.New("slhdsa-tee: invalid magnetar mode")
|
||||
ErrEmptyRIM = errors.New("slhdsa-tee: RequiredRIM must be non-empty (default-deny posture)")
|
||||
ErrEmptyHardware = errors.New("slhdsa-tee: AllowedHardware must be non-empty (default-deny posture)")
|
||||
ErrNoRequireFlag = errors.New("slhdsa-tee: at least one Require* TEE flag must be true")
|
||||
ErrMissingKMSKeyID = errors.New("slhdsa-tee: KMSKeyID required for audit signature")
|
||||
ErrMissingSeedKeyID = errors.New("slhdsa-tee: WrappedSeedKeyID required for HSM seed storage")
|
||||
ErrApproverMissing = errors.New("slhdsa-tee: ApproverID required when ApprovalRequired is true")
|
||||
ErrApprovalDenied = errors.New("slhdsa-tee: approval provider denied or returned mismatched signature")
|
||||
ErrAttestationRequired = errors.New("slhdsa-tee: attestation envelope required (cannot sign without TEE evidence)")
|
||||
ErrPolicyRefused = errors.New("slhdsa-tee: release gate refused (RIM, hardware, nonce, or chain verify)")
|
||||
ErrKMSReleaseUnreachable = errors.New("slhdsa-tee: release gate unreachable")
|
||||
ErrHSMUnreachable = errors.New("slhdsa-tee: HSM provider unreachable")
|
||||
ErrCorruptWrappedSeed = errors.New("slhdsa-tee: wrapped seed blob fails authenticated decryption")
|
||||
)
|
||||
|
||||
// Validate reports the first structural error in cfg. There is no
|
||||
// "warnings" return — institutional-custody policy is hard or it is
|
||||
// nothing.
|
||||
func (cfg *Config) Validate() error {
|
||||
if _, err := magnetar.ParamsFor(cfg.Mode); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidMode, err)
|
||||
}
|
||||
if len(cfg.RequiredRIM) == 0 {
|
||||
return ErrEmptyRIM
|
||||
}
|
||||
if len(cfg.AllowedHardware) == 0 {
|
||||
return ErrEmptyHardware
|
||||
}
|
||||
if !cfg.RequireSEVSNP && !cfg.RequireTDX && !cfg.RequireNVNRAS {
|
||||
return ErrNoRequireFlag
|
||||
}
|
||||
if cfg.KMSKeyID == "" {
|
||||
return ErrMissingKMSKeyID
|
||||
}
|
||||
if cfg.WrappedSeedKeyID == "" {
|
||||
return ErrMissingSeedKeyID
|
||||
}
|
||||
if cfg.ApprovalRequired && cfg.ApproverID == "" {
|
||||
return ErrApproverMissing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import "golang.org/x/crypto/curve25519"
|
||||
|
||||
// curve25519BasepointMul is a test-only helper that returns the
|
||||
// public half of a clamped X25519 private scalar. Lives in a
|
||||
// _test.go file so the production package surface does not export it.
|
||||
func curve25519BasepointMul(priv []byte) ([]byte, error) {
|
||||
return curve25519.X25519(priv, curve25519.Basepoint)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package slhdsatee implements operator-controlled SLH-DSA threshold
|
||||
// signing via TEE-gated master-seed reconstruction.
|
||||
//
|
||||
// This is NOT a public-BFT primitive. Magnetar v0.5's per-validator
|
||||
// standalone path (and v0.1 reveal-and-aggregate THBS-SE for delegated
|
||||
// custody) is the canonical permissionless path; THIS package is the
|
||||
// institutional-custody-only extension that binds key release to:
|
||||
//
|
||||
// 1. a verifiable hardware TEE attestation (AMD SEV-SNP / Intel TDX /
|
||||
// NVIDIA NRAS) chain-validated by github.com/luxfi/mpc/cc/attest;
|
||||
// 2. a KMS release-gate (github.com/luxfi/mpc/pkg/kms.ReleaseGate)
|
||||
// that pins the worker's RIM digest + hardware fingerprint and
|
||||
// binds a single-use challenge nonce per-request;
|
||||
// 3. an out-of-band human / programmatic approval signature
|
||||
// (github.com/luxfi/mpc/pkg/approval.ApprovalProvider);
|
||||
// 4. an HSM-resident wrap-key store
|
||||
// (github.com/luxfi/mpc/pkg/hsm.Provider) so the master SLH-DSA
|
||||
// seed lives sealed-at-rest and is only ever unwrapped inside the
|
||||
// attested TEE.
|
||||
//
|
||||
// The sign call returns bytes byte-identical to single-party FIPS 205
|
||||
// SLH-DSA SignDeterministic on (master_seed → KeyFromSeed → Sign(msg,
|
||||
// ctx)). Any caller holding the published MAGG-framed group public key
|
||||
// can verify with magnetar.VerifyBytes (or Verify) — no awareness of
|
||||
// the threshold or TEE substrate is required.
|
||||
//
|
||||
// Threat model:
|
||||
//
|
||||
// - Adversary controls the operator process (compromised binary,
|
||||
// malicious operator) outside the TEE. Without a valid attestation
|
||||
// that chains to the pinned vendor root AND a fresh approval that
|
||||
// matches the RIM/hardware policy, no sign is possible. The HSM
|
||||
// never releases the master seed in plaintext — only the AEAD
|
||||
// ciphertext sealed to the gate-issued ephemeral pubkey can leave
|
||||
// the gate.
|
||||
// - Adversary recovers an old sealed key. AAD binds (epoch, jobID,
|
||||
// teePub, issuedNonce); replay across epoch or jobID is refused.
|
||||
// - Adversary forges an attestation envelope whose Verify(nonce)
|
||||
// returns true but whose evidence does not chain to the vendor
|
||||
// root. ReleaseGate.Release calls CompositeAttestation.VerifyEvidence
|
||||
// which dispatches every blob through cc/attest.Dispatch and
|
||||
// refuses on chain-invalid; this package's Envelope ties the cc/attest
|
||||
// verifier into that contract.
|
||||
//
|
||||
// What this package is NOT:
|
||||
//
|
||||
// - NOT a no-trusted-dealer DKG. The master seed is generated once
|
||||
// under TEE attestation; subsequent signs only release the wrapped
|
||||
// seed under the same attestation policy. A real DKG construction
|
||||
// for SLH-DSA is the magnetar package's THBS-SE family — see
|
||||
// magnetar/ref/go/pkg/magnetar/thbsse.go for the permissionless
|
||||
// primitive that produces a FIPS 205 byte-identical signature
|
||||
// without any party ever holding the master seed.
|
||||
//
|
||||
// - NOT a substitute for magnetar.ValidatorSign or magnetar.Combine
|
||||
// on the public-BFT consensus surface. Use this ONLY when the
|
||||
// threat model permits "trusted custody with attested release"
|
||||
// (e.g. M-Chain bridge custody operator, A-Chain confidential
|
||||
// compute oracle).
|
||||
//
|
||||
// Wire compatibility: output is a magnetar.Signature (mode default
|
||||
// ModeM192s) — the same wire form the dispatcher emits today. The
|
||||
// SDK / verifier path is unchanged.
|
||||
package slhdsatee
|
||||
@@ -1,178 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Envelope is the minimal kms.CompositeAttestation implementation this
|
||||
// package consumes. It wraps an attested evidence blob (the bytes the
|
||||
// worker captured via SNP_GUEST_REQUEST / TDREPORT ioctls / NRAS JWT)
|
||||
// plus the operator-supplied RIM digest, hardware fingerprint, and
|
||||
// TEE pubkey.
|
||||
//
|
||||
// One Envelope per Sign call — no caching. The release gate calls
|
||||
// Verify(nonce) (cheap nonce check) then VerifyEvidence(ctx, opts...)
|
||||
// (full chain check via cc/attest). Both must pass.
|
||||
//
|
||||
// Wire-form is intentionally NOT defined here: this is a per-process
|
||||
// composition. The bridge / oracle wire layer that ships attested
|
||||
// evidence between TEE and operator (or operator and KMS) is the
|
||||
// responsibility of the calling subsystem. Magnetar's THBS-SE is the
|
||||
// public-BFT wire spec; this package's Envelope is the institutional-
|
||||
// custody in-process composition.
|
||||
type Envelope struct {
|
||||
// Kind is the cc/attest evidence kind matching EvidenceBytes
|
||||
// framing. Used by Dispatch to route to the right verifier.
|
||||
Kind attest.Kind
|
||||
|
||||
// EvidenceBytes is the raw vendor-framed evidence (SEV-SNP report
|
||||
// ABI bytes, TDX TDREPORT bytes, NRAS JWT bytes).
|
||||
EvidenceBytes []byte
|
||||
|
||||
// ExpectedNonce is the gate-issued challenge nonce the TEE bound
|
||||
// into REPORT_DATA before capturing the evidence. Compared by
|
||||
// Verify against the kms-supplied expectedNonce.
|
||||
ExpectedNonce [32]byte
|
||||
|
||||
// RIM is the operator-asserted RIM digest the TEE measured. The
|
||||
// release gate compares RIM against ReleasePolicy.RequiredRIM.
|
||||
RIM [32]byte
|
||||
|
||||
// Hardware is the operator-asserted hardware fingerprint
|
||||
// (sha256(model||driver||vbios) or platform/chip ID).
|
||||
Hardware [32]byte
|
||||
|
||||
// TEEPub is the X25519 public key sealed against — the private
|
||||
// half lives only inside the attested TEE.
|
||||
TEEPub [32]byte
|
||||
|
||||
// VerifyOpts are forwarded to attest.Dispatch by VerifyEvidence
|
||||
// (e.g. WithKDSGetter for offline tests, WithNow for fixed-clock
|
||||
// reproducibility, WithExpectedMeasurement to pin the launch
|
||||
// digest beyond RIM membership).
|
||||
VerifyOpts []attest.Option
|
||||
|
||||
// rimChecker is an optional override used to pin the launch
|
||||
// measurement against the verified report. When nil the package
|
||||
// uses the default rule: VerifiedReport.Measurement must lie in
|
||||
// the operator-supplied RequiredRIM allowlist. This is the
|
||||
// orthogonal hook the dispatcher uses to wire RIM membership into
|
||||
// the cc/attest chain layer without duplicating policy in two
|
||||
// places.
|
||||
rimChecker func(*attest.VerifiedReport) error
|
||||
}
|
||||
|
||||
var _ kms.CompositeAttestation = (*Envelope)(nil)
|
||||
|
||||
// Verify implements kms.CompositeAttestation.Verify. Returns true iff
|
||||
// the gate-issued nonce equals the nonce the operator embedded in the
|
||||
// evidence at TEE-capture time.
|
||||
//
|
||||
// This is the cheap pre-chain check. It does NOT prove the evidence
|
||||
// chains to the vendor root — VerifyEvidence does that. The gate
|
||||
// calls both in order.
|
||||
func (e *Envelope) Verify(expectedNonce [32]byte) (bool, error) {
|
||||
if subtle.ConstantTimeCompare(e.ExpectedNonce[:], expectedNonce[:]) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyEvidence implements kms.CompositeAttestation.VerifyEvidence.
|
||||
//
|
||||
// Dispatches the single evidence blob to cc/attest, returns the
|
||||
// verified report on success, surfaces any chain / signature / policy
|
||||
// failure via ErrChainInvalid / ErrSignatureInvalid / ErrPolicy. The
|
||||
// gate translates these into ErrAttestationChain wrapped under
|
||||
// ErrPolicyRefused.
|
||||
//
|
||||
// Additionally enforces that the operator-asserted RIM equals the
|
||||
// VerifiedReport.Measurement after chain validation. This is the
|
||||
// composition step: the gate checks RIM membership in its allowlist;
|
||||
// VerifyEvidence checks the report Measurement equals the operator-
|
||||
// asserted RIM. Together: a chain-validated report whose Measurement
|
||||
// is in the gate's RequiredRIM set.
|
||||
func (e *Envelope) VerifyEvidence(ctx context.Context, opts ...attest.Option) ([]*attest.VerifiedReport, error) {
|
||||
if len(e.EvidenceBytes) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty evidence", attest.ErrInvalidEvidence)
|
||||
}
|
||||
allOpts := append([]attest.Option{}, e.VerifyOpts...)
|
||||
allOpts = append(allOpts, opts...)
|
||||
rep, err := attest.Dispatch(ctx, e.Kind, e.EvidenceBytes, allOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.rimChecker != nil {
|
||||
if err := e.rimChecker(rep); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := defaultRIMCheck(rep, e.RIM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return []*attest.VerifiedReport{rep}, nil
|
||||
}
|
||||
|
||||
// RIMDigest implements kms.CompositeAttestation.RIMDigest.
|
||||
func (e *Envelope) RIMDigest() [32]byte { return e.RIM }
|
||||
|
||||
// HardwareFingerprint implements kms.CompositeAttestation.HardwareFingerprint.
|
||||
func (e *Envelope) HardwareFingerprint() [32]byte { return e.Hardware }
|
||||
|
||||
// TEEPublicKey implements kms.CompositeAttestation.TEEPublicKey.
|
||||
func (e *Envelope) TEEPublicKey() [32]byte { return e.TEEPub }
|
||||
|
||||
// EvidenceIssuers implements kms.CompositeAttestation.EvidenceIssuers.
|
||||
//
|
||||
// One issuer per envelope (this construction binds one TEE quote per
|
||||
// sign request). If composite (CPU TEE + GPU NRAS) is required, the
|
||||
// operator MUST run Sign once per quorum member and combine results;
|
||||
// for the institutional-custody case here, one CPU TEE quote is the
|
||||
// canonical posture.
|
||||
func (e *Envelope) EvidenceIssuers() []string {
|
||||
switch e.Kind {
|
||||
case attest.KindSEVSNP:
|
||||
return []string{kms.IssuerSEVSNP}
|
||||
case attest.KindTDX:
|
||||
return []string{kms.IssuerTDX}
|
||||
case attest.KindNRAS:
|
||||
return []string{kms.IssuerNVNRAS}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRIMCheck verifies the cc/attest verified-report Measurement
|
||||
// matches the operator-asserted RIM digest. We sha256-fold the raw
|
||||
// measurement bytes (variable-length per evidence kind: 48 bytes for
|
||||
// SEV-SNP, MRTD for TDX, derived for NRAS) into the 32-byte RIM
|
||||
// digest convention used by the gate's allowlist.
|
||||
//
|
||||
// Folding is one-way and stable: sha256(measurement) is the operator-
|
||||
// asserted RIM identifier; the release gate's RequiredRIM is the
|
||||
// allowlist of such identifiers; the worker that captured this
|
||||
// evidence claimed to be measuring RIM X; defaultRIMCheck enforces
|
||||
// that X equals sha256(measurement) of the chain-validated report.
|
||||
//
|
||||
// Constant-time compare to avoid timing leaks on partial-RIM match.
|
||||
func defaultRIMCheck(rep *attest.VerifiedReport, expected [32]byte) error {
|
||||
if rep == nil {
|
||||
return errors.New("slhdsa-tee: defaultRIMCheck: nil verified report")
|
||||
}
|
||||
got := sha256.Sum256(rep.Measurement)
|
||||
if subtle.ConstantTimeCompare(got[:], expected[:]) != 1 {
|
||||
return fmt.Errorf("%w: report measurement does not fold to operator-asserted RIM", attest.ErrPolicy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
)
|
||||
|
||||
// CombinerPool is the t-of-n attested-combiner registry the strict-PQ
|
||||
// profile binds Combine through.
|
||||
//
|
||||
// Composition:
|
||||
//
|
||||
// - Each AttestedCombiner is one of (SEV-SNP / TDX / NRAS) and holds
|
||||
// a Signer + the last successful VerifiedReport's IssuedAt.
|
||||
// - The pool is parameterised by (Threshold, RotationWindow,
|
||||
// KnownIssuers, Now). Threshold is the t-of-n quorum required for
|
||||
// a Combine to surface a signature. RotationWindow bounds the age
|
||||
// of each combiner's attestation: a Combine call that finds any
|
||||
// selected combiner outside the window refuses with
|
||||
// ErrMagnetarStaleAttestation BEFORE reaching that combiner's
|
||||
// signer.
|
||||
// - KnownIssuers is the set of vendor strings the pool is willing to
|
||||
// accept under the active chain profile (e.g. {"amd.sev.snp",
|
||||
// "intel.tdx", "nvidia.nras.v1"}); a combiner whose verified-report
|
||||
// Vendor is not in this set is refused.
|
||||
//
|
||||
// Hickey discipline: pool state is one value; mutation goes through
|
||||
// the API; concurrent Combine calls observe the same snapshot for the
|
||||
// duration of one call (under RLock).
|
||||
type CombinerPool struct {
|
||||
mu sync.RWMutex
|
||||
threshold int
|
||||
rotation time.Duration
|
||||
issuers map[string]struct{}
|
||||
members []*PoolMember
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// PoolMember binds one attested combiner endpoint to its current
|
||||
// attestation freshness state. The Signer is the slhdsa-tee.Signer
|
||||
// that holds the wrapped seed for THIS combiner (one wrapped seed
|
||||
// per attested host); the LastVerifiedReport is what
|
||||
// pool.Attest() refreshes when the operator submits a new quote.
|
||||
type PoolMember struct {
|
||||
// Name is the operator-supplied identifier for this combiner
|
||||
// endpoint (e.g. "us-east-1a-snp-01"). Used in audit logs and to
|
||||
// disambiguate members in pool errors.
|
||||
Name string
|
||||
|
||||
// Signer is the slhdsa-tee.Signer wired to this combiner. The
|
||||
// pool delegates the actual Sign call to Signer.Sign once the
|
||||
// pool-level freshness gate has accepted the most recent quote.
|
||||
Signer *Signer
|
||||
|
||||
// mu protects LastReport / LastIssuedAt. Per-member lock so
|
||||
// Attest() on one member does not block Combine on another.
|
||||
mu sync.RWMutex
|
||||
LastReport *attest.VerifiedReport
|
||||
LastIssuedAt time.Time
|
||||
}
|
||||
|
||||
// CombinerPoolConfig captures pool-level policy.
|
||||
type CombinerPoolConfig struct {
|
||||
// Threshold is the t in t-of-n. MUST be >= 2; the user mandate
|
||||
// explicitly calls for "at least 2-of-3 attested combiners must
|
||||
// produce matching signature for it to be accepted".
|
||||
Threshold int
|
||||
|
||||
// RotationWindow bounds the age of each combiner's attestation.
|
||||
// Outside this window, Combine refuses with
|
||||
// ErrMagnetarStaleAttestation. Production deployments rotate per
|
||||
// epoch (Lux block-time epoch == ~1s currently); a 60s rotation
|
||||
// window absorbs ~60 blocks of latency between operator
|
||||
// re-attestation and the next sign request.
|
||||
RotationWindow time.Duration
|
||||
|
||||
// KnownIssuers is the set of vendor strings the pool admits.
|
||||
// For strict-PQ production deployments, this is typically
|
||||
// {"amd.sev.snp"} (the only production-attestable verifier today;
|
||||
// TDX and NRAS are stubs at cc/attest tracked #222). The
|
||||
// dispatcher fails fast on a Combine that selects an issuer not
|
||||
// in this set.
|
||||
KnownIssuers map[string]struct{}
|
||||
|
||||
// Now is the wall-clock source. Production leaves this nil so
|
||||
// the pool uses time.Now; tests pin it for deterministic rotation
|
||||
// windows.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewCombinerPool builds an empty pool from cfg. Members are added
|
||||
// via AddMember; the pool is empty until at least Threshold members
|
||||
// are registered + attested.
|
||||
func NewCombinerPool(cfg CombinerPoolConfig) (*CombinerPool, error) {
|
||||
if cfg.Threshold < 2 {
|
||||
return nil, fmt.Errorf("slhdsa-tee: CombinerPool requires Threshold >= 2 (got %d)", cfg.Threshold)
|
||||
}
|
||||
if cfg.RotationWindow <= 0 {
|
||||
return nil, fmt.Errorf("slhdsa-tee: CombinerPool requires RotationWindow > 0 (got %s)", cfg.RotationWindow)
|
||||
}
|
||||
if len(cfg.KnownIssuers) == 0 {
|
||||
return nil, errors.New("slhdsa-tee: CombinerPool requires at least one KnownIssuer (default-deny posture)")
|
||||
}
|
||||
now := cfg.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
p := &CombinerPool{
|
||||
threshold: cfg.Threshold,
|
||||
rotation: cfg.RotationWindow,
|
||||
issuers: make(map[string]struct{}, len(cfg.KnownIssuers)),
|
||||
now: now,
|
||||
}
|
||||
for k := range cfg.KnownIssuers {
|
||||
p.issuers[k] = struct{}{}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// AddMember registers an attested combiner endpoint. The member is
|
||||
// NOT yet sign-eligible — Attest must be called with a fresh quote
|
||||
// before it counts toward the quorum.
|
||||
func (p *CombinerPool) AddMember(name string, signer *Signer) (*PoolMember, error) {
|
||||
if signer == nil {
|
||||
return nil, errors.New("slhdsa-tee: CombinerPool.AddMember: nil Signer")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, errors.New("slhdsa-tee: CombinerPool.AddMember: empty Name")
|
||||
}
|
||||
m := &PoolMember{Name: name, Signer: signer}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, existing := range p.members {
|
||||
if existing.Name == name {
|
||||
return nil, fmt.Errorf("slhdsa-tee: CombinerPool.AddMember: duplicate member %q", name)
|
||||
}
|
||||
}
|
||||
p.members = append(p.members, m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Attest refreshes a member's attestation freshness state. The caller
|
||||
// supplies (env, jobID) — the pool calls env.VerifyEvidence to drive
|
||||
// the chain-validation, checks the resulting report's Vendor against
|
||||
// KnownIssuers, and pins the LastIssuedAt to the pool clock on
|
||||
// success. Failure leaves the prior state untouched (no half-update).
|
||||
//
|
||||
// Attest is called by the operator's control-plane out-of-band of any
|
||||
// Sign call: a control-plane tick captures fresh quotes from each
|
||||
// combiner and submits them. The Combine path is then a freshness
|
||||
// READ — it never blocks on a network roundtrip to KDS / PCS / NRAS.
|
||||
func (p *CombinerPool) Attest(ctx context.Context, memberName string, env *Envelope) error {
|
||||
if env == nil {
|
||||
return ErrAttestationRequired
|
||||
}
|
||||
p.mu.RLock()
|
||||
var target *PoolMember
|
||||
for _, m := range p.members {
|
||||
if m.Name == memberName {
|
||||
target = m
|
||||
break
|
||||
}
|
||||
}
|
||||
knownIssuers := make(map[string]struct{}, len(p.issuers))
|
||||
for k := range p.issuers {
|
||||
knownIssuers[k] = struct{}{}
|
||||
}
|
||||
now := p.now()
|
||||
p.mu.RUnlock()
|
||||
|
||||
if target == nil {
|
||||
return fmt.Errorf("slhdsa-tee: CombinerPool.Attest: unknown member %q", memberName)
|
||||
}
|
||||
|
||||
// Drive the chain validation. The Envelope already carries the
|
||||
// caller-supplied VerifyOpts (KDSGetter for offline tests,
|
||||
// expectedReportData for nonce binding, etc.).
|
||||
reports, err := env.VerifyEvidence(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slhdsa-tee: CombinerPool.Attest: VerifyEvidence: %w", err)
|
||||
}
|
||||
if len(reports) != 1 {
|
||||
return fmt.Errorf("slhdsa-tee: CombinerPool.Attest: expected exactly one verified report, got %d", len(reports))
|
||||
}
|
||||
rep := reports[0]
|
||||
if _, ok := knownIssuers[rep.Vendor]; !ok {
|
||||
return fmt.Errorf("slhdsa-tee: CombinerPool.Attest: vendor %q not in KnownIssuers", rep.Vendor)
|
||||
}
|
||||
|
||||
target.mu.Lock()
|
||||
target.LastReport = rep
|
||||
target.LastIssuedAt = now
|
||||
target.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Combine drives the t-of-n attested-combiner Sign and returns the
|
||||
// agreed wire bytes + per-member audit signatures.
|
||||
//
|
||||
// Flow:
|
||||
//
|
||||
// 1. Snapshot the member list under RLock; pick the FIRST Threshold
|
||||
// members whose LastIssuedAt + RotationWindow > now. If fewer than
|
||||
// Threshold members pass the freshness gate, refuse with
|
||||
// ErrMagnetarStaleAttestation.
|
||||
// 2. For each selected member, drive Signer.Sign with a fresh
|
||||
// Envelope holding the most recent attestation evidence (NOT the
|
||||
// pool's cached LastReport — Sign requires fresh evidence with
|
||||
// the gate-issued nonce; the pool's freshness gate is the
|
||||
// additional discipline beyond the per-call attestation).
|
||||
// 3. Compare the wire bytes byte-for-byte. If all Threshold members
|
||||
// produced the same bytes, surface that signature with the audit
|
||||
// trail. If they disagree, refuse with
|
||||
// ErrMagnetarSignatureDivergence — divergence indicates a
|
||||
// misconfigured or compromised combiner; do NOT silently pick.
|
||||
//
|
||||
// Each member's Signer call drives the FULL slhdsa-tee.Sign machinery
|
||||
// (approval, release gate, HSM, magnetar.Sign, audit signature). The
|
||||
// pool's role is the freshness + t-of-n agreement layer ON TOP of
|
||||
// each member's per-call discipline.
|
||||
func (p *CombinerPool) Combine(
|
||||
ctx context.Context,
|
||||
envs map[string]*Envelope,
|
||||
jobID [32]byte,
|
||||
msg []byte,
|
||||
signCtx []byte,
|
||||
) ([]byte, []*SignReceipt, error) {
|
||||
if len(envs) == 0 {
|
||||
return nil, nil, ErrAttestationRequired
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
return nil, nil, errors.New("slhdsa-tee: CombinerPool.Combine: empty message")
|
||||
}
|
||||
|
||||
// Snapshot members under RLock so concurrent Attest() doesn't
|
||||
// flip the freshness state mid-iteration.
|
||||
p.mu.RLock()
|
||||
threshold := p.threshold
|
||||
rotation := p.rotation
|
||||
now := p.now()
|
||||
members := make([]*PoolMember, len(p.members))
|
||||
copy(members, p.members)
|
||||
p.mu.RUnlock()
|
||||
|
||||
if len(members) < threshold {
|
||||
return nil, nil, fmt.Errorf("%w: %d members registered, %d required",
|
||||
ErrMagnetarInsufficientQuorum, len(members), threshold)
|
||||
}
|
||||
|
||||
// Stable iteration order: by name. Tests + audit logs benefit
|
||||
// from deterministic member-selection order.
|
||||
sort.Slice(members, func(i, j int) bool { return members[i].Name < members[j].Name })
|
||||
|
||||
// Freshness gate: select the first Threshold members whose
|
||||
// LastIssuedAt + RotationWindow > now AND whose envelope is
|
||||
// present in envs. Any selected member outside the window is a
|
||||
// hard refusal — no silent fallback to a stale member.
|
||||
type selected struct {
|
||||
member *PoolMember
|
||||
env *Envelope
|
||||
}
|
||||
var sel []selected
|
||||
var staleSeen []string
|
||||
var missingEvidence []string
|
||||
for _, m := range members {
|
||||
env, ok := envs[m.Name]
|
||||
if !ok {
|
||||
missingEvidence = append(missingEvidence, m.Name)
|
||||
continue
|
||||
}
|
||||
m.mu.RLock()
|
||||
last := m.LastIssuedAt
|
||||
m.mu.RUnlock()
|
||||
if last.IsZero() || now.Sub(last) > rotation {
|
||||
staleSeen = append(staleSeen, m.Name)
|
||||
continue
|
||||
}
|
||||
sel = append(sel, selected{member: m, env: env})
|
||||
if len(sel) >= threshold {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(sel) < threshold {
|
||||
// Surface the most actionable refusal:
|
||||
// - if ANY member was stale → ErrMagnetarStaleAttestation
|
||||
// - else → ErrMagnetarInsufficientQuorum
|
||||
if len(staleSeen) > 0 {
|
||||
return nil, nil, fmt.Errorf("%w: members %v outside rotation window %s (have %d fresh, need %d)",
|
||||
ErrMagnetarStaleAttestation, staleSeen, rotation, len(sel), threshold)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%w: %d fresh members with evidence, %d required (missing: %v)",
|
||||
ErrMagnetarInsufficientQuorum, len(sel), threshold, missingEvidence)
|
||||
}
|
||||
|
||||
// Drive each selected member's Sign in series. Parallel would be
|
||||
// possible but the audit trail benefits from deterministic
|
||||
// member-ordering. (CCF / Hyperledger / Cardano governance
|
||||
// patterns all prefer ordered execution under quorum semantics.)
|
||||
type signOutput struct {
|
||||
wire []byte
|
||||
receipt *SignReceipt
|
||||
err error
|
||||
name string
|
||||
}
|
||||
out := make([]signOutput, 0, len(sel))
|
||||
for _, s := range sel {
|
||||
wire, receipt, err := s.member.Signer.Sign(ctx, s.env, jobID, msg, signCtx)
|
||||
out = append(out, signOutput{wire: wire, receipt: receipt, err: err, name: s.member.Name})
|
||||
}
|
||||
|
||||
// Any per-member failure is a hard refusal at the pool level —
|
||||
// the strict-PQ profile cannot silently drop a member from
|
||||
// quorum mid-flight.
|
||||
for _, o := range out {
|
||||
if o.err != nil {
|
||||
return nil, nil, fmt.Errorf("slhdsa-tee: CombinerPool.Combine: member %q Sign: %w", o.name, o.err)
|
||||
}
|
||||
}
|
||||
|
||||
// Byte-equality across the quorum. SLH-DSA SignDeterministic is
|
||||
// byte-stable — same (seed, msg, ctx) tuple → identical bytes.
|
||||
// Two attested combiners holding the SAME wrapped seed under
|
||||
// the SAME RIM MUST produce identical output. Divergence is a
|
||||
// hard refusal.
|
||||
canonical := out[0].wire
|
||||
receipts := make([]*SignReceipt, 0, len(out))
|
||||
receipts = append(receipts, out[0].receipt)
|
||||
for _, o := range out[1:] {
|
||||
if subtle.ConstantTimeCompare(canonical, o.wire) != 1 {
|
||||
return nil, nil, fmt.Errorf("%w: %q vs %q", ErrMagnetarSignatureDivergence, out[0].name, o.name)
|
||||
}
|
||||
receipts = append(receipts, o.receipt)
|
||||
}
|
||||
return canonical, receipts, nil
|
||||
}
|
||||
|
||||
// Threshold reports the configured t-of-n quorum count. Exposed for
|
||||
// the dispatcher's documentation surface — embedders that publish
|
||||
// the pool's policy to audit logs.
|
||||
func (p *CombinerPool) Threshold() int { return p.threshold }
|
||||
|
||||
// RotationWindow reports the configured rotation window. Same
|
||||
// rationale as Threshold.
|
||||
func (p *CombinerPool) RotationWindow() time.Duration { return p.rotation }
|
||||
|
||||
// MemberCount reports the number of registered members (regardless
|
||||
// of attestation freshness).
|
||||
func (p *CombinerPool) MemberCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return len(p.members)
|
||||
}
|
||||
|
||||
// FreshMemberCount reports the number of members currently inside
|
||||
// the rotation window. Useful for control-plane health checks before
|
||||
// any sign call.
|
||||
func (p *CombinerPool) FreshMemberCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
now := p.now()
|
||||
n := 0
|
||||
for _, m := range p.members {
|
||||
m.mu.RLock()
|
||||
last := m.LastIssuedAt
|
||||
m.mu.RUnlock()
|
||||
if !last.IsZero() && now.Sub(last) <= p.rotation {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// pool_test.go pins the CombinerPool invariants independent of the
|
||||
// magnetar dispatcher. These tests exercise the freshness gate,
|
||||
// quorum selection, and divergence-refusal at the slhdsa-tee package
|
||||
// level so future consumers (mldsa-tee pool, rlwe-tee pool — same
|
||||
// shape) can mirror the test discipline.
|
||||
|
||||
// TestCombinerPool_Constructor_Defaults pins the constructor's
|
||||
// default-deny posture: Threshold<2, RotationWindow=0, empty
|
||||
// KnownIssuers all refuse.
|
||||
func TestCombinerPool_Constructor_Defaults(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg CombinerPoolConfig
|
||||
}{
|
||||
{
|
||||
name: "threshold-1",
|
||||
cfg: CombinerPoolConfig{
|
||||
Threshold: 1, RotationWindow: time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rotation-zero",
|
||||
cfg: CombinerPoolConfig{
|
||||
Threshold: 2, RotationWindow: 0,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-issuers",
|
||||
cfg: CombinerPoolConfig{
|
||||
Threshold: 2, RotationWindow: time.Second,
|
||||
KnownIssuers: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := NewCombinerPool(c.cfg); err == nil {
|
||||
t.Fatalf("NewCombinerPool(%s): expected error, got nil", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinerPool_AddMember pins per-member registration semantics:
|
||||
// nil Signer refused, empty name refused, duplicate name refused.
|
||||
func TestCombinerPool_AddMember(t *testing.T) {
|
||||
pool, err := NewCombinerPool(CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: time.Minute,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
|
||||
// nil signer
|
||||
if _, err := pool.AddMember("a", nil); err == nil {
|
||||
t.Fatal("AddMember nil signer: expected error")
|
||||
}
|
||||
|
||||
// empty name
|
||||
if _, err := pool.AddMember("", &Signer{}); err == nil {
|
||||
t.Fatal("AddMember empty name: expected error")
|
||||
}
|
||||
|
||||
// duplicate
|
||||
if _, err := pool.AddMember("a", &Signer{}); err != nil {
|
||||
t.Fatalf("AddMember a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("a", &Signer{}); err == nil {
|
||||
t.Fatal("AddMember dup a: expected error")
|
||||
}
|
||||
|
||||
if pool.MemberCount() != 1 {
|
||||
t.Fatalf("MemberCount = %d, want 1", pool.MemberCount())
|
||||
}
|
||||
if pool.Threshold() != 2 {
|
||||
t.Fatalf("Threshold = %d, want 2", pool.Threshold())
|
||||
}
|
||||
if pool.RotationWindow() != time.Minute {
|
||||
t.Fatalf("RotationWindow = %s, want 1m", pool.RotationWindow())
|
||||
}
|
||||
}
|
||||
|
||||
// poolMakeSigner builds a Signer over the committed Milan SEV-SNP
|
||||
// fixture sharing the SAME wrapped seed across members.
|
||||
func poolMakeSigner(t *testing.T, seed []byte) *Signer {
|
||||
t.Helper()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
|
||||
gate := poolMakeGate(t, rim, hw)
|
||||
hsmP := newTestFileHSM(t)
|
||||
if err := hsmP.StoreKey(context.Background(), "master-seed", seed); err != nil {
|
||||
t.Fatalf("StoreKey master-seed: %v", err)
|
||||
}
|
||||
cfg := Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: false,
|
||||
}
|
||||
signer, err := New(gate, hsmP, newTestApprovalProvider(t), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("slhdsatee.New: %v", err)
|
||||
}
|
||||
return signer
|
||||
}
|
||||
|
||||
func poolMakeGate(t *testing.T, rim, hw [32]byte) *kms.LocalReleaseGate {
|
||||
t.Helper()
|
||||
policy := kms.NewReleasePolicy([][32]byte{rim}, [][32]byte{hw})
|
||||
policy.RequireSEVSNP = true
|
||||
var rootKey [32]byte
|
||||
if _, err := rand.Read(rootKey[:]); err != nil {
|
||||
t.Fatalf("rootKey: %v", err)
|
||||
}
|
||||
gate, err := kms.NewLocalReleaseGate(policy, kms.NewMemoryNonceStore(), rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalReleaseGate: %v", err)
|
||||
}
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
gate.SetReplayWindow(5 * time.Second)
|
||||
return gate
|
||||
}
|
||||
|
||||
// poolMakeSharedSeed returns deterministic SeedSize bytes parameterised
|
||||
// by label so we can construct multiple Signers with the same seed
|
||||
// (byte-equality across pool members requires identical seeds) or
|
||||
// different seeds (divergence path).
|
||||
func poolMakeSharedSeed(label byte) []byte {
|
||||
params := magnetar.MustParamsFor(magnetar.ModeM192s)
|
||||
seed := make([]byte, params.SeedSize)
|
||||
for i := range seed {
|
||||
seed[i] = label ^ byte(i)
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
// poolMakeEnvelope builds an Envelope binding the committed Milan
|
||||
// fixture + KDS replay + fixed-clock options, suitable for both
|
||||
// pool.Attest and per-call sign.
|
||||
func poolMakeEnvelope(t *testing.T, rim, hw, teePub [32]byte) *Envelope {
|
||||
t.Helper()
|
||||
return &Envelope{
|
||||
Kind: attest.KindSEVSNP,
|
||||
EvidenceBytes: append([]byte(nil), sevSnpAttestationMilan...),
|
||||
RIM: rim,
|
||||
Hardware: hw,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: []attest.Option{
|
||||
attest.WithKDSGetter(newKDSReplay()),
|
||||
attest.WithNow(fixedNow()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinerPool_Attest_VendorPin pins the vendor allowlist: a
|
||||
// chain-validated report whose Vendor is not in KnownIssuers refuses.
|
||||
func TestCombinerPool_Attest_VendorPin(t *testing.T) {
|
||||
seed := poolMakeSharedSeed(0xF0)
|
||||
signer := poolMakeSigner(t, seed)
|
||||
|
||||
pool, err := NewCombinerPool(CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: time.Minute,
|
||||
KnownIssuers: map[string]struct{}{
|
||||
"never-issuer": {}, // SEV-SNP path will not match
|
||||
},
|
||||
Now: fixedNow,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("a", signer); err != nil {
|
||||
t.Fatalf("AddMember a: %v", err)
|
||||
}
|
||||
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
teePub := makeTEEPub(t)
|
||||
env := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
|
||||
err = pool.Attest(context.Background(), "a", env)
|
||||
if err == nil {
|
||||
t.Fatal("Attest with wrong issuer: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinerPool_Combine_FreshnessGate pins the rotation-window
|
||||
// gate: members never attested are stale; combine refuses.
|
||||
func TestCombinerPool_Combine_FreshnessGate(t *testing.T) {
|
||||
seed := poolMakeSharedSeed(0xF1)
|
||||
signer1 := poolMakeSigner(t, seed)
|
||||
signer2 := poolMakeSigner(t, seed)
|
||||
|
||||
pool, err := NewCombinerPool(CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: 30 * time.Second,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: fixedNow,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("a", signer1); err != nil {
|
||||
t.Fatalf("AddMember a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("b", signer2); err != nil {
|
||||
t.Fatalf("AddMember b: %v", err)
|
||||
}
|
||||
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
teePub := makeTEEPub(t)
|
||||
|
||||
if pool.FreshMemberCount() != 0 {
|
||||
t.Fatalf("FreshMemberCount pre-attest = %d, want 0", pool.FreshMemberCount())
|
||||
}
|
||||
|
||||
envA := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
envB := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
|
||||
// Try Combine without any attestation — pool refuses.
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
_, _, err = pool.Combine(context.Background(),
|
||||
map[string]*Envelope{"a": envA, "b": envB},
|
||||
jobID, []byte("freshness"), nil)
|
||||
if err == nil {
|
||||
t.Fatal("Combine with no attestation: expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrMagnetarStaleAttestation) {
|
||||
t.Fatalf("Combine pre-attest: expected ErrMagnetarStaleAttestation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinerPool_Combine_ByteEqualityAcrossQuorum drives a 2-of-2
|
||||
// quorum end-to-end and asserts byte-equality across the agreed
|
||||
// signature. Independent of dispatcher.
|
||||
func TestCombinerPool_Combine_ByteEqualityAcrossQuorum(t *testing.T) {
|
||||
seed := poolMakeSharedSeed(0xF2)
|
||||
signer1 := poolMakeSigner(t, seed)
|
||||
signer2 := poolMakeSigner(t, seed)
|
||||
|
||||
pool, err := NewCombinerPool(CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: time.Minute,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: fixedNow,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("a", signer1); err != nil {
|
||||
t.Fatalf("AddMember a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("b", signer2); err != nil {
|
||||
t.Fatalf("AddMember b: %v", err)
|
||||
}
|
||||
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
teePub := makeTEEPub(t)
|
||||
|
||||
if err := pool.Attest(context.Background(), "a", poolMakeEnvelope(t, rim, hw, teePub)); err != nil {
|
||||
t.Fatalf("Attest a: %v", err)
|
||||
}
|
||||
if err := pool.Attest(context.Background(), "b", poolMakeEnvelope(t, rim, hw, teePub)); err != nil {
|
||||
t.Fatalf("Attest b: %v", err)
|
||||
}
|
||||
if pool.FreshMemberCount() != 2 {
|
||||
t.Fatalf("FreshMemberCount post-attest = %d, want 2", pool.FreshMemberCount())
|
||||
}
|
||||
|
||||
envA := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
envB := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
wire, receipts, err := pool.Combine(context.Background(),
|
||||
map[string]*Envelope{"a": envA, "b": envB},
|
||||
jobID, []byte("byte-equality"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Combine: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Combine returned empty wire")
|
||||
}
|
||||
if len(receipts) != 2 {
|
||||
t.Fatalf("Combine receipts = %d, want 2", len(receipts))
|
||||
}
|
||||
for i, r := range receipts {
|
||||
if r == nil {
|
||||
t.Fatalf("receipt[%d] is nil", i)
|
||||
}
|
||||
if len(r.AuditSignature) == 0 {
|
||||
t.Fatalf("receipt[%d] audit is empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinerPool_Combine_Divergence pins the byte-equality discipline:
|
||||
// two signers holding DIFFERENT seeds produce different bytes; pool
|
||||
// refuses with ErrMagnetarSignatureDivergence.
|
||||
func TestCombinerPool_Combine_Divergence(t *testing.T) {
|
||||
seedA := poolMakeSharedSeed(0xA1)
|
||||
seedB := poolMakeSharedSeed(0xB2) // different seed
|
||||
signerA := poolMakeSigner(t, seedA)
|
||||
signerB := poolMakeSigner(t, seedB)
|
||||
|
||||
pool, err := NewCombinerPool(CombinerPoolConfig{
|
||||
Threshold: 2,
|
||||
RotationWindow: time.Minute,
|
||||
KnownIssuers: map[string]struct{}{"amd.sev.snp": {}},
|
||||
Now: fixedNow,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCombinerPool: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("a", signerA); err != nil {
|
||||
t.Fatalf("AddMember a: %v", err)
|
||||
}
|
||||
if _, err := pool.AddMember("b", signerB); err != nil {
|
||||
t.Fatalf("AddMember b: %v", err)
|
||||
}
|
||||
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
teePub := makeTEEPub(t)
|
||||
|
||||
if err := pool.Attest(context.Background(), "a", poolMakeEnvelope(t, rim, hw, teePub)); err != nil {
|
||||
t.Fatalf("Attest a: %v", err)
|
||||
}
|
||||
if err := pool.Attest(context.Background(), "b", poolMakeEnvelope(t, rim, hw, teePub)); err != nil {
|
||||
t.Fatalf("Attest b: %v", err)
|
||||
}
|
||||
|
||||
envA := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
envB := poolMakeEnvelope(t, rim, hw, teePub)
|
||||
|
||||
var jobID [32]byte
|
||||
if _, err := rand.Read(jobID[:]); err != nil {
|
||||
t.Fatalf("jobID: %v", err)
|
||||
}
|
||||
_, _, err = pool.Combine(context.Background(),
|
||||
map[string]*Envelope{"a": envA, "b": envB},
|
||||
jobID, []byte("divergence-bytes"), nil)
|
||||
if !errors.Is(err, ErrMagnetarSignatureDivergence) {
|
||||
t.Fatalf("Combine divergence: expected ErrMagnetarSignatureDivergence, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import "errors"
|
||||
|
||||
// ChainSecurityProfile is the value the consensus / chain layer
|
||||
// publishes to express the residency posture it is willing to tolerate
|
||||
// for SLH-DSA Combine.
|
||||
//
|
||||
// Decomplecting: profile is a value, not a place. The chain layer
|
||||
// constructs it from its ChainConfig (e.g. the precompile
|
||||
// StrictPQReporter the EVM precompiles use today) and hands it down
|
||||
// at Sign call time. No global, no init() hook, no env var. The
|
||||
// dispatcher reads it the same way the precompile contract.RefuseUnderStrictPQ
|
||||
// helper reads its StrictPQReporter: ONE function, ONE place, ONE
|
||||
// canonical refusal sentinel.
|
||||
type ChainSecurityProfile int
|
||||
|
||||
const (
|
||||
// ProfileLegacyCompat tolerates the strict-atom commodity-host
|
||||
// Combine path. The FIPS 205 master bytes transiently exist in
|
||||
// the public combiner's SHAKE-expansion buffers for a few
|
||||
// microseconds. Acceptable for permissionless / community
|
||||
// validation paths where no host is in the TCB; refused for
|
||||
// strict-PQ deployments. This is the default.
|
||||
ProfileLegacyCompat ChainSecurityProfile = 0
|
||||
|
||||
// ProfileStrictPQ requires every Combine to route through a
|
||||
// TEE-attested combiner pool. The master bytes only ever exist
|
||||
// inside a measured enclave (SEV-SNP / TDX / NRAS attested
|
||||
// host) whose binary / firmware match the operator-asserted RIM
|
||||
// and chain-validate to the vendor root. No commodity-host
|
||||
// fallback; refusal is hard.
|
||||
ProfileStrictPQ ChainSecurityProfile = 1
|
||||
)
|
||||
|
||||
// String reports the canonical wire label for the profile.
|
||||
func (p ChainSecurityProfile) String() string {
|
||||
switch p {
|
||||
case ProfileStrictPQ:
|
||||
return "strict-PQ"
|
||||
case ProfileLegacyCompat:
|
||||
return "legacy-compat"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ErrMagnetarNoTEEAttestation is the canonical refusal returned when
|
||||
// a strict-PQ chain profile attempts to Combine without a valid TEE
|
||||
// quote. The wire-stable identifier is "ERR_MAGNETAR_NO_TEE_ATTESTATION".
|
||||
//
|
||||
// Callers SHOULD switch on errors.Is(err, ErrMagnetarNoTEEAttestation)
|
||||
// to distinguish profile-gate refusal from upstream attestation /
|
||||
// release-gate errors.
|
||||
var ErrMagnetarNoTEEAttestation = errors.New("ERR_MAGNETAR_NO_TEE_ATTESTATION: strict-PQ chain profile requires TEE-attested combiner")
|
||||
|
||||
// ErrMagnetarStaleAttestation is returned when an attested combiner's
|
||||
// last successful re-attestation lies outside the configured rotation
|
||||
// window. The bytes of the attestation chain-validate but the freshness
|
||||
// guarantee has lapsed — operators MUST re-attest before the next sign.
|
||||
var ErrMagnetarStaleAttestation = errors.New("ERR_MAGNETAR_STALE_ATTESTATION: combiner attestation outside rotation window")
|
||||
|
||||
// ErrMagnetarInsufficientQuorum is returned when fewer than the
|
||||
// configured threshold-of-attested-combiners agreed on byte-identical
|
||||
// output. The pool refuses to surface a signature whose origin is
|
||||
// less than t attested combiners.
|
||||
var ErrMagnetarInsufficientQuorum = errors.New("ERR_MAGNETAR_INSUFFICIENT_QUORUM: fewer than threshold attested combiners produced matching signature")
|
||||
|
||||
// ErrMagnetarSignatureDivergence is returned when the configured
|
||||
// quorum DID produce signatures but they were not byte-identical.
|
||||
// Hard refusal — divergence indicates a misconfigured or compromised
|
||||
// combiner; the pool MUST NOT silently pick a winner.
|
||||
var ErrMagnetarSignatureDivergence = errors.New("ERR_MAGNETAR_SIGNATURE_DIVERGENCE: attested combiners produced non-matching signature bytes")
|
||||
@@ -1,261 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
)
|
||||
|
||||
// Sign produces a FIPS 205 SLH-DSA signature on msg, gated by the
|
||||
// supplied attestation Envelope.
|
||||
//
|
||||
// Flow (decomplected — each step is independent and complete):
|
||||
//
|
||||
// 1. approval.ApproveIntent — out-of-band human/programmatic gate.
|
||||
// 2. gate.Issue — fresh nonce + epoch from KMS.
|
||||
// 3. env.Verify(nonce) — operator bound the gate-nonce into
|
||||
// REPORT_DATA at TEE capture.
|
||||
// 4. env.VerifyEvidence(ctx) — cc/attest chain validates the report
|
||||
// against vendor root (AMD KDS / TDX PCS / NRAS JWKS).
|
||||
// 5. gate.Release — gate.RIM ∋ env.RIM, gate.Hardware ∋ env.Hardware,
|
||||
// Require* issuers present, seal a session key under env.TEEPub.
|
||||
// 6. Inside the TEE: unwrap sealed session key, decrypt the wrapped
|
||||
// master seed (this package's surface treats hsm.Provider.GetKey
|
||||
// as already-yielding-plaintext-inside-attested-TEE; production
|
||||
// deployments perform AEAD-unwrap with sealed key here).
|
||||
// 7. magnetar.KeyFromSeed(seed) → magnetar.Sign(msg, ctx, det=true).
|
||||
// 8. Zeroize seed + sk.Bytes immediately. Return sig + sealed key
|
||||
// metadata so callers can audit (epoch, jobID, ephemeral pub).
|
||||
//
|
||||
// Output is the MAGS-framed wire bytes (via Signature.MarshalBinary)
|
||||
// — byte-identical to single-party FIPS 205 SignDeterministic on
|
||||
// the same (seed-derived sk, msg). Any verifier with the matching
|
||||
// magnetar.PublicKey (or its MAGG wire bytes) validates with
|
||||
// magnetar.Verify / VerifyBytes.
|
||||
//
|
||||
// jobID is the audit-binding identifier — opaque to the gate, bound
|
||||
// into AAD so cross-job replay of the sealed key is refused.
|
||||
// Production callers derive jobID = sha256(domain || workload || msg)
|
||||
// or any other collision-free convention. Tests use fresh random.
|
||||
//
|
||||
// signCtx is the FIPS 205 §10.2 context string. Pass nil for empty.
|
||||
//
|
||||
// Sign is safe for concurrent calls against the same Signer. Each
|
||||
// call generates an independent jobID-keyed nonce and seals to a
|
||||
// fresh ephemeral key.
|
||||
func (s *Signer) Sign(ctx context.Context, env *Envelope, jobID [32]byte, msg []byte, signCtx []byte) ([]byte, *SignReceipt, error) {
|
||||
if env == nil {
|
||||
return nil, nil, ErrAttestationRequired
|
||||
}
|
||||
if len(msg) == 0 {
|
||||
return nil, nil, fmt.Errorf("slhdsa-tee: empty message")
|
||||
}
|
||||
|
||||
sealed, err := s.auditedRelease(ctx, env, jobID, msg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Pull the wrapped master seed from the HSM. Production
|
||||
// deployments AEAD-unwrap with the sealed session-key here; for
|
||||
// the institutional-custody surface where the HSM itself enforces
|
||||
// at-rest confidentiality (AWS KMS, Azure Key Vault, GCP Cloud
|
||||
// KMS, Zymbit, YubiHSM, KMS secret manager), GetKey returns the
|
||||
// plaintext seed inside the attested TEE context.
|
||||
seed, err := s.hsmP.GetKey(ctx, s.cfg.WrappedSeedKeyID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: HSM GetKey: %v", ErrHSMUnreachable, err)
|
||||
}
|
||||
defer zeroize(seed)
|
||||
|
||||
if len(seed) != s.params.SeedSize {
|
||||
return nil, nil, fmt.Errorf("%w: HSM-stored seed length %d does not match SeedSize %d (mode=%s)",
|
||||
ErrCorruptWrappedSeed, len(seed), s.params.SeedSize, s.params.Mode)
|
||||
}
|
||||
|
||||
sk, err := magnetar.KeyFromSeed(s.params, seed)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: KeyFromSeed: %v", ErrCorruptWrappedSeed, err)
|
||||
}
|
||||
defer zeroize(sk.Bytes)
|
||||
defer zeroize(sk.Seed)
|
||||
|
||||
sig, err := magnetar.Sign(s.params, sk, msg, signCtx, false /*deterministic*/, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("slhdsa-tee: magnetar.Sign: %w", err)
|
||||
}
|
||||
|
||||
// Self-verify safety belt — refuses to return bytes that would
|
||||
// fail at the caller. A failure here signals a kernel bug, not a
|
||||
// caller bug. Matches the dispatcher's discipline.
|
||||
if err := magnetar.VerifyCtx(s.params, sk.Pub, msg, signCtx, sig); err != nil {
|
||||
return nil, nil, fmt.Errorf("slhdsa-tee: self-verify failed (kernel bug): %w", err)
|
||||
}
|
||||
|
||||
wire, err := sig.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("slhdsa-tee: sig.MarshalBinary: %w", err)
|
||||
}
|
||||
|
||||
// Optionally write an HSM-backed audit signature over
|
||||
// (jobID, msgDigest, epoch, RIM) — institutional custody
|
||||
// auditors replay this against the HSM's KMS audit log to prove
|
||||
// the release happened. The signature does not feed back into
|
||||
// the FIPS 205 wire bytes; it lives only in the SignReceipt.
|
||||
audit, err := s.auditSignature(ctx, jobID, msg, sealed.Epoch, env.RIM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("slhdsa-tee: audit signature: %w", err)
|
||||
}
|
||||
|
||||
recv := &SignReceipt{
|
||||
JobID: jobID,
|
||||
Epoch: sealed.Epoch,
|
||||
IssuedNonce: sealed.IssuedNonce,
|
||||
EphemeralPub: sealed.EphemeralPub,
|
||||
EvidenceKind: string(env.Kind),
|
||||
EvidenceIssuer: evidenceIssuerString(env),
|
||||
AuditSignature: audit,
|
||||
}
|
||||
return wire, recv, nil
|
||||
}
|
||||
|
||||
// SignReceipt is the audit blob returned alongside the FIPS 205
|
||||
// signature. Embedders log this to the KMS audit channel; nothing
|
||||
// in it is cryptographically required for verification — Verify
|
||||
// only needs (gpkBytes, msg, sigBytes).
|
||||
type SignReceipt struct {
|
||||
JobID [32]byte
|
||||
Epoch uint64
|
||||
IssuedNonce [32]byte
|
||||
EphemeralPub [32]byte
|
||||
EvidenceKind string
|
||||
EvidenceIssuer string
|
||||
AuditSignature []byte
|
||||
}
|
||||
|
||||
// auditSignature emits an HSM-backed signature over the canonical
|
||||
// (jobID || msgDigest || epoch || rim) tuple via the configured HSM
|
||||
// provider. The HSM's Sign API is provider-native: AWS KMS uses
|
||||
// ECDSA-P256, Azure Key Vault uses Ed25519 / ECDSA, the file
|
||||
// provider uses Ed25519. The signature is opaque-bytes from this
|
||||
// package's perspective — auditors who trust the HSM root validate
|
||||
// against the provider's pubkey.
|
||||
func (s *Signer) auditSignature(ctx context.Context, jobID [32]byte, msg []byte, epoch uint64, rim [32]byte) ([]byte, error) {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("LUX-SLHDSA-TEE-AUDIT-V1"))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(jobID[:])
|
||||
h.Write(epochBytes(epoch))
|
||||
h.Write(rim[:])
|
||||
d := sha256.Sum256(msg)
|
||||
h.Write(d[:])
|
||||
auditDigest := h.Sum(nil)
|
||||
return s.hsmP.Sign(ctx, s.cfg.KMSKeyID, auditDigest)
|
||||
}
|
||||
|
||||
// epochBytes encodes epoch as 8 bytes big-endian.
|
||||
func epochBytes(e uint64) []byte {
|
||||
return []byte{
|
||||
byte(e >> 56), byte(e >> 48), byte(e >> 40), byte(e >> 32),
|
||||
byte(e >> 24), byte(e >> 16), byte(e >> 8), byte(e),
|
||||
}
|
||||
}
|
||||
|
||||
// evidenceIssuerString returns the canonical wire issuer for env.
|
||||
func evidenceIssuerString(env *Envelope) string {
|
||||
switch is := env.EvidenceIssuers(); len(is) {
|
||||
case 0:
|
||||
return ""
|
||||
default:
|
||||
return is[0]
|
||||
}
|
||||
}
|
||||
|
||||
// signIntent satisfies approval.CanonicalIntent for the (jobID, msg,
|
||||
// envelope-summary) tuple. The approver signs the digest of this
|
||||
// intent; verification at gate-time re-derives the digest from the
|
||||
// same inputs and refuses if the approver's signature does not match.
|
||||
type signIntent struct {
|
||||
jobID [32]byte
|
||||
msg []byte
|
||||
env envelopeSummary
|
||||
}
|
||||
|
||||
// envelopeSummary captures the fields of Envelope that participate
|
||||
// in the approval digest. RIM and Hardware are operator-asserted and
|
||||
// thus auditable to the approver; the evidence bytes themselves are
|
||||
// re-captured per Sign and excluded from the intent digest.
|
||||
type envelopeSummary struct {
|
||||
Kind string
|
||||
RIM [32]byte
|
||||
Hardware [32]byte
|
||||
TEEPub [32]byte
|
||||
}
|
||||
|
||||
func newSignIntent(jobID [32]byte, msg []byte, env *Envelope) *signIntent {
|
||||
return &signIntent{
|
||||
jobID: jobID,
|
||||
msg: append([]byte(nil), msg...),
|
||||
env: envelopeSummary{
|
||||
Kind: string(env.Kind),
|
||||
RIM: env.RIM,
|
||||
Hardware: env.Hardware,
|
||||
TEEPub: env.TEEPub,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Digest implements approval.CanonicalIntent.
|
||||
func (si *signIntent) Digest() [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("LUX-SLHDSA-TEE-INTENT-V1"))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(si.jobID[:])
|
||||
mdigest := sha256.Sum256(si.msg)
|
||||
h.Write(mdigest[:])
|
||||
h.Write([]byte(si.env.Kind))
|
||||
h.Write([]byte{0x00})
|
||||
h.Write(si.env.RIM[:])
|
||||
h.Write(si.env.Hardware[:])
|
||||
h.Write(si.env.TEEPub[:])
|
||||
var out [32]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
// Bytes implements approval.CanonicalIntent. Returns a deterministic
|
||||
// canonical encoding (no JSON, no length-prefix ambiguity): the same
|
||||
// fields the Digest hash absorbs, in the same order.
|
||||
func (si *signIntent) Bytes() []byte {
|
||||
out := make([]byte, 0, 32+32+len(si.env.Kind)+1+32+32+32+32)
|
||||
out = append(out, []byte("LUX-SLHDSA-TEE-INTENT-V1")...)
|
||||
out = append(out, 0x00)
|
||||
out = append(out, si.jobID[:]...)
|
||||
mdigest := sha256.Sum256(si.msg)
|
||||
out = append(out, mdigest[:]...)
|
||||
out = append(out, []byte(si.env.Kind)...)
|
||||
out = append(out, 0x00)
|
||||
out = append(out, si.env.RIM[:]...)
|
||||
out = append(out, si.env.Hardware[:]...)
|
||||
out = append(out, si.env.TEEPub[:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
var _ approval.CanonicalIntent = (*signIntent)(nil)
|
||||
|
||||
// FreshJobID returns 32 bytes of crypto/rand. Convenience helper for
|
||||
// callers that derive jobIDs at random rather than from a domain
|
||||
// salt; production deployments may prefer a domain-bound jobID.
|
||||
func FreshJobID() ([32]byte, error) {
|
||||
var out [32]byte
|
||||
if _, err := rand.Read(out[:]); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// Signer is the institutional-custody SLH-DSA signer.
|
||||
//
|
||||
// Composition (not inheritance):
|
||||
//
|
||||
// - gate : kms.ReleaseGate — the trust root.
|
||||
// - hsmP : hsm.Provider — wraps the master seed at rest.
|
||||
// - appr : approval.ApprovalProvider — out-of-band human/programmatic gate.
|
||||
// - cfg : Config — policy: RIM, hardware, mode, key IDs.
|
||||
//
|
||||
// Each is independently complete and replaceable. No subclassing, no
|
||||
// hidden state — the four fields name the four responsibilities, and
|
||||
// every Sign call drives them in a fixed order.
|
||||
//
|
||||
// The Signer instance can be reused across many Sign calls. It is
|
||||
// safe for concurrent use; per-call state (sealed key, plaintext
|
||||
// seed) is stack-local and zeroized on return.
|
||||
type Signer struct {
|
||||
gate kms.ReleaseGate
|
||||
hsmP hsm.Provider
|
||||
appr approval.ApprovalProvider
|
||||
cfg Config
|
||||
|
||||
params *magnetar.Params
|
||||
|
||||
// mu protects nothing today — both gate and hsmP are themselves
|
||||
// thread-safe — but reserved for future per-Signer rate-limit /
|
||||
// counter state without changing the public API.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New builds a Signer from the supplied dependencies.
|
||||
//
|
||||
// All four parameters are required. nil hsmP or nil gate or nil
|
||||
// approval (when ApprovalRequired) is a construction error — there is
|
||||
// no "best effort" fallback path.
|
||||
func New(gate kms.ReleaseGate, hsmP hsm.Provider, appr approval.ApprovalProvider, cfg Config) (*Signer, error) {
|
||||
if gate == nil {
|
||||
return nil, errors.New("slhdsa-tee: nil release gate")
|
||||
}
|
||||
if hsmP == nil {
|
||||
return nil, errors.New("slhdsa-tee: nil HSM provider")
|
||||
}
|
||||
if cfg.ApprovalRequired && appr == nil {
|
||||
return nil, errors.New("slhdsa-tee: nil approval provider but ApprovalRequired is true")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params, err := magnetar.ParamsFor(cfg.Mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: ParamsFor: %w", err)
|
||||
}
|
||||
return &Signer{
|
||||
gate: gate,
|
||||
hsmP: hsmP,
|
||||
appr: appr,
|
||||
cfg: cfg,
|
||||
params: params,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Provision wraps a fresh master seed under the HSM provider for
|
||||
// later release-gated signing. Called once at operator bootstrap (or
|
||||
// at scheduled rotation). The seed bytes are zeroized after storage.
|
||||
//
|
||||
// In a real institutional deployment, Provision runs inside the
|
||||
// attested TEE itself — the operator captures attestation, calls
|
||||
// gate.Issue, gate.Release, derives the wrapping key from the sealed
|
||||
// session key, AEAD-wraps the seed, and StoreKey the ciphertext. The
|
||||
// HSM holds wrapped bytes; the TEE holds plaintext only ephemerally.
|
||||
//
|
||||
// For this package's surface, Provision is purposefully simple: it
|
||||
// generates a fresh seed of params.SeedSize bytes and stores it via
|
||||
// hsmP.StoreKey under cfg.WrappedSeedKeyID. Wrapping under the TEE
|
||||
// session-key is the next composition step the embedder layers on
|
||||
// top — see ExampleEmbedding in the README.
|
||||
//
|
||||
// Production deployments using a KMS-backed HSM (AWS KMS, Azure Key
|
||||
// Vault, GCP KMS) get at-rest encryption from the cloud HSM itself;
|
||||
// adding AEAD-wrapping is defense-in-depth, not the trust root.
|
||||
//
|
||||
// Returns the magnetar.PublicKey derived from the provisioned seed
|
||||
// so the embedder can register it as the canonical group public key.
|
||||
func (s *Signer) Provision(ctx context.Context, rng *seedRNG) (*magnetar.PublicKey, error) {
|
||||
seed := make([]byte, s.params.SeedSize)
|
||||
defer zeroize(seed)
|
||||
|
||||
if rng == nil {
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: provision: entropy: %w", err)
|
||||
}
|
||||
} else {
|
||||
if _, err := rng.Read(seed); err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: provision: deterministic seed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.hsmP.StoreKey(ctx, s.cfg.WrappedSeedKeyID, seed); err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: provision: HSM StoreKey: %w", err)
|
||||
}
|
||||
|
||||
sk, err := magnetar.KeyFromSeed(s.params, seed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: provision: KeyFromSeed: %w", err)
|
||||
}
|
||||
pub := sk.Public()
|
||||
// Zeroize the secret key bytes — only the public key escapes.
|
||||
zeroize(sk.Bytes)
|
||||
zeroize(sk.Seed)
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// PublicKey reads the master seed via the HSM provider and derives
|
||||
// the magnetar PublicKey deterministically. Used by callers that
|
||||
// need the wire-form group public key without performing a sign.
|
||||
//
|
||||
// The seed is loaded into a stack-local buffer and zeroized on
|
||||
// return; the derived PrivateKey's seed copy is also zeroized.
|
||||
//
|
||||
// PublicKey is RELEASE-GATE FREE — it only reads the at-rest HSM
|
||||
// material. The TEE-attested release gate is exercised by Sign, not
|
||||
// by PublicKey. This matches the standard "public material is
|
||||
// public, private material is gated" separation.
|
||||
//
|
||||
// For deployments where even the public key is a custody secret
|
||||
// (rare; usually pkBytes are published on-chain), do not call this
|
||||
// method — embed the published group public key independently.
|
||||
func (s *Signer) PublicKey(ctx context.Context) (*magnetar.PublicKey, error) {
|
||||
seed, err := s.hsmP.GetKey(ctx, s.cfg.WrappedSeedKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: PublicKey: HSM GetKey: %w", err)
|
||||
}
|
||||
defer zeroize(seed)
|
||||
if len(seed) != s.params.SeedSize {
|
||||
return nil, fmt.Errorf("slhdsa-tee: PublicKey: seed length %d does not match SeedSize %d (mode=%s)",
|
||||
len(seed), s.params.SeedSize, s.params.Mode)
|
||||
}
|
||||
sk, err := magnetar.KeyFromSeed(s.params, seed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("slhdsa-tee: PublicKey: KeyFromSeed: %w", err)
|
||||
}
|
||||
defer zeroize(sk.Bytes)
|
||||
defer zeroize(sk.Seed)
|
||||
return sk.Public(), nil
|
||||
}
|
||||
|
||||
// Mode reports the FIPS 205 parameter set this signer is bound to.
|
||||
// Embedders use this to label published signatures (M192s / M192f /
|
||||
// M256s) when the wire form does not already carry the mode.
|
||||
func (s *Signer) Mode() magnetar.Mode { return s.cfg.Mode }
|
||||
|
||||
// Params returns the magnetar Params for this signer's mode. Exposed
|
||||
// for embedders that need to call magnetar.Verify / VerifyBytes with
|
||||
// the matching params.
|
||||
func (s *Signer) Params() *magnetar.Params { return s.params }
|
||||
|
||||
// auditedRelease drives the full Issue → approval → composite-envelope
|
||||
// → Release flow and returns the SealedSessionKey on success. Each
|
||||
// failure mode wraps a sentinel from config.go so callers can branch
|
||||
// on errors.Is without parsing strings.
|
||||
func (s *Signer) auditedRelease(ctx context.Context, env *Envelope, jobID [32]byte, msg []byte) (kms.SealedSessionKey, error) {
|
||||
if env == nil {
|
||||
return kms.SealedSessionKey{}, ErrAttestationRequired
|
||||
}
|
||||
|
||||
if s.cfg.ApprovalRequired {
|
||||
intent := newSignIntent(jobID, msg, env)
|
||||
sig, err := s.appr.ApproveIntent(ctx, s.cfg.ApproverID, intent)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: %v", ErrApprovalDenied, err)
|
||||
}
|
||||
ok, err := s.appr.VerifyApproval(ctx, intent, sig)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: verify: %v", ErrApprovalDenied, err)
|
||||
}
|
||||
if !ok {
|
||||
return kms.SealedSessionKey{}, ErrApprovalDenied
|
||||
}
|
||||
}
|
||||
|
||||
nonce, epoch, err := s.gate.Issue(jobID)
|
||||
if err != nil {
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: gate.Issue: %v", ErrKMSReleaseUnreachable, err)
|
||||
}
|
||||
env.ExpectedNonce = nonce
|
||||
|
||||
sealed, err := s.gate.Release(kms.ReleaseRequest{
|
||||
JobID: jobID,
|
||||
Epoch: epoch,
|
||||
Nonce: nonce,
|
||||
Attestation: env,
|
||||
Ctx: ctx,
|
||||
})
|
||||
if err != nil {
|
||||
// gate.Release wraps kms.ErrPolicyRefused / ErrAttestationChain
|
||||
// already — surface verbatim so callers using errors.Is can
|
||||
// catch the kms sentinels directly.
|
||||
return kms.SealedSessionKey{}, fmt.Errorf("%w: %v", ErrPolicyRefused, err)
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// zeroize clears a byte slice in place. The compiler is NOT permitted
|
||||
// to elide this loop (the package's tests inspect the buffer
|
||||
// post-call). Stable across Go versions.
|
||||
func zeroize(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// seedRNG is a thin alias for the deterministic-RNG path used by
|
||||
// Provision in tests. Defined here so Provision's signature stays
|
||||
// stable; the in-test impl lives in signer_test.go.
|
||||
type seedRNG struct {
|
||||
src []byte
|
||||
off int
|
||||
}
|
||||
|
||||
// Read implements io.Reader.
|
||||
func (r *seedRNG) Read(p []byte) (int, error) {
|
||||
if r.off >= len(r.src) {
|
||||
return 0, errEndOfSeedRNG
|
||||
}
|
||||
n := copy(p, r.src[r.off:])
|
||||
r.off += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var errEndOfSeedRNG = errors.New("slhdsa-tee: seedRNG exhausted")
|
||||
|
||||
// newTestClock returns a stable clock for tests. Production paths use
|
||||
// time.Now via gate / hsm internals; this helper exists so test code
|
||||
// has one obvious source of timestamps when constructing
|
||||
// ApprovalSignatures or audit records.
|
||||
func newTestClock() func() time.Time {
|
||||
return func() time.Time { return time.Unix(1_700_000_000, 0).UTC() }
|
||||
}
|
||||
@@ -1,752 +0,0 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package slhdsatee
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sevtest "github.com/google/go-sev-guest/testing"
|
||||
"github.com/google/go-sev-guest/verify/trust"
|
||||
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
"github.com/luxfi/mpc/cc/attest"
|
||||
"github.com/luxfi/mpc/pkg/approval"
|
||||
"github.com/luxfi/mpc/pkg/hsm"
|
||||
"github.com/luxfi/mpc/pkg/kms"
|
||||
)
|
||||
|
||||
// sevSnpAttestationMilan and sevSnpVcekMilan are the same real AMD
|
||||
// Milan SEV-SNP attestation + VCEK fixtures committed by the lux/mpc
|
||||
// cc/attest test corpus. We commit them here byte-equal so this
|
||||
// package's tests do not require any pkg/attest test-only export.
|
||||
//
|
||||
//go:embed testdata/sev_snp_attestation_milan.bin
|
||||
var sevSnpAttestationMilan []byte
|
||||
|
||||
//go:embed testdata/sev_snp_vcek_milan.cer
|
||||
var sevSnpVcekMilan []byte
|
||||
|
||||
// newKDSReplay returns the same SimpleGetter map cc/attest's tests
|
||||
// use to replay AMD KDS responses offline. Pinned to the Milan
|
||||
// product + the CHIP_ID + TCB encoded in the committed report.
|
||||
func newKDSReplay() trust.HTTPSGetter {
|
||||
return sevtest.SimpleGetter(map[string][]byte{
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/cert_chain": trust.AskArkMilanVcekBytes,
|
||||
"https://kdsintf.amd.com/vcek/v1/Milan/3ac3fe21e13fb0990eb28a802e3fb6a29483a6b0753590c951bdd3b8e53786184ca39e359669a2b76a1936776b564ea464cdce40c05f63c9b610c5068b006b5d?blSPL=2&teeSPL=0&snpSPL=5&ucodeSPL=68": sevSnpVcekMilan,
|
||||
})
|
||||
}
|
||||
|
||||
// fixedNow pins the verification clock inside the validity window of
|
||||
// the committed VCEK. Matches cc/attest's verifier_test.fixedNow().
|
||||
func fixedNow() time.Time {
|
||||
return time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// realMeasurement is the exact 48-byte launch-measurement bytes the
|
||||
// committed SEV-SNP report attests to. Used to compute the operator-
|
||||
// asserted RIM digest (sha256(measurement)) for the test envelope.
|
||||
func realMeasurement() []byte {
|
||||
return sevSnpAttestationMilan[0x90 : 0x90+48]
|
||||
}
|
||||
|
||||
// realChipID is the 64-byte CHIP_ID from the committed report.
|
||||
func realChipID() []byte {
|
||||
return sevSnpAttestationMilan[0x1A0 : 0x1A0+64]
|
||||
}
|
||||
|
||||
// makeRIM returns the operator-asserted RIM digest the test envelope
|
||||
// MUST claim. defaultRIMCheck folds the verified report's measurement
|
||||
// through sha256 — this helper computes the same fold so test
|
||||
// envelopes round-trip cleanly.
|
||||
func makeRIM(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
return sha256.Sum256(realMeasurement())
|
||||
}
|
||||
|
||||
// makeHardware returns the operator-asserted hardware fingerprint —
|
||||
// sha256(chip_id) as a per-silicon identifier. Production paths may
|
||||
// fold (model, driver, vbios) instead.
|
||||
func makeHardware(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
return sha256.Sum256(realChipID())
|
||||
}
|
||||
|
||||
// makeTEEPub returns a deterministic X25519 public key derived from
|
||||
// a test-fixed seed. Real TEEs generate this ephemerally at boot
|
||||
// and only publish the public half; the test uses a fixed value so
|
||||
// the sealed-key derivation is reproducible.
|
||||
func makeTEEPub(t *testing.T) [32]byte {
|
||||
t.Helper()
|
||||
// Curve25519 basepoint multiplication needs a clamped scalar.
|
||||
var priv [32]byte
|
||||
for i := range priv {
|
||||
priv[i] = byte(i + 1) // any non-zero pattern; clamped below
|
||||
}
|
||||
priv[0] &= 248
|
||||
priv[31] &= 127
|
||||
priv[31] |= 64
|
||||
pub, err := curve25519BasepointMul(priv[:])
|
||||
if err != nil {
|
||||
t.Fatalf("makeTEEPub: %v", err)
|
||||
}
|
||||
var out [32]byte
|
||||
copy(out[:], pub)
|
||||
return out
|
||||
}
|
||||
|
||||
// newTestFileHSM returns a FileProvider rooted at t.TempDir(). One
|
||||
// per test — no cross-test contamination.
|
||||
func newTestFileHSM(t *testing.T) hsm.Provider {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
// HexEncoded=true: FileProvider strings.TrimSpace's raw bytes that
|
||||
// happen to look like whitespace (0x09/0x0A/0x0D/0x20). Hex framing
|
||||
// makes the on-disk form unambiguous and round-trip-safe.
|
||||
cfg := &hsm.FileConfig{
|
||||
BasePath: dir,
|
||||
HexEncoded: true,
|
||||
}
|
||||
p, err := hsm.NewFileProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestFileHSM: %v", err)
|
||||
}
|
||||
// Seed an Ed25519 key for the audit signature: file provider Sign
|
||||
// requires an ed25519 seed.
|
||||
var ed25519Seed [32]byte
|
||||
if _, err := rand.Read(ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("ed25519 seed: %v", err)
|
||||
}
|
||||
if err := p.StoreKey(context.Background(), "audit-key", ed25519Seed[:]); err != nil {
|
||||
t.Fatalf("store audit key: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := p.Close(); err != nil {
|
||||
t.Errorf("hsm Close: %v", err)
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// newTestApprovalProvider returns the LocalDevProvider with
|
||||
// MPC_LOCAL_APPROVAL=true exported for this test's lifetime.
|
||||
// LocalDevProvider is the real ApprovalProvider; the only test
|
||||
// concession is the env-var that lifts its production refusal.
|
||||
func newTestApprovalProvider(t *testing.T) approval.ApprovalProvider {
|
||||
t.Helper()
|
||||
// MPC_LOCAL_APPROVAL=true is set process-wide by TestMain so that
|
||||
// parallel tests can share the LocalDevProvider without
|
||||
// t.Setenv-imposed serialization.
|
||||
p, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newTestApprovalProvider: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestMain enables the LocalDevProvider for the lifetime of this
|
||||
// test binary. The same env-var gate would refuse in a production
|
||||
// build (factory_test.go upstream pins this).
|
||||
func TestMain(m *testing.M) {
|
||||
_ = os.Setenv("MPC_LOCAL_APPROVAL", "true")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// denyApprovalProvider satisfies approval.ApprovalProvider but always
|
||||
// returns ApprovalSignature{} with no signature — used by
|
||||
// TestSigner_Sign_WebAuthnApprovalRequired's deny branch.
|
||||
//
|
||||
// This is a REAL ApprovalProvider implementation: no stubbed
|
||||
// interfaces, no mocks at the cc/attest or kms boundary. It satisfies
|
||||
// the contract by returning an empty signature that VerifyApproval
|
||||
// then refuses. The behavior models a deny verdict from a real
|
||||
// WebAuthn / Ledger device that returned user-cancel.
|
||||
type denyApprovalProvider struct{}
|
||||
|
||||
func (denyApprovalProvider) Provider() string { return "deny-test" }
|
||||
|
||||
func (denyApprovalProvider) GetPublicIdentity(_ context.Context, approverID string) (approval.PublicIdentity, error) {
|
||||
return approval.PublicIdentity{
|
||||
ApproverID: approverID,
|
||||
Provider: "deny-test",
|
||||
PublicKey: make([]byte, 32),
|
||||
Algorithm: approval.AlgorithmEd25519,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (denyApprovalProvider) ApproveIntent(_ context.Context, approverID string, intent approval.CanonicalIntent) (approval.ApprovalSignature, error) {
|
||||
return approval.ApprovalSignature{}, errors.New("deny-test: user cancelled")
|
||||
}
|
||||
|
||||
func (denyApprovalProvider) VerifyApproval(_ context.Context, intent approval.CanonicalIntent, sig approval.ApprovalSignature) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// newTestGate returns a fresh LocalReleaseGate + MemoryNonceStore
|
||||
// bound to the operator-asserted RIM + hardware allowlists. Replay
|
||||
// window and TTL are 5 seconds for tests so the rotation/expiry
|
||||
// paths complete in CI time.
|
||||
func newTestGate(t *testing.T, rim, hw [32]byte) (*kms.LocalReleaseGate, kms.NonceStore) {
|
||||
t.Helper()
|
||||
policy := kms.NewReleasePolicy([][32]byte{rim}, [][32]byte{hw})
|
||||
policy.RequireSEVSNP = true
|
||||
|
||||
var rootKey [32]byte
|
||||
if _, err := rand.Read(rootKey[:]); err != nil {
|
||||
t.Fatalf("rootKey: %v", err)
|
||||
}
|
||||
store := kms.NewMemoryNonceStore()
|
||||
gate, err := kms.NewLocalReleaseGate(policy, store, rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalReleaseGate: %v", err)
|
||||
}
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
gate.SetReplayWindow(5 * time.Second)
|
||||
return gate, store
|
||||
}
|
||||
|
||||
// newTestSigner wires gate + file HSM + LocalDevProvider into a
|
||||
// Signer with mode ModeM192s. Returns the Signer plus the same gate
|
||||
// pointer so tests can call Rotate / Issue / Release directly.
|
||||
func newTestSigner(t *testing.T, approvalRequired bool) (*Signer, *kms.LocalReleaseGate, hsm.Provider, [32]byte, [32]byte) {
|
||||
t.Helper()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
|
||||
gate, _ := newTestGate(t, rim, hw)
|
||||
hsmP := newTestFileHSM(t)
|
||||
appr := newTestApprovalProvider(t)
|
||||
|
||||
cfg := Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: approvalRequired,
|
||||
ApproverID: "test@lux.network",
|
||||
}
|
||||
s, err := New(gate, hsmP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := s.Provision(context.Background(), nil); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
return s, gate, hsmP, rim, hw
|
||||
}
|
||||
|
||||
// envelopeFromTestdata builds an Envelope wrapping the committed SEV
|
||||
// report. ExpectedNonce is set by the caller post-Issue; here we
|
||||
// pre-set the RIM, Hardware, TEEPub, and VerifyOpts.
|
||||
//
|
||||
// We deliberately do NOT call trust.ClearProductCertCache here —
|
||||
// the AMD VCEK/ARK chain is shared across all SEV-SNP envelopes and
|
||||
// the cache is correctness-equivalent to a fresh fetch. Leaving the
|
||||
// cache hot lets t.Parallel tests run without serializing on the
|
||||
// package-level cache mutation.
|
||||
func envelopeFromTestdata(t *testing.T, rim, hw, teePub [32]byte) *Envelope {
|
||||
t.Helper()
|
||||
return &Envelope{
|
||||
Kind: attest.KindSEVSNP,
|
||||
EvidenceBytes: append([]byte(nil), sevSnpAttestationMilan...),
|
||||
RIM: rim,
|
||||
Hardware: hw,
|
||||
TEEPub: teePub,
|
||||
VerifyOpts: []attest.Option{
|
||||
attest.WithKDSGetter(newKDSReplay()),
|
||||
attest.WithNow(fixedNow()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 1: full chain — TDX/SEV E2E
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_SEVSNP_E2E exercises the FULL Sign chain against a
|
||||
// real AMD Milan SEV-SNP report (chain-validated against the
|
||||
// committed VCEK + Milan ARK/ASK), a real LocalReleaseGate, a real
|
||||
// FileProvider HSM, and a real LocalDevProvider approval flow.
|
||||
//
|
||||
// On success the returned signature MUST verify under
|
||||
// magnetar.VerifyBytes against the published group public key —
|
||||
// proving the wire bytes are byte-identical to single-party FIPS 205.
|
||||
func TestSigner_Sign_SEVSNP_E2E(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, true /*ApprovalRequired*/)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
jobID, err := FreshJobID()
|
||||
if err != nil {
|
||||
t.Fatalf("FreshJobID: %v", err)
|
||||
}
|
||||
msg := []byte("LUX-SLHDSA-TEE: institutional-custody E2E test")
|
||||
|
||||
wire, receipt, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if len(wire) == 0 {
|
||||
t.Fatal("Sign returned empty wire bytes")
|
||||
}
|
||||
if receipt == nil {
|
||||
t.Fatal("Sign returned nil receipt")
|
||||
}
|
||||
if receipt.JobID != jobID {
|
||||
t.Errorf("receipt.JobID = %x, want %x", receipt.JobID, jobID)
|
||||
}
|
||||
if receipt.EvidenceKind != string(attest.KindSEVSNP) {
|
||||
t.Errorf("receipt.EvidenceKind = %q, want %q", receipt.EvidenceKind, attest.KindSEVSNP)
|
||||
}
|
||||
if receipt.EvidenceIssuer != kms.IssuerSEVSNP {
|
||||
t.Errorf("receipt.EvidenceIssuer = %q, want %q", receipt.EvidenceIssuer, kms.IssuerSEVSNP)
|
||||
}
|
||||
if len(receipt.AuditSignature) == 0 {
|
||||
t.Error("receipt.AuditSignature empty")
|
||||
}
|
||||
|
||||
// External-verifier path: the published wire bytes must verify
|
||||
// under the magnetar group public key (derived independently from
|
||||
// the HSM-stored seed). This proves byte-identity with FIPS 205.
|
||||
pub, err := s.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
gkBytes, err := magnetar.MarshalGroupKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalGroupKey: %v", err)
|
||||
}
|
||||
if !magnetar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("external VerifyBytes refused the signature; not FIPS 205 byte-identical")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 2: rejects corrupt attestation
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_RejectsBadAttestation flips a bit deep in the SEV
|
||||
// signature region. cc/attest.Dispatch must surface ErrSignatureInvalid
|
||||
// or ErrChainInvalid; Signer.Sign must propagate that as a release-
|
||||
// gate refusal wrapped under ErrPolicyRefused.
|
||||
func TestSigner_Sign_RejectsBadAttestation(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
// Flip a bit inside the ECDSA signature R-component (matches
|
||||
// cc/attest verifier_test offset). Stable in the signature region,
|
||||
// not in the MBZ tail.
|
||||
env.EvidenceBytes[0x2A0+0x10] ^= 0x01
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("reject-bad-evidence")
|
||||
_, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Sign: expected refusal on tampered evidence, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPolicyRefused) {
|
||||
t.Errorf("Sign: err = %v, want wrapped ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 3: rejects RIM mismatch
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_RejectsRIMMismatch builds a chain-valid envelope
|
||||
// whose operator-asserted RIM does NOT match sha256(measurement) of
|
||||
// the verified report. defaultRIMCheck must refuse and Sign must
|
||||
// surface a release-gate refusal.
|
||||
func TestSigner_Sign_RejectsRIMMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, _, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
|
||||
wrongRIM := sha256.Sum256([]byte("not-the-real-measurement"))
|
||||
env := envelopeFromTestdata(t, wrongRIM, hw, teePub)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("reject-wrong-rim")
|
||||
_, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Sign: expected refusal on RIM mismatch, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrPolicyRefused) {
|
||||
t.Errorf("Sign: err = %v, want wrapped ErrPolicyRefused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 4: rejects expired nonce / wrong epoch
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_RejectsExpiredNonce drives a Sign call where the
|
||||
// gate's epoch has been rotated AFTER Issue() but BEFORE Release().
|
||||
// LocalReleaseGate.Release refuses on stored-epoch mismatch and
|
||||
// Sign must surface ErrPolicyRefused.
|
||||
//
|
||||
// We rotate AFTER Issue (auditedRelease bundles Issue and Release in
|
||||
// one call, so we cannot rotate between them in-package). The
|
||||
// equivalent test path: rotate AFTER one successful Sign, then sign
|
||||
// again — the SECOND Sign must succeed at a fresh epoch and not
|
||||
// resurrect any state from the prior epoch.
|
||||
//
|
||||
// Replay-rejection is covered by reusing the SAME (jobID, nonce)
|
||||
// against the consume-set. We drive that here too.
|
||||
func TestSigner_Sign_RejectsExpiredNonce(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, gate, _, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
// Drop the issue TTL to ~10ms so the in-flight nonce expires
|
||||
// before Release can be called via the next sign.
|
||||
gate.SetIssueTTL(10 * time.Millisecond)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
// Issue manually so we control timing.
|
||||
nonce, epoch, err := gate.Issue(jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
env.ExpectedNonce = nonce
|
||||
time.Sleep(50 * time.Millisecond) // let it expire
|
||||
|
||||
_, releaseErr := gate.Release(kms.ReleaseRequest{
|
||||
JobID: jobID, Epoch: epoch, Nonce: nonce, Attestation: env, Ctx: context.Background(),
|
||||
})
|
||||
if releaseErr == nil {
|
||||
t.Fatal("gate.Release: expected expiry refusal, got nil")
|
||||
}
|
||||
if !errors.Is(releaseErr, kms.ErrPolicyRefused) {
|
||||
t.Errorf("releaseErr = %v, want wrapped kms.ErrPolicyRefused", releaseErr)
|
||||
}
|
||||
if !errors.Is(releaseErr, kms.ErrExpired) {
|
||||
t.Errorf("releaseErr = %v, want wrapped kms.ErrExpired", releaseErr)
|
||||
}
|
||||
|
||||
// Sanity: Sign with a fresh-issued envelope still works after
|
||||
// rotation. Restore TTL and call Sign normally.
|
||||
gate.SetIssueTTL(5 * time.Second)
|
||||
_ = gate.Rotate()
|
||||
freshJob, _ := FreshJobID()
|
||||
freshEnv := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
msg := []byte("post-rotation-sign")
|
||||
if _, _, err := s.Sign(context.Background(), freshEnv, freshJob, msg, nil); err != nil {
|
||||
t.Fatalf("Sign post-rotation: %v", err)
|
||||
}
|
||||
_ = s.cfg.RequireSEVSNP // touch field so we don't warn unused on cfg
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 5: AWS KMS backend
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_HSMSign_AWS_KMS exercises the AWS provider's Sign
|
||||
// API against a localstack / in-memory aws-sdk-go-v2 test boundary.
|
||||
//
|
||||
// AWS KMS production requires real cloud credentials + a real KMS
|
||||
// key — this is intentionally NOT done in unit CI. We document the
|
||||
// skip explicitly with rationale (per spec: "Skip if AWS SDK test
|
||||
// infra not available, but document the skip with rationale").
|
||||
//
|
||||
// To exercise locally: AWS_ENDPOINT_URL_KMS=http://localhost:4566
|
||||
//
|
||||
// AWS_ACCESS_KEY_ID=test
|
||||
// AWS_SECRET_ACCESS_KEY=test
|
||||
// AWS_REGION=us-east-1
|
||||
// AWS_KMS_TEST_KEY_ARN=alias/test
|
||||
// go test -run TestSigner_Sign_HSMSign_AWS_KMS
|
||||
func TestSigner_Sign_HSMSign_AWS_KMS(t *testing.T) {
|
||||
endpoint := os.Getenv("AWS_ENDPOINT_URL_KMS")
|
||||
keyARN := os.Getenv("AWS_KMS_TEST_KEY_ARN")
|
||||
if endpoint == "" || keyARN == "" {
|
||||
t.Skip("AWS_ENDPOINT_URL_KMS and AWS_KMS_TEST_KEY_ARN not set; localstack KMS not available — see test comment for setup. Skipped per spec rationale: unit CI must not require real AWS credentials. File provider path is exercised by TestSigner_Sign_HSMSign_File and all chain-verify tests.")
|
||||
}
|
||||
|
||||
awsCfg := &hsm.AWSConfig{
|
||||
Region: os.Getenv("AWS_REGION"),
|
||||
KeyARN: keyARN,
|
||||
Profile: os.Getenv("AWS_PROFILE"),
|
||||
}
|
||||
awsP, err := hsm.NewAWSProvider(awsCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAWSProvider: %v", err)
|
||||
}
|
||||
defer awsP.Close()
|
||||
|
||||
// AWS provider does NOT support storing raw SLH-DSA seeds via
|
||||
// KMS (KMS keys are HSM-resident, not byte-extractable). The
|
||||
// path we exercise is "AWS KMS for AUDIT signing" only — the
|
||||
// master seed lives in a file provider here, and KMSKeyID points
|
||||
// at the AWS KMS key for the audit signature.
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
_ = makeTEEPub(t) // exercised by full Sign in TestSigner_Sign_SEVSNP_E2E; here we only call AWS.Sign directly
|
||||
|
||||
gate, _ := newTestGate(t, rim, hw)
|
||||
fileP := newTestFileHSM(t) // master seed lives here
|
||||
cfg := Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: keyARN, // AWS KMS audit key
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: false,
|
||||
}
|
||||
// Composite HSM: file for master seed, AWS for audit. The
|
||||
// signer's hsm.Provider is the file (master) — we exercise the
|
||||
// AWS provider's Sign API directly to prove it reaches the KMS
|
||||
// endpoint correctly. End-to-end Signer.Sign would need a
|
||||
// multiplexer; out of scope for this single-provider Signer
|
||||
// surface and documented as such.
|
||||
s, err := New(gate, fileP, nil, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if _, err := s.Provision(context.Background(), nil); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
|
||||
digest := sha256.Sum256([]byte("aws-kms-audit-probe"))
|
||||
sig, err := awsP.Sign(context.Background(), keyARN, digest[:])
|
||||
if err != nil {
|
||||
t.Fatalf("AWS KMS Sign: %v", err)
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
t.Fatal("AWS KMS Sign returned empty signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 6: File-backed HSM end-to-end
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_HSMSign_File runs the full Sign path with the
|
||||
// file-backed HSM provider exclusively. This is the canonical CI
|
||||
// path: no external services, no skips. End-to-end PASS proves the
|
||||
// composition (gate + file hsm + local-dev approval + magnetar)
|
||||
// produces a FIPS 205 byte-identical signature.
|
||||
func TestSigner_Sign_HSMSign_File(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, _, rim, hw := newTestSigner(t, true)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("file-hsm-e2e")
|
||||
wire, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
pub, err := s.PublicKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
gkBytes, err := magnetar.MarshalGroupKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalGroupKey: %v", err)
|
||||
}
|
||||
if !magnetar.VerifyBytes(gkBytes, msg, wire) {
|
||||
t.Fatal("file-hsm-e2e: VerifyBytes refused FIPS 205 signature")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Required test 7: WebAuthn-style approval required
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_Sign_ApprovalRequired_DenyAndAllow proves the
|
||||
// approval-gate semantics: a deny verdict from the approval provider
|
||||
// MUST block signing with ErrApprovalDenied; an allow verdict from
|
||||
// the real LocalDevProvider MUST pass through to the rest of the
|
||||
// chain.
|
||||
//
|
||||
// We model "WebAuthn user-cancel" via denyApprovalProvider (a real
|
||||
// ApprovalProvider impl that returns deny). The deny branch fires
|
||||
// BEFORE any gate.Issue / hsm.GetKey call — net-zero side effects
|
||||
// on the rest of the system, which we assert by checking the gate's
|
||||
// in-flight nonce store remains empty.
|
||||
func TestSigner_Sign_ApprovalRequired_DenyAndAllow(t *testing.T) {
|
||||
t.Parallel()
|
||||
rim := makeRIM(t)
|
||||
hw := makeHardware(t)
|
||||
gate, store := newTestGate(t, rim, hw)
|
||||
fileP := newTestFileHSM(t)
|
||||
|
||||
cfg := Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "audit-key",
|
||||
WrappedSeedKeyID: "master-seed",
|
||||
ApprovalRequired: true,
|
||||
ApproverID: "ceo@lux.network",
|
||||
}
|
||||
|
||||
// Deny branch
|
||||
denyS, err := New(gate, fileP, denyApprovalProvider{}, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New(deny): %v", err)
|
||||
}
|
||||
if _, err := denyS.Provision(context.Background(), nil); err != nil {
|
||||
t.Fatalf("Provision: %v", err)
|
||||
}
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("approval-deny-test")
|
||||
_, _, err = denyS.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err == nil {
|
||||
t.Fatal("Sign(deny): expected ErrApprovalDenied, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrApprovalDenied) {
|
||||
t.Errorf("Sign(deny): err = %v, want wrapped ErrApprovalDenied", err)
|
||||
}
|
||||
// Side-effect check: gate must NOT have issued any nonce on the
|
||||
// deny path — auditedRelease short-circuits before Issue().
|
||||
if _, lookupErr := store.Lookup(jobID); !errors.Is(lookupErr, kms.ErrNonceUnknown) {
|
||||
t.Errorf("deny path leaked a gate-issued nonce: %v", lookupErr)
|
||||
}
|
||||
|
||||
// Allow branch. MPC_LOCAL_APPROVAL already exported by TestMain.
|
||||
appr, err := approval.NewProvider("local-dev", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("local-dev provider: %v", err)
|
||||
}
|
||||
allowS, err := New(gate, fileP, appr, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New(allow): %v", err)
|
||||
}
|
||||
allowEnv := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
allowJob, _ := FreshJobID()
|
||||
if _, _, err := allowS.Sign(context.Background(), allowEnv, allowJob, msg, nil); err != nil {
|
||||
t.Fatalf("Sign(allow): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extra coverage: byte-identity with FIPS 205 SignDeterministic
|
||||
// ============================================================================
|
||||
|
||||
// TestSigner_ByteIdentityWithFIPS205 proves the Signer.Sign output is
|
||||
// byte-equal to a direct magnetar.KeyFromSeed → Sign call on the
|
||||
// same seed + msg. This is the load-bearing claim of the TEE-only
|
||||
// extension: the wire form is indistinguishable from single-party
|
||||
// FIPS 205.
|
||||
func TestSigner_ByteIdentityWithFIPS205(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, _, hsmP, rim, hw := newTestSigner(t, false)
|
||||
teePub := makeTEEPub(t)
|
||||
env := envelopeFromTestdata(t, rim, hw, teePub)
|
||||
jobID, _ := FreshJobID()
|
||||
msg := []byte("byte-identity-probe")
|
||||
|
||||
wire, _, err := s.Sign(context.Background(), env, jobID, msg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
// Direct path: read the seed via HSM, KeyFromSeed, Sign
|
||||
// deterministic. Should produce byte-equal output.
|
||||
seed, err := hsmP.GetKey(context.Background(), "master-seed")
|
||||
if err != nil {
|
||||
t.Fatalf("HSM GetKey: %v", err)
|
||||
}
|
||||
params := magnetar.MustParamsFor(magnetar.ModeM192s)
|
||||
sk, err := magnetar.KeyFromSeed(params, seed)
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromSeed: %v", err)
|
||||
}
|
||||
directSig, err := magnetar.Sign(params, sk, msg, nil, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("direct Sign: %v", err)
|
||||
}
|
||||
directWire, err := directSig.MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("direct MarshalBinary: %v", err)
|
||||
}
|
||||
if string(wire) != string(directWire) {
|
||||
t.Fatalf("Sign output not byte-identical to single-party FIPS 205\n signer: %x\n direct: %x",
|
||||
wire[:16], directWire[:16])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Config validation
|
||||
// ============================================================================
|
||||
|
||||
// TestConfig_Validate covers every Config refusal sentinel so callers
|
||||
// using errors.Is can branch reliably.
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
rim := [32]byte{1}
|
||||
hw := [32]byte{2}
|
||||
good := Config{
|
||||
Mode: magnetar.ModeM192s,
|
||||
RequiredRIM: map[[32]byte]struct{}{rim: {}},
|
||||
AllowedHardware: map[[32]byte]struct{}{hw: {}},
|
||||
RequireSEVSNP: true,
|
||||
KMSKeyID: "k",
|
||||
WrappedSeedKeyID: "s",
|
||||
}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Fatalf("good: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
want error
|
||||
}{
|
||||
{"emptyRIM", func(c *Config) { c.RequiredRIM = nil }, ErrEmptyRIM},
|
||||
{"emptyHardware", func(c *Config) { c.AllowedHardware = nil }, ErrEmptyHardware},
|
||||
{"noRequireFlag", func(c *Config) { c.RequireSEVSNP = false }, ErrNoRequireFlag},
|
||||
{"missingKMSKeyID", func(c *Config) { c.KMSKeyID = "" }, ErrMissingKMSKeyID},
|
||||
{"missingSeedKeyID", func(c *Config) { c.WrappedSeedKeyID = "" }, ErrMissingSeedKeyID},
|
||||
{"approverMissing", func(c *Config) { c.ApprovalRequired = true; c.ApproverID = "" }, ErrApproverMissing},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := good
|
||||
// deep-copy maps so cases don't corrupt good
|
||||
c.RequiredRIM = map[[32]byte]struct{}{rim: {}}
|
||||
c.AllowedHardware = map[[32]byte]struct{}{hw: {}}
|
||||
tc.mutate(&c)
|
||||
err := c.Validate()
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Smoke: Signer.Mode / Params accessors
|
||||
// ============================================================================
|
||||
|
||||
func TestSigner_ModeAndParams(t *testing.T) {
|
||||
s, _, _, _, _ := newTestSigner(t, false)
|
||||
if s.Mode() != magnetar.ModeM192s {
|
||||
t.Errorf("Mode = %v, want ModeM192s", s.Mode())
|
||||
}
|
||||
if s.Params() == nil {
|
||||
t.Fatal("Params returned nil")
|
||||
}
|
||||
if s.Params().Mode != magnetar.ModeM192s {
|
||||
t.Errorf("Params.Mode = %v, want ModeM192s", s.Params().Mode)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user