Attestra
Verifying an attestation

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(...) <= 0 rather than a sentinel last initialised 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.

On this page