mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
)
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user