pulsar: revert TEE coupling — TEE belongs to the consumer, not the protocol

Reverts the TEE attestation surface added in v1.0.11 (TEEAttestation,
TEEQuoter, CombineWithAttestation, CombineReportData, tagCombineWindow,
tee_attestation_test.go). Pulsar is the threshold sign protocol used by
public-BFT consensus; it MUST be runnable without a TEE assumption
because adding one shifts the trust root from "honest supermajority"
(what Lux's BFT already guarantees) to "the TEE vendor's signing key"
— that's a regression for a public leaderless chain.

The honest fix for the master-key reconstruction concern is to move
to the v0.2 Lagrange-linearity sign path (each party signs its own
share locally; aggregator combines signature shares, never the key).
That target was already on the Pulsar roadmap before this rip; the
v0.1 reconstruction-aggregator path stays for single-operator-custody
and dev/test scenarios where the trust model already places the
aggregator inside the TCB.

Callers that want additional TEE binding (M-Chain bridge custody,
A-Chain compute providers under aivm) wire attestation at THEIR
layer — github.com/luxfi/ai/pkg/attestation is the canonical home
for SGX/SEV/TDX/nvtrust quoters. Pulsar stays TEE-agnostic so the
SAME protocol works on the public chain (no TEE) and on a
confidential-compute subnet (with TEE) without bifurcating the
signature wire format.

types.go Signature docstring is rewritten to reflect this layering:
v0.1 is dev/single-operator only; v0.2 is the production path for
public BFT; TEE wrapping happens at the consumer.
This commit is contained in:
2026-05-21 14:32:37 -07:00
parent fab4cfd7b8
commit dce4a5c797
4 changed files with 26 additions and 439 deletions
-239
View File
@@ -1,239 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsar
import (
"bytes"
"errors"
"testing"
"golang.org/x/crypto/sha3"
)
// fakeQuoter is a software-only TEEQuoter used to pin the binding
// between the aggregator's claimed report-data and the embedded
// signature event. Production deployments wire in a real SGX/SEV/TDX
// quoter; this test surface exists only so we can pin the function's
// contract end-to-end.
type fakeQuoter struct {
platform string
measurement []byte
failWith error
}
func (f *fakeQuoter) Platform() string { return f.platform }
func (f *fakeQuoter) EnclaveMeasurement() []byte { return f.measurement }
func (f *fakeQuoter) Quote(reportData []byte) ([]byte, error) {
if f.failWith != nil {
return nil, f.failWith
}
// A real quote envelope is platform-specific. For test purposes we
// build a synthetic envelope whose internal layout is:
// measurement (32 B) || reportData (64 B) || sha3-256(quote prefix)
// so the verifier can recover both fields without a vendor parser.
envelope := make([]byte, 0, len(f.measurement)+len(reportData)+32)
envelope = append(envelope, f.measurement...)
envelope = append(envelope, reportData...)
tail := sha3.Sum256(envelope)
envelope = append(envelope, tail[:]...)
return envelope, nil
}
func TestCombineWithAttestation_RoundTrip(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("threshold sign with TEE attestation")
var sid [16]byte
copy(sid[:], "pulsar-tee-01")
attempt := uint32(1)
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i), 0xEE}))
if err != nil {
t.Fatal(err)
}
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
m, err := s.Round1(msg)
if err != nil {
t.Fatalf("Round1 %d: %v", i, err)
}
r1[i] = m
}
r2 := make([]*Round2Message, 3)
for i, s := range signers {
m, _, err := s.Round2(r1)
if err != nil {
t.Fatalf("Round2 %d: %v", i, err)
}
r2[i] = m
}
measurement := bytes.Repeat([]byte{0x4D}, 32)
q := &fakeQuoter{platform: "sgx-dcap", measurement: measurement}
sig, err := CombineWithAttestation(params, pub, msg, nil, false, sid, attempt, quorum, 3, r1, r2, shares, q)
if err != nil {
t.Fatalf("CombineWithAttestation: %v", err)
}
// The signature must verify under stock FIPS 204 — the TEE
// attestation is an ADDITIONAL layer, never a replacement for
// signature verification.
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("FIPS 204 Verify rejected the threshold-produced sig: %v", err)
}
// The attestation must be present and well-formed.
if sig.Attestation == nil {
t.Fatal("Attestation is nil — quoter was wired but produced no attestation")
}
if sig.Attestation.Platform != "sgx-dcap" {
t.Errorf("Platform = %q want sgx-dcap", sig.Attestation.Platform)
}
if !bytes.Equal(sig.Attestation.EnclaveMeasurement, measurement) {
t.Errorf("EnclaveMeasurement mismatch")
}
if len(sig.Attestation.ReportData) != 64 {
t.Fatalf("ReportData len = %d want 64 (SGX REPORT_DATA slot width)", len(sig.Attestation.ReportData))
}
// Report-data must match the canonical binding.
wantReport := CombineReportData(sid, attempt, pub, msg)
if !bytes.Equal(sig.Attestation.ReportData, wantReport) {
t.Error("ReportData does not match CombineReportData(sid,attempt,pub,msg)")
}
// Quote must embed both measurement and report-data verbatim.
embeddedMeasurement := sig.Attestation.Quote[:32]
embeddedReportData := sig.Attestation.Quote[32:96]
if !bytes.Equal(embeddedMeasurement, measurement) {
t.Error("Quote did not embed measurement at the expected offset")
}
if !bytes.Equal(embeddedReportData, sig.Attestation.ReportData) {
t.Error("Quote did not embed report-data at the expected offset")
}
}
func TestCombineWithAttestation_NoQuoter_ProducesPlainSignature(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("no quoter wired — bare Combine")
var sid [16]byte
copy(sid[:], "pulsar-tee-02")
attempt := uint32(1)
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i), 0xEE}))
if err != nil {
t.Fatal(err)
}
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
m, _ := s.Round1(msg)
r1[i] = m
}
r2 := make([]*Round2Message, 3)
for i, s := range signers {
m, _, _ := s.Round2(r1)
r2[i] = m
}
sig, err := CombineWithAttestation(params, pub, msg, nil, false, sid, attempt, quorum, 3, r1, r2, shares, nil)
if err != nil {
t.Fatalf("CombineWithAttestation(nil quoter): %v", err)
}
if sig.Attestation != nil {
t.Error("Attestation is non-nil despite nil quoter")
}
if err := Verify(params, pub, msg, sig); err != nil {
t.Fatalf("Verify: %v", err)
}
}
func TestCombineWithAttestation_QuoterError_DiscardsSignature(t *testing.T) {
params := MustParamsFor(ModeP65)
pub, shares, _, ident := runDKGWithIdentities(t, 5, 3, ModeP65)
msg := []byte("quoter fails — no signature should be returned")
var sid [16]byte
copy(sid[:], "pulsar-tee-03")
attempt := uint32(1)
quorum := []NodeID{shares[0].NodeID, shares[1].NodeID, shares[2].NodeID}
sessionKeys := ident.quorumSessionKeys(t, quorum, sid, msg)
signers := make([]*ThresholdSigner, 3)
for i := 0; i < 3; i++ {
s, err := NewThresholdSigner(params, sid, attempt, quorum, shares[i], sessionKeys[shares[i].NodeID], msg, deterministicReader([]byte{byte(i), 0xEE}))
if err != nil {
t.Fatal(err)
}
signers[i] = s
}
r1 := make([]*Round1Message, 3)
for i, s := range signers {
m, _ := s.Round1(msg)
r1[i] = m
}
r2 := make([]*Round2Message, 3)
for i, s := range signers {
m, _, _ := s.Round2(r1)
r2[i] = m
}
wantErr := errors.New("tee: enclave attestation service unreachable")
q := &fakeQuoter{platform: "sgx-dcap", measurement: bytes.Repeat([]byte{0x4D}, 32), failWith: wantErr}
sig, err := CombineWithAttestation(params, pub, msg, nil, false, sid, attempt, quorum, 3, r1, r2, shares, q)
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v want %v (quoter failure must propagate)", err, wantErr)
}
if sig != nil {
t.Error("sig is non-nil despite quoter error — caller must NOT receive a signature without attestation when one was requested")
}
}
func TestCombineReportData_Stable(t *testing.T) {
// Pure-function property: same inputs ⇒ same 64-byte output.
var sid [16]byte
copy(sid[:], "stable-report-01")
pub := &PublicKey{Mode: ModeP65, Bytes: bytes.Repeat([]byte{0xAB}, 32)}
msg := []byte("hello pulsar")
a := CombineReportData(sid, 7, pub, msg)
b := CombineReportData(sid, 7, pub, msg)
if !bytes.Equal(a, b) {
t.Fatal("CombineReportData is not deterministic")
}
if len(a) != 64 {
t.Fatalf("CombineReportData length = %d want 64", len(a))
}
// Different attempt ⇒ different report-data.
c := CombineReportData(sid, 8, pub, msg)
if bytes.Equal(a, c) {
t.Error("ReportData collides across distinct attempts")
}
// Different message ⇒ different report-data.
d := CombineReportData(sid, 7, pub, []byte("hello pulsar!"))
if bytes.Equal(a, d) {
t.Error("ReportData collides across distinct messages")
}
}
-105
View File
@@ -479,111 +479,6 @@ func Combine(params *Params, groupPubkey *PublicKey, message []byte, ctx []byte,
return &Signature{Mode: params.Mode, Bytes: sigBytes}, nil
}
// TEEQuoter is the runtime hook the aggregator uses to obtain a
// hardware-enclave attestation quote covering the Combine()
// reconstruction window. The aggregator wires a Quoter at startup
// based on which platform the binary is running on:
//
// Intel SGX (DCAP) — sgxquoter.New() in pulsar/tee/sgx
// AMD SEV-SNP — snpguest binding in pulsar/tee/sev
// Intel TDX — tdx-quote in pulsar/tee/tdx
// ARM CCA — cca-token in pulsar/tee/cca
// AWS Nitro Enclaves — nsm-driver in pulsar/tee/nitro
// Software-only (test) — TestQuoter (never accept in production)
//
// Quote MUST embed reportData verbatim in the platform-specific
// REPORT_DATA / USER_DATA slot. Platform implementations that
// cannot bind the full 64 bytes MUST hash reportData to fit
// (SGX has a 64-byte slot, SEV-SNP has a 64-byte slot, TDX has a
// 64-byte slot — none of them need truncation for this caller).
type TEEQuoter interface {
Platform() string
EnclaveMeasurement() []byte
Quote(reportData []byte) ([]byte, error)
}
// CombineReportData builds the 64-byte report-data binding that
// goes into the TEE quote. It is a pure function of the public
// signing-event inputs — no secret material participates.
//
// Domain separation: the "pulsar-combine-window-v1" suffix scopes
// this binding to the threshold-Combine reconstruction window, so
// the same enclave attesting to (say) a DKG transcript or a TEE-
// guarded key-resharing cannot have its quote replayed as proof of
// a Combine reconstruction.
//
// The output is exactly 64 bytes so it drops into the SGX/TDX/SEV
// 64-byte REPORT_DATA slot without truncation or hashing.
func CombineReportData(sessionID [16]byte, attempt uint32, groupPubkey *PublicKey, message []byte) []byte {
parts := [][]byte{
sessionID[:],
{byte(attempt >> 24), byte(attempt >> 16), byte(attempt >> 8), byte(attempt)},
}
if groupPubkey != nil {
parts = append(parts, groupPubkey.Bytes)
}
parts = append(parts, message)
parts = append(parts, []byte("pulsar-combine-window-v1"))
var buf []byte
for _, p := range parts {
buf = append(buf, p...)
}
return cshake256(buf, 64, tagCombineWindow)
}
// CombineWithAttestation runs Combine() and additionally requests
// a TEE attestation quote covering the reconstruction window. The
// returned Signature carries Attestation populated with the
// platform's evidence so relying parties that distrust the
// aggregator host can verify that reconstruction happened inside
// a measured enclave.
//
// When quoter is nil the function falls through to plain Combine();
// the returned Signature has Attestation == nil and the caller is
// responsible for documenting that the deployment's trust model
// includes the aggregator host.
//
// The quoter is invoked AFTER the FIPS 204 sign call succeeds and
// BEFORE the secret-material zeroisation in Combine(). Any error
// from the quoter discards the just-computed signature and returns
// the quoter error verbatim — we never emit a signature without
// attestation when the operator wired one in.
func CombineWithAttestation(
params *Params,
groupPubkey *PublicKey,
message []byte,
ctx []byte,
randomized bool,
sessionID [16]byte,
attempt uint32,
quorum []NodeID,
threshold int,
round1 []*Round1Message,
round2 []*Round2Message,
allShares []*KeyShare,
quoter TEEQuoter,
) (*Signature, error) {
sig, err := Combine(params, groupPubkey, message, ctx, randomized, sessionID, attempt, quorum, threshold, round1, round2, allShares)
if err != nil {
return nil, err
}
if quoter == nil {
return sig, nil
}
reportData := CombineReportData(sessionID, attempt, groupPubkey, message)
quote, qerr := quoter.Quote(reportData)
if qerr != nil {
return nil, qerr
}
sig.Attestation = &TEEAttestation{
Platform: quoter.Platform(),
Quote: quote,
ReportData: reportData,
EnclaveMeasurement: quoter.EnclaveMeasurement(),
}
return sig, nil
}
// transcriptTau1 builds the Round-1 transcript tau_1 = (sid, kappa,
// T, i, pk, mu). tau_1 is bound into every commit and MAC so that a
// cross-session replay of the commit-and-reveal pair becomes a
-8
View File
@@ -60,14 +60,6 @@ const (
tagExpandB = "PULSAR-EXPANDB-V1"
tagComplaint = "PULSAR-COMPLAINT-V1"
tagSeedShare = "PULSAR-SEED-SHARE-V1"
// tagCombineWindow: scopes the 64-byte report-data that the
// aggregator binds into a TEE attestation quote covering the
// Combine() reconstruction window. Domain-separated from every
// other Pulsar transcript tag so a quote attesting to (say) a
// DKG ceremony or a key resharing cannot be replayed as proof
// of a Combine reconstruction with a chosen (sid, attempt,
// groupPubkey, message). See threshold.go CombineReportData.
tagCombineWindow = "PULSAR-COMBINE-WINDOW-V1"
)
// functionName is the SP 800-185 cSHAKE function-name parameter.
+26 -87
View File
@@ -133,96 +133,35 @@ type KeyShare struct {
// applied. A relying party that can verify ML-DSA can verify a
// Pulsar Signature with no code change.
//
// Trust-model note for the threshold path: Combine() briefly
// reconstructs the master ML-DSA secret key in the aggregator's
// memory before calling FIPS 204 sign. This is byte-equal to a
// non-threshold ML-DSA-65 signature by construction (that is the
// whole point — relying parties verify with stock FIPS 204), but
// the aggregator MUST run inside a hardware enclave for the
// post-quantum guarantee to hold against the aggregator host.
// Production deployments populate Attestation with the SGX/SEV/TDX
// quote covering the reconstruction window; relying parties that
// distrust the aggregator host MUST check Attestation in addition
// to ML-DSA Verify. The signature bytes alone are not evidence
// that the reconstruction was enclave-confined.
// Trust-model note for the threshold path: the v0.1 reconstruction-
// aggregator instantiation of Combine() briefly reconstructs the
// master ML-DSA secret key in the aggregator's memory before
// calling FIPS 204 sign. This is byte-equal to a non-threshold
// ML-DSA-65 signature by construction (that is the point — relying
// parties verify with stock FIPS 204), but reconstruction-at-the-
// aggregator is NOT a public-BFT-safe trust model: a compromised
// or coresident-snooping aggregator can exfiltrate the master sk.
//
// v0.1 is therefore a development / single-operator-custody path,
// NOT the public-chain production path. The production path is the
// v0.2 Lagrange-linearity sign (see threshold.go header) where each
// party signs its own ML-DSA share and the aggregator combines
// signature shares — the master key is never reconstructed in any
// party's memory, so the security model reduces to "honest
// supermajority", which is exactly what Lux's BFT consensus already
// assumes. No TEE assumption is required and no TEE vendor enters
// the trust root.
//
// Callers that wish to additionally TEE-bind the aggregator (e.g.
// M-Chain bridge custody where a single operator runs the
// aggregator, or A-Chain compute providers under aivm) wire TEE
// attestation at THEIR layer using github.com/luxfi/ai/pkg/attestation
// — Pulsar itself stays TEE-agnostic so the same protocol works on
// the public chain (no TEE) and on a confidential-compute subnet
// (with TEE), without bifurcating the wire format.
type Signature struct {
Mode Mode
Bytes []byte
// Attestation is the optional TEE evidence covering the
// aggregator's Combine() reconstruction. nil when the aggregator
// ran outside a TEE (single-tenant operator, dev/test, or a
// deployment where the trust model places the aggregator inside
// the TCB by policy). When non-nil this is a vendor quote
// (Intel SGX DCAP, AMD SEV-SNP attestation report, ARM CCA token,
// or a TDX quote) over a transcript hash that binds:
// sid || attempt || quorum || groupPubkey || message ||
// SHA3-256(reconstructed_sk_lifetime_marker)
// The exact attestation envelope is platform-specific; relying
// parties verify the quote against the platform CA and then
// match the report-data binding.
Attestation *TEEAttestation
}
// TEEAttestation carries a hardware-enclave attestation quote that
// covers the aggregator's Combine() reconstruction window.
//
// The signature bytes themselves are stock FIPS 204 ML-DSA — a
// relying party can verify them with any conformant implementation.
// The TEE attestation is an INDEPENDENT layer that lets a relying
// party who does not trust the aggregator host (only the TEE
// vendor) confirm that the brief master-key reconstruction inside
// Combine() happened in an enclave with measured code, sealed
// memory, and no key egress.
//
// Without this attestation the threshold's post-quantum guarantee
// degrades to "trust the aggregator host" for the duration of the
// reconstruction call — typically <1 ms on the master path, but
// long enough for a memory-snooping coresident attacker to exfil
// the ML-DSA sk if the aggregator host is compromised. The TEE
// closes that window.
type TEEAttestation struct {
// Platform is the TEE family that produced Quote.
// "sgx-dcap" — Intel SGX Data Center Attestation Primitives
// "sev-snp" — AMD SEV-SNP attestation report
// "tdx" — Intel TDX quote
// "cca" — ARM Confidential Compute Architecture token
// "nitro" — AWS Nitro Enclaves attestation document
// Relying parties that do not recognise Platform MUST refuse the
// signature (fail-closed); never silently downgrade to "no TEE".
Platform string
// Quote is the raw vendor-formatted attestation evidence. Its
// internal layout is platform-specific; relying parties parse it
// with the platform's verification library (e.g. Intel TGX/QVL,
// AMD's snpguest, AWS nitro-cli). The relying party MUST verify
// the quote against the platform's trust anchor (root CA) before
// trusting any field inside it.
Quote []byte
// ReportData is the 64-byte (SGX/TDX) or 32-byte (SEV-SNP) field
// inside Quote that this aggregator bound to. It is computed as
// SHA3-256 (truncated/padded to the platform's slot width) of:
// sid || attempt || groupPubkey || message ||
// sha3-256("pulsar-combine-window-v1")
// Relying parties recompute ReportData from the signature
// envelope and refuse the signature if it does not match the
// value inside Quote — this is what binds the attestation to
// THIS signing event rather than some other event the same
// enclave attested to earlier.
ReportData []byte
// EnclaveMeasurement is the platform's code-identity hash for
// the aggregator binary. Relying parties pin the expected
// measurement out-of-band (governance vote, signed allowlist,
// hardware-rooted manifest); the attestation only proves the
// reconstruction was inside SOME enclave at that measurement.
// Whether that measurement is acceptable is the relying party's
// policy.
// SGX: MRENCLAVE (32 bytes)
// SEV-SNP: MEASUREMENT (48 bytes)
// TDX: MRTD (48 bytes)
EnclaveMeasurement []byte
}
// Round1Message is the broadcast emitted by ThresholdSigner.Round1.