On Hyperledger Fabric
The Go verifier in the registry chaincode, its own signer set, and why an unseeded chaincode accepts nothing.
On Hyperledger Fabric the rule lives in chaincode/verify/verify.go, a
package the registry chaincode imports. It reuses go-ethereum's crypto, so the
same secp256k1 signature that verifies on Ethereum verifies here
against the same recovered address.
The quorum
// VerifyQuorum reports whether sigs contain at least threshold signatures from
// distinct authorized signers over a's digest.
func VerifyQuorum(authorized []string, threshold int, a Attestation, sigs [][]byte) (bool, error) {
if threshold <= 0 {
return false, errors.New("threshold must be positive")
}
auth := make(map[string]struct{}, len(authorized))
for _, s := range authorized {
auth[strings.ToLower(s)] = struct{}{}
}
digest := a.Digest()
var last common.Address
count := 0
for i, sig := range sigs {
if len(sig) != 65 {
return false, fmt.Errorf("signature %d: bad length %d", i, len(sig))
}
pub, err := crypto.SigToPub(digest[:], sig)
if err != nil {
return false, fmt.Errorf("signature %d: %w", i, err)
}
rec := crypto.PubkeyToAddress(*pub)
if i > 0 && bytes.Compare(rec.Bytes(), last.Bytes()) <= 0 {
return false, nil // unsorted or duplicate signer
}
last = rec
if _, ok := auth[strings.ToLower(rec.Hex())]; !ok {
return false, nil
}
count++
}
return count >= threshold, nil
}The source doc comment states the same intent as the other two verifiers:
sigs must be sorted by recovered signer address, strictly ascending; that
ordering is what proves distinctness in a single pass; it is the same rule the
Solidity and Substrate verifiers enforce, so a bundle accepted here is accepted
on every chain; and it neutralises signature malleability, because a malleated
signature recovers to the same address and is rejected as a repeat.
Three differences of expression, none of them differences of rule:
- The ordering guard is
i > 0 && bytes.Compare(...) <= 0rather than a sentinellastinitialised to zero. The effect is identical: every signature after the first must recover to a strictly greater address. - Comparison is over the raw address bytes, which is the same lexicographic ordering the Solidity and Rust verifiers use.
- The authorized set is a lowercased set lookup rather than a linear scan or a
contract call, and
rec.Hex()is lowercased before lookup, so checksummed and non-checksummed hex both match.
The two-value return separates malformed input from a failed check. A bad
signature length or a recovery failure is an error; a bundle that is
well-formed but unsorted, repeated, unauthorized or short of the threshold is
(false, nil). Callers get a reason for the first and a plain verdict for the
second.
The signer set
The chaincode keeps its own roster in its own state, in
chaincode/registry/registry.go:
type SignerSet struct {
Signers []string `json:"signers"` // 0x-hex addresses
Threshold int `json:"threshold"`
}It is administered with PutSigners(signersJSON, threshold) and read with
GetSigners. Both are contract methods on the chaincode; underneath them sit
two helpers that hold the actual bound and the actual failure:
func putSigners(l stateRW, signers []string, threshold int) error {
if threshold < 1 || threshold > len(signers) {
return fmt.Errorf("threshold %d is outside 1..%d", threshold, len(signers))
}
b, err := json.Marshal(SignerSet{Signers: signers, Threshold: threshold})
if err != nil {
return err
}
return l.PutState(signersKey(), b)
}
func getSigners(l stateRW) (*SignerSet, error) {
b, err := l.GetState(signersKey())
if err != nil {
return nil, fmt.Errorf("read signer set: %w", err)
}
if b == nil {
return nil, fmt.Errorf("signer set not seeded; call PutSigners first")
}
var s SignerSet
if err := json.Unmarshal(b, &s); err != nil {
return nil, err
}
return &s, nil
}putSigners applies the same bound as every other chain: threshold within
1..len(signers). A threshold of zero would accept an empty bundle, and a
threshold above the roster size would accept nothing.
PutSigners replaces the set wholesale rather than adding and removing
individual entries, which is the operational difference from
Ethereum, where addSigner and
removeSigner mutate the list one address at a time.
Fabric fails closed
getSigners returns an error when the key is absent. It does not return an
empty set, and it does not fall back to a default. The consequence is stated in
the source comment on recordAttestation:
recordAttestation verifies a quorum of authorized oracle signatures over the canonical digest before writing anything. It fails closed: an unseeded chaincode has no signer set and therefore accepts nothing. Fabric previously trusted the caller's MSP identity and stored whatever it was handed.
The second sentence of that comment is the history, and it is worth stating plainly because it explains the design. An earlier version of this chaincode took the caller's MSP identity as sufficient authority and persisted whatever JSON it received. Under that model the record on the ledger asserted only that some member of the network had submitted it. The signatures were not the basis of trust; channel membership was. Recording a quorum-verified attestation and recording a made-up one were the same transaction.
The verification path now runs before any write:
set, err := getSigners(l)
if err != nil {
return err
}and then, after decoding the signatures:
ok, err := verify.VerifyQuorum(set.Signers, set.Threshold, va, sigs)
if err != nil {
return fmt.Errorf("verify quorum: %w", err)
}
if !ok {
return fmt.Errorf("quorum not met: need %d distinct authorized signers", set.Threshold)
}The operational consequence: a freshly deployed chaincode accepts no
attestations until PutSigners has been called. Every RecordAttestation
fails first at getSigners, with signer set not seeded; call PutSigners first. This is the intended behaviour and not a deployment fault. Seeding the
signer set is a required step of bringing the chaincode up, and until it is
done the chaincode is closed rather than open.