quasar: Pulsar threshold ML-DSA as a Quasar certificate profile

Add protocol/quasar/pulsar -- the consensus-side adapter that wires the
luxfi/pulsar threshold ML-DSA primitives into Quasar as a cert profile.
Consensus consumes Pulsar; Pulsar never consumes consensus.

Decomplected ownership (the load-bearing boundary):

  Consensus owns ORCHESTRATION: validator-set membership, committee
  sampling, session binding, canonical nonce selection, QC aggregation,
  signer bitmap, ConsensusCert assembly, retries, fallback, slashing
  inputs.

  Pulsar owns CRYPTO: the two-round shape (Round1 binds the nonce,
  Round2 emits a proof-carrying z partial), z aggregation mod q, the
  public hint recovery (FindHint over w' = A*z - c*t1*2^d), and the
  final FIPS 204 ML-DSA signature.

PulsarRoundSigner implements luxfi/pulsar's RoundSigner interface:

  Round1   selects the canonical NonceCert via pulsar.CanonicalNonceIndex
           over the session and refuses any non-canonical nonce
           (anti-grind: a coordinator cannot pick w1, hence the
           challenge, by choosing among boundary-clear certs after
           seeing the message).
  Round2   emits one signer's z Partial; pulsar.VerifyZPartial enforces
           the public bindings + the registered ZK correctness check.
  Finalize selects the canonical signer subset
           (pulsar.CanonicalSignerSet), aggregates z
           (pulsar.MergeAggregates), and assembles the two-certificate
           ConsensusCert accountability artifact (signer bitmap +
           transcript root).

The final FIPS 204 signature byte production is secret-bearing (needs
the module matrix A, t1, the challenge, and the per-coefficient hint),
all package-private to luxfi/pulsar, so it stays there: Finalize
delegates to a pluggable SignatureCore. The core is fail-closed until
luxfi/pulsar ships a reviewed Boundary-Cleared / Carry-Elimination
signer (pulsar.ProductionBCCSigningReady); until then Finalize returns
a structurally-complete ConsensusCert with an EMPTY Signature and
ErrProfileNotReady, and the orchestrator falls back to Corona. No
secret residual is ever opened on this path.

Session binding (session.go): the 32-byte session id is a
domain-separated cSHAKE256 over chain_id, epoch, height, round,
block_hash, validator_set_root, joint_pk_id, dkg_transcript_root,
committee_id, signer_set_root, nonce_pool_root, protocol_version --
every field fixed-width for canonical (non-grindable) encoding. Validate
refuses a session with an all-zero security-relevant root (cross-round
replay guard).

NonceCert generation is a BACKGROUND validator subprotocol filling a
NonceCertPool (pool.go) -- never inline on the block hot path. An empty
pool yields ErrNonceCertPoolEmpty (fall back to Corona), never an inline
nonce.

The profile registers by the package convention (like wave_signer.go's
RoundSigner): a self-contained pulsar.RoundSigner whose ConsensusCert
Signature bytes feed the existing QuasarCert.Pulsar slot and verify via
the polaris.go verifyPulsarLeg / pulsarwire.VerifyBytes path already
wired. No change to polaris.go (cert assembly is a separate concern).

go.mod: replace github.com/luxfi/pulsar => ../luxfi/pulsar. The
RoundSigner / NonceCert / CertProfile API lands on the
fix/threshold-mldsa-hint-leak branch and is not in any published tag
(cached v1.1.1 predates it). Remove the replace once a tag carrying the
API ships.

Tests (protocol/quasar/pulsar, all green): contract implementation,
Round1->Round2->Finalize fail-closed fallback, stub-core success wiring,
empty-pool fallback, non-canonical-nonce rejection, full session-id
field binding, session validation. go build ./... green; existing
protocol/quasar suite unchanged and green.
This commit is contained in:
zooqueen
2026-06-21 02:00:25 -07:00
parent 8deb8e3363
commit f5ab5e2578
8 changed files with 848 additions and 35 deletions
+15 -13
View File
@@ -24,13 +24,11 @@ require (
github.com/luxfi/validators v1.2.0
github.com/luxfi/version v1.0.1
github.com/luxfi/warp v1.18.6
github.com/luxfi/zap v0.7.2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/stretchr/testify v1.11.1
go.uber.org/mock v0.6.0
golang.org/x/crypto v0.52.0
google.golang.org/protobuf v1.36.11
)
require (
@@ -41,7 +39,6 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cockroachdb/errors v1.12.0 // indirect
@@ -67,7 +64,6 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/gtank/merlin v0.1.1 // indirect
github.com/gtank/ristretto255 v0.2.0 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
@@ -89,7 +85,6 @@ require (
github.com/luxfi/geth v1.16.98 // indirect
github.com/luxfi/lens v0.1.4 // 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/luxfi/sampler v1.1.0 // indirect
@@ -98,7 +93,6 @@ require (
github.com/luxfi/zapdb v1.10.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
@@ -125,13 +119,12 @@ require (
go.yaml.in/yaml/v2 v2.4.4 // 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/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gonum.org/v1/gonum v0.17.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
)
@@ -140,8 +133,17 @@ exclude google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1
exclude google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090
// luxfi/pulsar and luxfi/lens are now consumed via published tags
// (pulsar v0.1.5, lens v0.1.3). Local replace directives were removed
// so CI can fetch modules from proxy.golang.org without sibling clones.
// See CROSS-REPO-VERSION-PIN.md for the SHA → tag mapping at the
// March 3, 2026 PQ Consensus Architecture Freeze.
// luxfi/lens is consumed via a published tag (lens v0.1.3). The local
// replace directive was removed so CI can fetch modules from
// proxy.golang.org without sibling clones. See CROSS-REPO-VERSION-PIN.md
// for the SHA → tag mapping at the March 3, 2026 PQ Consensus
// Architecture Freeze.
//
// luxfi/pulsar is pinned to the sibling checkout via a replace directive:
// the Quasar Pulsar cert profile (protocol/quasar/pulsar) consumes the
// RoundSigner / NonceCert / CertProfile API that lands on the
// fix/threshold-mldsa-hint-leak branch and is NOT yet in any published
// tag (the cached v1.1.1 predates it). Remove the replace once a tag
// carrying the RoundSigner API ships (per the pulsar migration plan, the
// v0.2.0 / Pulsar-M rename cut).
replace github.com/luxfi/pulsar => ../luxfi/pulsar
-22
View File
@@ -18,8 +18,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -113,8 +111,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is=
github.com/gtank/merlin v0.1.1/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s=
github.com/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVHQ/0=
@@ -138,10 +134,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/luxfi/accel v1.1.9 h1:Tsk6gXj2uKE19501bD0ajRYdeCHIlTGb6jYyLc+F8hc=
github.com/luxfi/accel v1.1.9/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
github.com/luxfi/accel v1.2.1 h1:MdqerqBT7XWFnw/4HFdFxui9bGVqUFJtddSI24Nahns=
github.com/luxfi/accel v1.2.1/go.mod h1:K00BcnLzEYMPHwCFq8Tf/450ApmTs9xBVvYOnofJCkc=
github.com/luxfi/accel v1.2.2 h1:IzG1qUogapr4/KbbHP9avAimu3ioGJqh67WYKHxfZys=
github.com/luxfi/accel v1.2.2/go.mod h1:5IvpNMfw9FgeBB9oobp+jbauAdhapfhc2bHYPUsHL9Y=
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
@@ -186,8 +178,6 @@ github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
github.com/luxfi/metric v1.5.7 h1:LoSPEUpak2SLcynF+LT2cXjl9ECp4nY+Lia9zudmDv4=
github.com/luxfi/metric v1.5.7/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
@@ -196,8 +186,6 @@ github.com/luxfi/p2p v1.21.1 h1:gmz1JMDhzHIL3dQlhwIDvR4OlFuhNVfnWUl/ipYhAIo=
github.com/luxfi/p2p v1.21.1/go.mod h1:SsNPR5fPGWWNem9plGWhSmRqyDoysJ3kPAN0zG0g3iw=
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
github.com/luxfi/pulsar v1.1.1 h1:jo1jEgUsGiVxpT17Eg7Gw4Ax+07pbKFzq4NhHMLmDpI=
github.com/luxfi/pulsar v1.1.1/go.mod h1:U7tPleeAHJ9dZ61ymtstzLKKoZjxM2zFeGZ+RSjHyRw=
github.com/luxfi/runtime v1.1.0 h1:6TrvzAmZVCTVbR1ebntHTO3/kVBaogPUSkxdDMnrTiw=
github.com/luxfi/runtime v1.1.0/go.mod h1:Mfv2zlXqvfRFMS+/zXgG1TieyP9VnvtVzOGB437+o4Y=
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
@@ -214,17 +202,12 @@ github.com/luxfi/vm v1.2.0 h1:jTwQRHdC9VmyRZTPSn+IqjMju7f6xlxLc6P9CEg+Y2M=
github.com/luxfi/vm v1.2.0/go.mod h1:qasVIBRerVQuvy9vFGrX3H8X8pPMPG5un/KbZSyq5YY=
github.com/luxfi/warp v1.18.6 h1:+ly7mHz77ig4yUyLax32aAjk7tiQQ6eygzOSDNfYRbQ=
github.com/luxfi/warp v1.18.6/go.mod h1:OBN23yiGl+E4qupkPHuetBIEqsI8q5leE8WJH8soFjY=
github.com/luxfi/zap v0.7.2 h1:YecWTWNE5PPJXL56sLIkzS8b23bprUwZ5lPAQuLUtTE=
github.com/luxfi/zap v0.7.2/go.mod h1:1k+nwT+JW802YzuPAuf7CxMSGr/qxvbGgGwi5k6X9Ok=
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
github.com/luxfi/zapdb v1.10.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM=
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk=
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU=
@@ -326,7 +309,6 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
@@ -334,8 +316,6 @@ golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
@@ -354,7 +334,6 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -380,7 +359,6 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+40
View File
@@ -0,0 +1,40 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package pulsar wires the luxfi/pulsar threshold ML-DSA primitives into
// Quasar as a certificate profile. It is the consensus-SIDE adapter: Quasar
// consumes Pulsar, Pulsar never consumes Quasar.
//
// Decomplected ownership (the load-bearing boundary of this package):
//
// Consensus owns ORCHESTRATION:
// - validator-set membership and committee sampling (prism.Cut)
// - session binding to the consensus round (PulsarSession)
// - canonical, non-grindable nonce selection from the pool
// - QC aggregation of z-shares, signer bitmap, ConsensusCert assembly
// - retries, coarse aborts, fallback policy, slashing inputs
//
// Pulsar owns CRYPTO:
// - the two-round shape (Round1 binds the nonce; Round2 emits a
// proof-carrying z partial)
// - z aggregation modulo q, the public hint recovery (FindHint over the
// public w' = A·z − c·t1·2^d), and the final FIPS 204 ML-DSA signature
//
// The crypto-side contract is luxfi/pulsar's RoundSigner interface. The final
// FIPS 204 signature byte production is secret-bearing (it needs the module
// matrix A, t1, the challenge, and the per-coefficient hint), so it lives
// ONLY inside package pulsar — never here. This package therefore drives the
// orchestration it owns end to end, and delegates the signature-assembly step
// to a pluggable pulsarlib.RoundSigner CORE. The core is fail-closed until the
// lib ships a sound Boundary-Cleared / Carry-Elimination (BCC/CEF) signer with
// externally-reviewed ZK proofs (pulsar.ProductionBCCSigningReady). Until then
// Finalize returns a structurally-complete ConsensusCert with an EMPTY
// Signature and reports ErrProfileNotReady, and the Quasar orchestrator falls
// back to the Corona (Ring-LWE) profile. No secret residual is ever opened on
// this path.
//
// NonceCerts are produced by a BACKGROUND validator NonceTranscript
// subprotocol that fills a NonceCertPool — never inline on the block hot path.
// An empty pool yields ErrNonceCertPoolEmpty (a fallback signal), never an
// inline nonce.
package pulsar
+41
View File
@@ -0,0 +1,41 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// errors.go -- typed errors for the Pulsar cert profile. These are the
// fallback / retry signals the Quasar orchestrator matches against to decide
// whether to fall back to Corona, retry with a fresh nonce, or slash.
package pulsar
import "errors"
var (
// ErrNonceCertPoolEmpty signals the background NonceCert pool is empty.
// The orchestrator MUST fall back to Corona (or retry once the pool
// refills); the hot path NEVER generates a nonce inline.
ErrNonceCertPoolEmpty = errors.New("quasar/pulsar: NonceCert pool empty; fall back to Corona")
// ErrProfileNotReady signals the secret-bearing FIPS 204 signature-assembly
// core is not registered (pulsar.ProductionBCCSigningReady() is false: the
// BCC/CEF ZK proofs are fail-closed). Finalize still returns a
// structurally-complete ConsensusCert with an EMPTY Signature; the
// orchestrator falls back to Corona.
ErrProfileNotReady = errors.New("quasar/pulsar: BCC signing core not registered; fall back to Corona")
// ErrSessionFieldUnset rejects a PulsarSession with an all-zero
// security-relevant root (unbound field). Signing under it would allow
// cross-round replay.
ErrSessionFieldUnset = errors.New("quasar/pulsar: session has an unset security-relevant field")
// ErrNonCanonicalNonce signals the supplied nonce id is not the canonical
// pool selection for this session. Every signer recomputes the canonical
// index and refuses a non-canonical nonce (anti-grind).
ErrNonCanonicalNonce = errors.New("quasar/pulsar: non-canonical nonce for session")
// ErrInsufficientShares signals fewer collected shares than the threshold.
ErrInsufficientShares = errors.New("quasar/pulsar: fewer shares than threshold")
// ErrNoNonceCert signals Round2/Finalize were called without a bound
// Round1 (no NonceCert).
ErrNoNonceCert = errors.New("quasar/pulsar: no bound NonceCert (call Round1 first)")
)
+118
View File
@@ -0,0 +1,118 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// pool.go -- the background NonceCert pool.
//
// NonceCert generation is a BACKGROUND validator subprotocol (Pulsar's
// NonceMPC: validators jointly compute w1 = HighBits(w) and BoundaryClear(w)
// over hidden w = A·y shares, then quorum-sign a clearance certificate). It is
// NOT on the block hot path. The hot path only CONSUMES a pre-cleared cert
// from this pool. If the pool is empty, the Pulsar profile fails over to
// Corona; it NEVER generates a nonce inline (that would either block consensus
// on an MPC ceremony or, worse, open the secret residual w).
package pulsar
import (
"sync"
pulsarlib "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
// NonceCertPool is the consumer interface the hot path sees. A production
// implementation is fed by the background NonceMPC subprotocol; this package
// ships an in-memory FIFO (MemNonceCertPool) for tests and single-process
// deployments. The interface is the seam: consensus owns the pool's lifecycle
// and replenishment; this profile only takes a cert and binds it.
type NonceCertPool interface {
// Root is the Merkle/commitment root of the current pool contents. It is
// bound into the session id (PulsarSession.NoncePoolRoot) so the canonical
// nonce selection is pinned to the exact pool the signers agree on.
Root() [32]byte
// Size is the number of unconsumed certs currently available.
Size() uint64
// At returns the cert at canonical pool index i (i < Size). The index is
// chosen by pulsarlib.CanonicalNonceIndex over (sessionID, Root, Size), so
// no coordinator can grind w1 by choosing among many cleared certs after
// seeing the message.
At(i uint64) (pulsarlib.NonceCert, bool)
// Consume marks the cert at index i as spent so it is never reused across
// rounds (nonce reuse in a Schnorr/Fiat-Shamir-style scheme leaks the key).
Consume(i uint64)
}
// MemNonceCertPool is an in-memory, mutex-guarded NonceCertPool. It is the
// reference consumer for tests and for a single coordinator process that has
// already run the background NonceMPC. It does NOT run the MPC itself.
type MemNonceCertPool struct {
mu sync.Mutex
certs []pulsarlib.NonceCert
consumed []bool
root [32]byte
}
// NewMemNonceCertPool builds a pool from already-cleared NonceCerts and a
// precomputed commitment root. The caller (the background subprotocol) is
// responsible for the root being a sound commitment to certs.
func NewMemNonceCertPool(certs []pulsarlib.NonceCert, root [32]byte) *MemNonceCertPool {
return &MemNonceCertPool{
certs: certs,
consumed: make([]bool, len(certs)),
root: root,
}
}
// Root implements NonceCertPool.
func (p *MemNonceCertPool) Root() [32]byte {
p.mu.Lock()
defer p.mu.Unlock()
return p.root
}
// Size implements NonceCertPool: the count of unconsumed certs.
func (p *MemNonceCertPool) Size() uint64 {
p.mu.Lock()
defer p.mu.Unlock()
var n uint64
for i := range p.certs {
if !p.consumed[i] {
n++
}
}
return n
}
// At implements NonceCertPool over the live (unconsumed) view: index i selects
// the i-th still-available cert in pool order.
func (p *MemNonceCertPool) At(i uint64) (pulsarlib.NonceCert, bool) {
p.mu.Lock()
defer p.mu.Unlock()
var seen uint64
for idx := range p.certs {
if p.consumed[idx] {
continue
}
if seen == i {
return p.certs[idx], true
}
seen++
}
return pulsarlib.NonceCert{}, false
}
// Consume implements NonceCertPool: marks the i-th available cert spent.
func (p *MemNonceCertPool) Consume(i uint64) {
p.mu.Lock()
defer p.mu.Unlock()
var seen uint64
for idx := range p.certs {
if p.consumed[idx] {
continue
}
if seen == i {
p.consumed[idx] = true
return
}
seen++
}
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// round_signer.go -- PulsarRoundSigner: the Quasar cert-profile adapter that
// implements luxfi/pulsar's RoundSigner by CONSUMING its primitives.
//
// The three round methods map onto the consensus hot path:
//
// Round1 binds the canonical NonceCert (selected via
// pulsar.CanonicalNonceIndex over the session) to the consensus
// session id. It refuses a non-canonical nonce (anti-grind).
// Round2 emits one signer's proof-carrying z Partial. The public bindings
// (party / session / nonce) are enforced by pulsar.VerifyZPartial.
// Finalize selects the canonical signer subset (pulsar.CanonicalSignerSet),
// aggregates z (pulsar.MergeAggregates), assembles the ConsensusCert
// accountability artifact (signer bitmap + transcript root), and asks
// the registered BCC core to produce the final FIPS 204 signature.
// If no core is registered, the Signature is left empty and
// ErrProfileNotReady is returned for fallback to Corona.
package pulsar
import (
pulsarlib "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
// SignatureCore is the secret-bearing seam. Producing the final FIPS 204
// ML-DSA signature requires the module matrix A, t1, the challenge, and the
// per-coefficient public hint (FindHint over w' = A·z − c·t1·2^d). All of that
// is package-private to luxfi/pulsar, so a sound implementation MUST live
// inside that package and be injected here. Until luxfi/pulsar ships a
// reviewed Boundary-Cleared / Carry-Elimination signer, no core is registered
// and the profile fails closed (ErrProfileNotReady), falling back to Corona.
//
// The core receives only PUBLIC inputs: the aggregated z (Aggregate.ZSum), the
// nonce cert (which carries only w1 + a clearance QC, never w), and the
// session. It never sees secret residuals.
type SignatureCore interface {
// AssembleSignature recovers the public hint and emits the FIPS 204
// signature for the aggregated z under the joint key bound to the session.
AssembleSignature(
session PulsarSession,
cert pulsarlib.NonceCert,
agg pulsarlib.Aggregate,
) (pulsarlib.Signature, error)
}
// PulsarRoundSigner is the consensus-side Pulsar cert profile. It is
// constructed by the Quasar orchestration layer (validator set, sampling, and
// QC aggregation live there); this type owns only the per-round crypto
// orchestration that the pulsar.RoundSigner contract names.
//
// It satisfies pulsarlib.RoundSigner.
type PulsarRoundSigner struct {
// Session is the consensus binding for this round. SessionID() is the
// 32-byte domain-separated session id every signer agrees on.
Session PulsarSession
// Pool is the background NonceCert pool. Round1 selects the canonical cert
// from it; an empty pool yields ErrNonceCertPoolEmpty (fallback signal).
Pool NonceCertPool
// Threshold is T: the minimum number of valid partials Finalize aggregates.
Threshold int
// ValidatorSetSize bounds the signer bitmap (every set bit must index a
// validator). Used by the ConsensusCert structural check.
ValidatorSetSize int
// L is the ML-DSA secret dimension (ℓ) for the active mode; it sizes the
// z-vector aggregation. For ML-DSA-65 (ModeP65) this is 5.
L int
// Core produces the final FIPS 204 signature. nil means fail-closed: the
// profile assembles the ConsensusCert but leaves the Signature empty and
// returns ErrProfileNotReady.
Core SignatureCore
}
// compile-time assertion: PulsarRoundSigner implements the lib contract.
var _ pulsarlib.RoundSigner = (*PulsarRoundSigner)(nil)
// Profile reports the Pulsar (Module-LWE threshold ML-DSA) certificate
// profile. This is the value the QuasarCert.Pulsar leg corresponds to.
func (s *PulsarRoundSigner) Profile() pulsarlib.CertProfile {
return pulsarlib.ProfilePulsar
}
// Round1 binds the canonical NonceCert to the consensus session.
//
// The caller passes the session id (which MUST equal s.Session.SessionID();
// callers that already hold the session can pass it without recomputing) and a
// candidate (nonceID, cert). Round1 recomputes the canonical pool index from
// (sessionID, pool root, pool size) and refuses anything but the canonical
// cert -- this is the anti-grind rule: a coordinator cannot pick w1 (hence the
// challenge) by choosing among many boundary-clear certs after seeing the
// message.
func (s *PulsarRoundSigner) Round1(
sessionID, nonceID [32]byte,
cert pulsarlib.NonceCert,
) (pulsarlib.SignRound1, error) {
if s.Pool == nil || s.Pool.Size() == 0 {
return pulsarlib.SignRound1{}, ErrNonceCertPoolEmpty
}
// Canonical, non-grindable selection over the exact pool bound into the
// session (s.Session.NoncePoolRoot must equal s.Pool.Root()).
idx := pulsarlib.CanonicalNonceIndex(sessionID, s.Pool.Root(), s.Pool.Size())
canonical, ok := s.Pool.At(idx)
if !ok {
return pulsarlib.SignRound1{}, ErrNonceCertPoolEmpty
}
// The supplied cert MUST be the canonical one for this session.
if canonical.NonceID != nonceID || cert.NonceID != canonical.NonceID {
return pulsarlib.SignRound1{}, ErrNonCanonicalNonce
}
return pulsarlib.SignRound1{
SessionID: sessionID,
NonceID: canonical.NonceID,
NonceCert: canonical,
}, nil
}
// Round2 produces one signer's proof-carrying z Partial. The PartialInput
// carries the public bindings (party id, session, nonce, DKG/nonce
// commitments, the challenge, and the packed z-share); pulsar.VerifyZPartial
// enforces them and delegates the zero-knowledge correctness check to the
// registered (fail-closed) PartialZVerifier. The Partial itself carries no
// hint-secret field (no c*s2, c*t0, r0, LowBits).
func (s *PulsarRoundSigner) Round2(
r1 pulsarlib.SignRound1,
in pulsarlib.PartialInput,
) (pulsarlib.Partial, error) {
if r1.NonceID == ([32]byte{}) {
return pulsarlib.Partial{}, ErrNoNonceCert
}
// Bind the partial to this round's session + nonce, regardless of any
// mismatched values the caller put in PartialInput.
in.SessionID = r1.SessionID
in.NonceID = r1.NonceID
p := pulsarlib.Partial{
PartyID: in.PartyID,
SessionID: r1.SessionID,
NonceID: r1.NonceID,
ZShare: in.ZShare,
}
// Enforce the public bindings (party/session/nonce/z) + the registered ZK
// correctness check. Fails closed when no sound PartialZVerifier is set.
if err := pulsarlib.VerifyZPartial(&p, in); err != nil {
return pulsarlib.Partial{}, err
}
return p, nil
}
// Finalize selects the canonical signer subset, aggregates the z-shares,
// assembles the two-certificate accountability artifact, and asks the BCC core
// for the final FIPS 204 signature.
//
// Returns the Aggregate (z-sum + bitmap), the ConsensusCert (signer bitmap +
// transcript root + signature), and an error. When no signature core is
// registered the ConsensusCert is structurally complete but its Signature is
// empty and the error is ErrProfileNotReady (fallback to Corona). The Aggregate
// is returned in BOTH cases so the orchestrator can fan it into a higher tree
// node regardless.
func (s *PulsarRoundSigner) Finalize(
r1 pulsarlib.SignRound1,
partials []pulsarlib.Partial,
) (pulsarlib.Aggregate, pulsarlib.ConsensusCert, error) {
// 1. Canonical, anti-grind signer subset: deterministic first-threshold by
// PartyID, so an aggregator cannot grind z / the hint / the signature
// bytes by choosing among valid signer sets.
chosen, bitmap, err := pulsarlib.CanonicalSignerSet(partials, s.Threshold)
if err != nil {
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, err
}
// 2. Aggregate z modulo q via the tree-merge path (associative; equals the
// flat sum). Each chosen partial becomes a singleton Aggregate so we can
// use the exported MergeAggregates (z-sums only; no hint material).
children := make([]pulsarlib.Aggregate, len(chosen))
for i, p := range chosen {
children[i] = pulsarlib.Aggregate{
SessionID: r1.SessionID,
NonceID: r1.NonceID,
SignerBitmap: singletonBitmap(p.PartyID),
ZSum: p.ZShare,
}
}
agg, err := pulsarlib.MergeAggregates(children, s.L)
if err != nil {
return pulsarlib.Aggregate{}, pulsarlib.ConsensusCert{}, err
}
// Carry the canonical bitmap (MergeAggregates rebuilds the union, which
// equals the canonical bitmap for a disjoint chosen set).
agg.SignerBitmap = bitmap
// 3. Structural accountability artifact. Transcript root binds the nonce
// transcript the signers cleared; the bitmap proves which validators
// participated (BLS-like accountability for a lattice scheme).
cert := pulsarlib.ConsensusCert{
Epoch: s.Session.Epoch,
Height: s.Session.Height,
Round: s.Session.Round,
BlockHash: s.Session.BlockHash,
JointPKID: s.Session.JointPKID,
SignerBitmap: bitmap,
TranscriptRoot: r1.NonceCert.NonceTranscriptRoot,
}
// 4. The secret-bearing FIPS 204 assembly. Fail closed if no core.
if s.Core == nil {
return agg, cert, ErrProfileNotReady
}
sig, err := s.Core.AssembleSignature(s.Session, r1.NonceCert, agg)
if err != nil {
return agg, cert, err
}
cert.Signature = sig
return agg, cert, nil
}
// singletonBitmap returns a one-bit bitmap with only PartyID set, sized to hold
// that bit. MergeAggregates unions these into the full signer bitmap.
func singletonBitmap(partyID uint32) []byte {
bm := make([]byte, partyID/8+1)
bm[partyID/8] |= 1 << (partyID % 8)
return bm
}
+290
View File
@@ -0,0 +1,290 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// round_signer_test.go -- focused tests that the Pulsar cert profile builds,
// implements the luxfi/pulsar RoundSigner contract, and that a round-trip stub
// (Round1 -> Round2 -> Finalize) drives the consensus-owned orchestration
// correctly using the exported pulsar primitives.
//
// These tests deliberately exercise ONLY the consensus-side orchestration this
// package owns. The secret-bearing FIPS 204 signature assembly lives inside
// luxfi/pulsar and is fail-closed until its BCC/CEF ZK proofs are reviewed; we
// assert the fail-closed fallback and (via a stub core) the success wiring,
// without claiming a real threshold signature.
package pulsar
import (
"bytes"
"testing"
pulsarlib "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
)
func init() {
// The hot path's Round2 calls pulsar.VerifyZPartial, which delegates the
// zero-knowledge check to the registered PartialZVerifier (fail-closed by
// default). For the orchestration round-trip we register an accept-bindings
// verifier exactly as luxfi/pulsar's own partial_test.go does; the public
// bindings (party/session/nonce/z) are still enforced unconditionally.
pulsarlib.RegisterPartialZVerifier(acceptBindingsOnly{})
}
type acceptBindingsOnly struct{}
func (acceptBindingsOnly) VerifyPartial(*pulsarlib.Partial, []byte, []byte, []byte) error {
return nil
}
// stubCore is a TEST-ONLY signature core: it returns a deterministic,
// non-empty Signature so we can exercise the Finalize success path. It is NOT a
// real threshold ML-DSA signer (that must live in luxfi/pulsar). It proves the
// wiring: Finalize hands the core the PUBLIC aggregate + nonce cert + session,
// and threads the returned Signature into the ConsensusCert.
type stubCore struct{ sawZSumLen int }
func (c *stubCore) AssembleSignature(
_ PulsarSession,
cert pulsarlib.NonceCert,
agg pulsarlib.Aggregate,
) (pulsarlib.Signature, error) {
c.sawZSumLen = len(agg.ZSum)
return pulsarlib.Signature{Mode: pulsarlib.ModeP65, Bytes: []byte("stub-sig")}, nil
}
// fullSession returns a PulsarSession with every security-relevant root set
// (so Validate passes) and a NoncePoolRoot matching the supplied pool root.
func fullSession(poolRoot [32]byte) PulsarSession {
mk := func(b byte) [32]byte {
var x [32]byte
for i := range x {
x[i] = b
}
return x
}
return PulsarSession{
ChainID: mk(0x11),
Epoch: 7,
Height: 42,
Round: 3,
BlockHash: mk(0x22),
ValidatorSetRoot: mk(0x33),
JointPKID: mk(0x44),
DKGTranscriptRoot: mk(0x55),
CommitteeID: mk(0x66),
SignerSetRoot: mk(0x77),
NoncePoolRoot: poolRoot,
ProtocolVersion: 1,
}
}
// makePool builds a one-cert in-memory pool whose single NonceCert carries a
// non-zero transcript root (so the ConsensusCert transcript binding is real).
func makePool() (*MemNonceCertPool, [32]byte) {
var poolRoot [32]byte
for i := range poolRoot {
poolRoot[i] = 0xAB
}
var nonceID, trRoot [32]byte
for i := range nonceID {
nonceID[i] = 0xC0
trRoot[i] = 0x0D
}
cert := pulsarlib.NonceCert{NonceID: nonceID, NonceTranscriptRoot: trRoot}
return NewMemNonceCertPool([]pulsarlib.NonceCert{cert}, poolRoot), nonceID
}
// TestPulsarRoundSignerImplementsContract is a compile-time + runtime check
// that the profile satisfies luxfi/pulsar's RoundSigner and reports the right
// profile value.
func TestPulsarRoundSignerImplementsContract(t *testing.T) {
var rs pulsarlib.RoundSigner = &PulsarRoundSigner{}
if got := rs.Profile(); got != pulsarlib.ProfilePulsar {
t.Fatalf("Profile() = %v, want ProfilePulsar", got)
}
}
// TestRoundTripFailClosed drives Round1 -> Round2 -> Finalize with NO signature
// core: the orchestration completes (canonical nonce, canonical signer subset,
// z-aggregate, ConsensusCert), but the FIPS 204 signature is empty and the
// error is the Corona-fallback signal.
func TestRoundTripFailClosed(t *testing.T) {
pool, nonceID := makePool()
sess := fullSession(pool.Root())
if err := sess.Validate(); err != nil {
t.Fatalf("session Validate: %v", err)
}
sid := sess.SessionID()
s := &PulsarRoundSigner{
Session: sess,
Pool: pool,
Threshold: 2,
ValidatorSetSize: 8,
L: 5, // ML-DSA-65 secret dimension ℓ
Core: nil,
}
// Round1: bind the canonical nonce.
canonical, ok := pool.At(pulsarlib.CanonicalNonceIndex(sid, pool.Root(), pool.Size()))
if !ok {
t.Fatal("pool.At canonical index failed")
}
r1, err := s.Round1(sid, nonceID, canonical)
if err != nil {
t.Fatalf("Round1: %v", err)
}
if r1.SessionID != sid || r1.NonceID != nonceID {
t.Fatal("Round1 did not bind session/nonce")
}
// Round2: two signers' z-partials. ZShare length must match L*N*polyBytes
// only matters to the real core; for aggregation we just need consistent
// non-empty packed vectors.
partials := make([]pulsarlib.Partial, 0, 2)
for _, party := range []uint32{2, 5} {
in := pulsarlib.PartialInput{
PartyID: party,
ZShare: bytes.Repeat([]byte{byte(party)}, 5*256*4),
}
p, err := s.Round2(r1, in)
if err != nil {
t.Fatalf("Round2 party %d: %v", party, err)
}
partials = append(partials, p)
}
// Finalize: structurally complete cert, empty signature, fallback error.
agg, cert, err := s.Finalize(r1, partials)
if err != ErrProfileNotReady {
t.Fatalf("Finalize err = %v, want ErrProfileNotReady", err)
}
if len(cert.Signature.Bytes) != 0 {
t.Fatal("expected empty signature on fail-closed path")
}
// The ConsensusCert must still be a valid accountability artifact:
// quorum=2 over the canonical 2-signer bitmap, all bits in-set.
if err := cert.VerifyStructure(2, s.ValidatorSetSize); err != nil {
t.Fatalf("ConsensusCert.VerifyStructure: %v", err)
}
if cert.TranscriptRoot != canonical.NonceTranscriptRoot {
t.Fatal("ConsensusCert did not bind the nonce transcript root")
}
if cert.Epoch != sess.Epoch || cert.Height != sess.Height || cert.Round != sess.Round {
t.Fatal("ConsensusCert did not bind the consensus round")
}
if len(agg.ZSum) == 0 {
t.Fatal("aggregate z-sum is empty")
}
}
// TestRoundTripWithStubCore proves the success wiring: with a (stub) core
// registered, Finalize threads the assembled signature into the cert and hands
// the core the public aggregate.
func TestRoundTripWithStubCore(t *testing.T) {
pool, nonceID := makePool()
sess := fullSession(pool.Root())
sid := sess.SessionID()
core := &stubCore{}
s := &PulsarRoundSigner{
Session: sess, Pool: pool, Threshold: 2, ValidatorSetSize: 8, L: 5, Core: core,
}
canonical, _ := pool.At(pulsarlib.CanonicalNonceIndex(sid, pool.Root(), pool.Size()))
r1, err := s.Round1(sid, nonceID, canonical)
if err != nil {
t.Fatalf("Round1: %v", err)
}
var partials []pulsarlib.Partial
for _, party := range []uint32{1, 4} {
p, err := s.Round2(r1, pulsarlib.PartialInput{PartyID: party, ZShare: bytes.Repeat([]byte{byte(party)}, 5*256*4)})
if err != nil {
t.Fatalf("Round2: %v", err)
}
partials = append(partials, p)
}
_, cert, err := s.Finalize(r1, partials)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if string(cert.Signature.Bytes) != "stub-sig" {
t.Fatal("Finalize did not thread the core's signature into the cert")
}
if core.sawZSumLen == 0 {
t.Fatal("core was not handed the aggregated z-sum")
}
}
// TestEmptyPoolFallsBack: an empty NonceCert pool yields the Corona-fallback
// signal, never an inline nonce.
func TestEmptyPoolFallsBack(t *testing.T) {
var root [32]byte
root[0] = 1
empty := NewMemNonceCertPool(nil, root)
sess := fullSession(root)
s := &PulsarRoundSigner{Session: sess, Pool: empty, Threshold: 2, ValidatorSetSize: 8, L: 5}
var nid [32]byte
nid[0] = 9
if _, err := s.Round1(sess.SessionID(), nid, pulsarlib.NonceCert{NonceID: nid}); err != ErrNonceCertPoolEmpty {
t.Fatalf("Round1 on empty pool err = %v, want ErrNonceCertPoolEmpty", err)
}
}
// TestNonCanonicalNonceRejected: a cert that is not the canonical pool
// selection for the session is refused (anti-grind).
func TestNonCanonicalNonceRejected(t *testing.T) {
pool, _ := makePool()
sess := fullSession(pool.Root())
s := &PulsarRoundSigner{Session: sess, Pool: pool, Threshold: 2, ValidatorSetSize: 8, L: 5}
var bogus [32]byte
bogus[0] = 0xFF // not the canonical nonce id
if _, err := s.Round1(sess.SessionID(), bogus, pulsarlib.NonceCert{NonceID: bogus}); err != ErrNonCanonicalNonce {
t.Fatalf("Round1 with non-canonical nonce err = %v, want ErrNonCanonicalNonce", err)
}
}
// TestSessionIDBindsAllFields: changing any one binding field changes the
// session id (the binding is load-bearing across the whole round context).
func TestSessionIDBindsAllFields(t *testing.T) {
var root [32]byte
root[0] = 0xAB
base := fullSession(root)
baseID := base.SessionID()
mutators := map[string]func(*PulsarSession){
"chain_id": func(s *PulsarSession) { s.ChainID[0] ^= 1 },
"epoch": func(s *PulsarSession) { s.Epoch ^= 1 },
"height": func(s *PulsarSession) { s.Height ^= 1 },
"round": func(s *PulsarSession) { s.Round ^= 1 },
"block_hash": func(s *PulsarSession) { s.BlockHash[0] ^= 1 },
"validator_set_root": func(s *PulsarSession) { s.ValidatorSetRoot[0] ^= 1 },
"joint_pk_id": func(s *PulsarSession) { s.JointPKID[0] ^= 1 },
"dkg_transcript_root": func(s *PulsarSession) { s.DKGTranscriptRoot[0] ^= 1 },
"committee_id": func(s *PulsarSession) { s.CommitteeID[0] ^= 1 },
"signer_set_root": func(s *PulsarSession) { s.SignerSetRoot[0] ^= 1 },
"nonce_pool_root": func(s *PulsarSession) { s.NoncePoolRoot[0] ^= 1 },
"protocol_version": func(s *PulsarSession) { s.ProtocolVersion ^= 1 },
}
for name, mut := range mutators {
s := base
mut(&s)
if s.SessionID() == baseID {
t.Fatalf("mutating %q did not change the session id", name)
}
}
}
// TestSessionValidateRejectsUnsetRoots: an all-zero security-relevant root is
// refused.
func TestSessionValidateRejectsUnsetRoots(t *testing.T) {
var root [32]byte
root[0] = 1
good := fullSession(root)
if err := good.Validate(); err != nil {
t.Fatalf("full session must validate, got %v", err)
}
bad := good
bad.DKGTranscriptRoot = [32]byte{} // unbind a field
if err := bad.Validate(); err != ErrSessionFieldUnset {
t.Fatalf("unset DKG transcript root err = %v, want ErrSessionFieldUnset", err)
}
}
+113
View File
@@ -0,0 +1,113 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// session.go -- the consensus-side session binding for a Pulsar round.
//
// PulsarSession captures every field that pins one threshold-sign ceremony to
// exactly one consensus round, so a signature produced for (epoch, height,
// round, block) can never be replayed into a different round, committee, or
// joint key. The 32-byte session_id is the domain-separated hash of all
// fields; every signer recomputes it and refuses to sign a mismatched session.
package pulsar
import (
"encoding/binary"
"golang.org/x/crypto/sha3"
)
// sessionDomain domain-separates the Pulsar session-id hash from every other
// hash in the system (FIPS 204 mu, the nonce-cert payload root, the round
// digest, etc.). cSHAKE customization plus an in-band protocol tag give two
// independent separation layers.
const (
sessionCustomization = "QUASAR-PULSAR-SESSION-v1"
sessionProtocolTag = "Quasar/Pulsar/Session/v1"
)
// PulsarSession is the consensus binding for one Pulsar threshold-sign round.
// Every field is consensus-owned; none is secret. The zero value is invalid
// (Validate rejects all-zero security-relevant roots) so a caller cannot
// silently sign an unbound session.
type PulsarSession struct {
ChainID [32]byte // chain this round finalizes on
Epoch uint64 // DKG / committee epoch
Height uint64 // block height
Round uint64 // intra-height round / view
BlockHash [32]byte // the block (item) being certified
ValidatorSetRoot [32]byte // commitment to the active validator set
JointPKID [32]byte // identifier of the committee joint ML-DSA key
DKGTranscriptRoot [32]byte // commitment to the DKG transcript that made the key
CommitteeID [32]byte // identifier of the sampled committee
SignerSetRoot [32]byte // commitment to the eligible signer set
NoncePoolRoot [32]byte // Merkle root of the background NonceCert pool
ProtocolVersion uint32 // wire/protocol version
}
// SessionID derives the 32-byte session identifier by hashing every binding
// field under a domain-separated cSHAKE256. Each variable-position field is
// fixed-width (arrays) or length-free fixed integers, so the concatenation is
// unambiguous (canonical encoding -- no field-boundary grinding).
//
// Field order is fixed and MUST match the task's session_id definition:
//
// H(chain_id ‖ epoch ‖ height ‖ round ‖ block_hash ‖ validator_set_root ‖
// joint_pk_id ‖ dkg_transcript_root ‖ committee_id ‖ signer_set_root ‖
// nonce_pool_root ‖ protocol_version)
func (s *PulsarSession) SessionID() [32]byte {
h := sha3.NewCShake256(nil, []byte(sessionCustomization))
_, _ = h.Write([]byte(sessionProtocolTag))
var u8 [8]byte
var u4 [4]byte
_, _ = h.Write(s.ChainID[:])
binary.BigEndian.PutUint64(u8[:], s.Epoch)
_, _ = h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], s.Height)
_, _ = h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], s.Round)
_, _ = h.Write(u8[:])
_, _ = h.Write(s.BlockHash[:])
_, _ = h.Write(s.ValidatorSetRoot[:])
_, _ = h.Write(s.JointPKID[:])
_, _ = h.Write(s.DKGTranscriptRoot[:])
_, _ = h.Write(s.CommitteeID[:])
_, _ = h.Write(s.SignerSetRoot[:])
_, _ = h.Write(s.NoncePoolRoot[:])
binary.BigEndian.PutUint32(u4[:], s.ProtocolVersion)
_, _ = h.Write(u4[:])
var out [32]byte
_, _ = h.Read(out[:])
return out
}
// Validate refuses a session whose security-relevant roots are all zero. A
// zero root means an unbound field (e.g. a missing DKG transcript or
// validator-set commitment); signing under it would let an adversary reuse the
// signature across rounds. ProtocolVersion and the numeric height/round/epoch
// are allowed to be zero (genesis, view 0).
func (s *PulsarSession) Validate() error {
zero := [32]byte{}
switch {
case s.ChainID == zero:
return ErrSessionFieldUnset
case s.BlockHash == zero:
return ErrSessionFieldUnset
case s.ValidatorSetRoot == zero:
return ErrSessionFieldUnset
case s.JointPKID == zero:
return ErrSessionFieldUnset
case s.DKGTranscriptRoot == zero:
return ErrSessionFieldUnset
case s.CommitteeID == zero:
return ErrSessionFieldUnset
case s.SignerSetRoot == zero:
return ErrSessionFieldUnset
case s.NoncePoolRoot == zero:
return ErrSessionFieldUnset
}
return nil
}