Skip to content

fix(multisig): reject zero-member identity/verifier to close anyone-can-spend bypass#1859

Open
adecaro wants to merge 1 commit into
mainfrom
1858-zero-length-multiidentity-yields-vacuously-true-verify-anyone-can-spend-output
Open

fix(multisig): reject zero-member identity/verifier to close anyone-can-spend bypass#1859
adecaro wants to merge 1 commit into
mainfrom
1858-zero-length-multiidentity-yields-vacuously-true-verify-anyone-can-spend-output

Conversation

@adecaro

@adecaro adecaro commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Closes the "anyone-can-spend" bypass described in the linked security issue: a
MultiIdentity with zero constituent identities could be serialised directly (bypassing the
honest-client WrapIdentities guard), committed on-chain as a token output owner, and later
spent by anyone supplying an empty MultiSignature{} — no real signature required.

Two minimal, targeted guards close the two independent paths that allowed this:

token/services/identity/multisig/sig.go — fail closed on degenerate verifier state

func (v *Verifier) Verify(msg, raw []byte) error {
+   if len(v.Verifiers) == 0 {
+       return errors.New("multisig verifier has no members")
+   }
    sig := &MultiSignature{}
    ...

The previous len(v.Verifiers) != len(sig.Signatures) guard passed vacuously when both
lengths were zero (0 != 0false). The loop then iterated zero times and returned nil.
The new check fires before any ASN.1 parsing, making the function fail closed regardless of
how a zero-verifier Verifier was constructed.

token/services/identity/multisig/deserializer.go — reject at the trust boundary

 func (d *TypedIdentityDeserializer) DeserializeVerifier(...) (driver.Verifier, error) {
     multisigIdentity := &MultiIdentity{}
     err := multisigIdentity.Deserialize(raw)
     if err != nil { ... }
+    if len(multisigIdentity.Identities) == 0 {
+        return nil, errors.New("multisig identity has no members")
+    }
     verifier := &Verifier{}
     ...

This is the primary enforcement point: attacker-controlled bytes enter here. The guard ensures
a zero-identity MultiIdentity is rejected before a Verifier object is ever allocated.


Why two guards

  • Deserializer guard — primary: blocks the attacker path at the point where raw bytes
    become a typed Go value.
  • Verify guard — defence-in-depth: ensures the function fails closed even if a
    zero-verifier Verifier is constructed through any future programmatic path (e.g. a test
    helper, a different deserializer, or a future refactor) that does not go through
    DeserializeVerifier.

The two guards are independent and complement each other; neither alone is sufficient for
robust defence-in-depth.


Test coverage

Five new tests across the three test files pin the expected behaviour and would catch any
regression:

File Test Covers
sig_test.go TestVerifier_Verify_ZeroVerifiers Zero-verifier Verifier + empty MultiSignature{} bytes → error
deserializer_test.go TestDeserializeVerifier_EmptyIdentities Direct asn1.Marshal(MultiIdentity{}) attacker path → rejected at deserialization
identity_test.go TestWrapIdentities_SingleIdentity Boundary: one identity is the minimum valid input — happy path at the lower bound
identity_test.go TestUnwrap_EmptyMultiIdentity Documents that Unwrap itself does not reject empty identity (not the enforcement point); empty slice is returned, no error
identity_test.go TestInfoMatcher_Match_ZeroMatchers Documents the analogous vacuous-true behaviour in InfoMatcher.Match; pins expectation that the mitigation lives at the deserializer, not inside Match

Checklist

  • Existing unit tests pass (go test ./token/services/identity/multisig/...)
  • New tests added for both fixed paths
  • No behaviour change for valid (non-empty) multisig identities
  • No new dependencies introduced
  • Both changed files have DCO sign-off commit

Signed-off-by: Angelo De Caro <adc@zurich.ibm.com>
@adecaro adecaro added this to the Q3/26 milestone Jul 6, 2026
@adecaro adecaro requested a review from AkramBitar July 6, 2026 17:03
@adecaro adecaro self-assigned this Jul 6, 2026
@adecaro adecaro added bug Something isn't working identity labels Jul 6, 2026

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working identity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zero-length MultiIdentity yields vacuously-true Verify() — anyone-can-spend output

2 participants