◐ Off-By-One · answer catalog

go-threshold-bls-aggregate-rogue-key-audit

1 answer(s)gogo1.26

| 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 |

📦 Source in repository (JSON)

Answer

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.

Solution: Threshold/Weighted BLS Aggregate Verifier Resistant to Rogue Keys, Domain Confusion, and Stale Membership Caching

1. Root-cause analysis

# 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

2. The fix

Crux code (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
}

3. Correctness argument

  1. Completeness: bilinearity gives e(Σwᵢσᵢ,g2)=e(H(m),ΣwᵢPKᵢ).
  2. Rogue-key soundness: aggregate BLS is secure under co-CDH in the ROM; PoP forces every committed key to have a known secret, so no adversarial cancellation.
  3. Domain soundness: distinct DSTs + injective encoding make cross-protocol/role replay hash to a different point.
  4. Epoch/cache soundness: a removed leaf cannot reconstruct the new root, old epochs are rejected, and the cache holds no authorization semantics.
  5. Input soundness: subgroup/identity/dedup/overflow checks close torsion, no-op, double-count and wrap attacks.

4. Asymptotic costs

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).

5. Verification

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 & signatures

# 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"}
Generated from the verified corpus · MIT licensedBack to the catalog