mirror of
https://github.com/luxfi/threshold.git
synced 2026-10-30 15:15:53 +00:00
Closes the magnetar v1.1 strict-atom transient-SHAKE-bytes residual for
the strict-PQ chain profile.
Why
---
magnetar v1.1's strict-atom Combine path (thbsse_assemble.go) reduces
the FIPS 205 master's residency window to a few microseconds inside
the public combiner's SHAKE-expansion buffers. That window is
acceptable for legacy-compat (public combiner, no host in TCB) but
not for strict-PQ deployments (regulated custody, sovereign
settlement, high-value bridge). Closing the residual fully requires
either MPC-over-SHAKE (open research) or a TEE-attested combiner.
This commit takes the second path: route every strict-PQ Combine
through luxfi/threshold/protocols/slhdsa-tee.
What
----
* `protocols/slhdsa-tee/profile.go` --- `ChainSecurityProfile` value
type (LegacyCompat / StrictPQ) + canonical refusal sentinels
(`ErrMagnetarNoTEEAttestation` = `ERR_MAGNETAR_NO_TEE_ATTESTATION`,
`ErrMagnetarStaleAttestation`, `ErrMagnetarInsufficientQuorum`,
`ErrMagnetarSignatureDivergence`).
* `protocols/slhdsa-tee/pool.go` --- `CombinerPool` t-of-n attested-
combiner registry. Each member binds a `Signer` to its
`LastVerifiedReport`; `Attest` refreshes freshness state via
`env.VerifyEvidence` and pins vendor against `KnownIssuers`;
`Combine` selects the first `t` fresh members, drives each signer's
full slhdsa-tee.Sign machinery, and refuses byte-divergent output.
Defaults: Threshold=2, RotationWindow=60s, KnownIssuers={amd.sev.snp}.
* `pkg/thresholdd/magnetar.go` --- scheme-bound profile gate. Single
function `magnetarRefuseUnderStrictPQ` (one function, one place ---
mirrors `precompile/contract.RefuseUnderStrictPQ`). Called at the
top of `Sign` and `Sign_Ctx`. Under strict-PQ both refuse with the
canonical sentinel; only `Sign_TEE` (single-host) and the new
`Combine_TEE` (t-of-n pool) produce signatures. Three new accessors
on the scheme: `SetChainSecurityProfile`, `SetCombinerPool`,
`AttestCombinerMember`.
* `Combine_TEE` --- the canonical strict-PQ surface. Takes a slice of
`PoolCombineMember` (per-member attestation payload + name) + shared
jobID/msg/ctx, drives `CombinerPool.Combine`, returns the agreed
wire bytes + per-member audit signatures.
Composition with the existing resolver gate
-------------------------------------------
The sister-agent resolver gate (`thresholdd.RefuseUnderStrictPQ` +
`ChainProfileResolver`) fires first in the JSON-RPC routing layer
when the per-request `X-Chain-ID` resolves to strict-PQ. The new
scheme-bound gate is the orthogonal second axis: it refuses whenever
the process-wide profile binding says strict-PQ, regardless of
chain-ID resolution state. Both pass = sign proceeds. Either refuses
= hard refusal.
Tests
-----
* `pkg/thresholdd/magnetar_tee_gate_test.go` (5 tests):
- `TestMagnetarCombine_StrictPQProfile_RequiresTEE`
- `TestMagnetarCombine_AttestationVerified_AllowsSign`
- `TestMagnetarCombine_StaleAttestation_RejectsSign`
- `TestMagnetarCombine_TwoOfThreeAttestedCombiners_MatchSig`
- `TestMagnetarCombine_SignatureDivergence_HardRefusal`
* `protocols/slhdsa-tee/pool_test.go` (5 tests):
- `TestCombinerPool_Constructor_Defaults`
- `TestCombinerPool_AddMember`
- `TestCombinerPool_Attest_VendorPin`
- `TestCombinerPool_Combine_FreshnessGate`
- `TestCombinerPool_Combine_ByteEqualityAcrossQuorum`
- `TestCombinerPool_Combine_Divergence`
All 11 new tests green against the committed AMD Milan SEV-SNP
fixture. Full `./pkg/thresholdd/...` + `./protocols/slhdsa-tee/...`
suites pass.
Production attestation
----------------------
Strict-PQ deployments today MUST use SEV-SNP --- the only `cc/attest`
verifier currently production-implemented. TDX + NRAS verifiers
return `ErrNotImplemented` (tracked at lux/mpc#222 stages 2-3) and
the pool's `KnownIssuers` allowlist will admit them once shipped ---
no magnetar-side code change required.
549 lines
21 KiB
Go
549 lines
21 KiB
Go
// 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)
|