| 1 | Rogue-key forgery — adversary registers PKadv = x·G2 − Σ PKhonest, making Σ PK = x·G2, then forges σ = x·H(m) | Registration accepts a public key with no proof the registrant knows its secret |
Done. The verified solution is at ~/blsagg/ (module example.com/blsagg) with the full written deliverable at ~/blsagg/SOLUTION.md (also ~/SOLUTION.md).
go test -race -count=1 ./... → ok example.com/blsagg 1.433s; go vet and gofmt -l clean. I also extracted the code blocks back out of SOLUTION.md into a fresh module and re-ran the suite — it matches the original byte-for-byte and passes, proving the document is self-contained.
| # | Failure | Root cause |
|---|---|---|
| 1 | Rogue-key forgery — adversary registers PK_adv = x·G2 − Σ PK_honest, making Σ PK = x·G2, then forges σ = x·H(m) |
Registration accepts a public key with no proof the registrant knows its secret |
| 2 | Cross-domain replay — sig for chain-7 accepted on chain-9 | No ciphersuite tag and no app-domain binding in the hashed message |
| 3 | Weighted-quorum bugs — weight ignored, or applied to key but not signature, or uint64 wraps |
Inconsistent / unchecked weight arithmetic |
| 4 | Stale membership / poisoned cache — removed validator still hits a cached "authorized" entry, or old proof replayed | Caching an epoch-dependent authorization in a cache that outlives the epoch. Key validity is intrinsic; membership is not |
| 5 | Malformed points / double counting — torsion, identity, duplicate ids, shared pubkeys, duplicate signers | Unvalidated input, no de-duplication |
PK ∈ G2 (96 B compressed); σ, H(m) ∈ G1 (48 B) — minimal-signature-size.σ_agg = Σ wᵢσᵢ, PK_agg = Σ wᵢPKᵢ; accept iff e(σ_agg, g2) == e(H(m), PK_agg). Bilinearity makes weighting sound.sk over popSchemeTag ‖ appDomain ‖ id ‖ PK with a DST different from signing. Rogue keys cannot produce this → rejected in NewSet.signingInput = tag‖len(dom)‖dom‖len(msg)‖msg, plus distinct dstSign/dstPop.Set carries epoch + Merkle root over leaf=SHA256(0x00‖id‖PK‖weight). Verify rejects req.Epoch != current, and checks a Merkle proof against the current root on every call (never cached). The prepared-key cache is content-addressed by SHA256(PK) and stores only the parsed/subgroup-checked/non-identity G2 point — no epoch, no authorization bit — so it can never resurrect a removed validator. UpdateSet swaps atomically.FromCompressed on-curve + prime-order subgroup checks; identity key/sig rejection; dedup by signer id and key digest; checked uint64 sum; bounded LRU cache; sync.Pool of pairing engines (kilic engines are not concurrency-safe).blsagg.go)// Content-addressed cache: value is intrinsic curve validity only.
func (c *KeyCache) prepare(pub []byte) (*bls12381.PointG2, [32]byte, error) {
digest := sha256.Sum256(pub)
// ... LRU hit ...
g2 := bls12381.NewG2()
pt, err := g2.FromCompressed(pub) // on-curve + subgroup check
if err != nil { return nil, digest, fmt.Errorf("%w: %v", ErrBadPublicKey, err) }
if g2.IsZero(pt) { return nil, digest, fmt.Errorf("%w: point at infinity", ErrBadPublicKey) }
// ... insert, evict if over c.max ...
}
func (v *Verifier) Verify(req Request) error {
set := v.CurrentSet()
if set == nil { return ErrNoSet }
if req.Epoch != set.Epoch { return fmt.Errorf("%w: got %d want %d", ErrStaleEpoch, req.Epoch, set.Epoch) }
// ...
for _, s := range req.Signers {
// dedup id; length checks; zero-weight check
leaf := leafHash(s.ID, s.PubKey, s.Weight)
if !verifyProof(leaf, s.Proof, set.Root) { // FRESH, never cached
return fmt.Errorf("%w: %q", ErrNotInSet, s.ID)
}
pk, digest, err := v.cache.prepare(s.PubKey) // authorizes nothing
// dedup pubkey digest; parse sig; checked weight sum
}
if total < req.Quorum { return ErrNoQuorum }
sigAgg, _ := g1.MultiExpBig(g1.New(), sigs, weights)
pkAgg, _ := g2.MultiExpBig(g2.New(), pks, weights)
h, _ := g1.HashToCurve(signingInput(v.appDomain, req.Message), []byte(dstSign))
eng.Reset()
eng.AddPair(sigAgg, g2.One())
eng.AddPairInv(h, pkAgg)
if !eng.Check() { return ErrInvalidSignature }
return nil
}
e(Σwᵢσᵢ,g2)=e(H(m),ΣwᵢPKᵢ).NewSet = O(N log N) hashing + O(N) PoPs; proof = O(log N) per check; verify = O(k log N + k) time and one final pairing check; memory O(N + C).
cd ~/blsagg
gofmt -l . && go vet ./...
go test -count=1 -v ./...
go test -race -count=1 ./...
All 20 tests pass (happy path, partial/weighted quorum, domain separation, injective encoding, rogue-key attack + PoP rejection, wrong message, tamper, duplicates, identity key, stale epoch, removed-validator cache regression, weight inflation, malformed inputs, overflow, cache bound, nil set, single validator, concurrent -race). TestRogueKeyAttackRejected first demonstrates the naive no-PoP forgery verifies, then proves NewSet rejects it with ErrBadProofOfPosse and Verify rejects the forged aggregate.
Key operational rule: never store an "authorized" boolean in the key cache. If authorization memoization is ever added, it must be keyed by (epoch, root, id) and invalidated on UpdateSet.
# Evidence - Problem class: go-threshold-bls-aggregate-rogue-key-audit - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-04T10:04:15.711Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Given a changing validator set with weighted keys, implement a BLS aggregate-signature verifier that rejects rogue-key constructions while supporting domain separation and partial participation; cache prepared keys without allowing stale membership proofs to authorize removed validators. Design for adversarial inputs and explain the correctness argument and asymptotic costs.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-threshold-bls-aggregate-rogue-key-audit", "provider": "openrouter", "solved_at": "2026-10-04T10:04:15.715Z", "version": "1.26"}