From bd1bdf705c40eab34bfb65927997cb44354d65d8 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 21 Jun 2026 20:27:58 +0300 Subject: [PATCH] Reject non-canonical badge encodings in Verify --- badgeverify/badgeverify.go | 7 +++++++ badgeverify/badgeverify_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/badgeverify/badgeverify.go b/badgeverify/badgeverify.go index de5a10b..5bdd2bd 100644 --- a/badgeverify/badgeverify.go +++ b/badgeverify/badgeverify.go @@ -246,6 +246,13 @@ func verifyAt(badgeStr, sigB64 string, now time.Time) (Badge, error) { if err != nil { return Badge{}, err } + // Reject non-canonical encodings (leading zeros, '+' signs, etc.). The + // issuer always signs the canonical form, so a string that does not + // round-trip is malformed — this forecloses any byte-string-vs-parsed- + // fields malleability for downstream consumers. + if canon, cerr := Canonical(b); cerr != nil || canon != badgeStr { + return b, fmt.Errorf("%w: non-canonical encoding", ErrMalformed) + } if err := verifyDetached(badgeStr, sigB64, b.Kid); err != nil { return b, err } diff --git a/badgeverify/badgeverify_test.go b/badgeverify/badgeverify_test.go index f3929a5..8f57502 100644 --- a/badgeverify/badgeverify_test.go +++ b/badgeverify/badgeverify_test.go @@ -229,3 +229,22 @@ func TestColonRejectedInFields(t *testing.T) { t.Fatalf("colon in subject must be rejected, got %v", err) } } + +// TestRejectsNonCanonicalBadge pins the malleability guard: a badge whose +// encoding does not round-trip through Canonical (e.g. a leading-zero node_id) +// is rejected as malformed BEFORE the signature is even checked. +func TestRejectsNonCanonicalBadge(t *testing.T) { + priv := newIssuer(t, "v1") + // validBadge() fields, but node_id 109517 written as "0109517". + nonCanon := "pilotbadge:v1:0109517:github:1700000000:0:v1:" + sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(nonCanon))) + if _, err := Verify(nonCanon, sig); !errors.Is(err, ErrMalformed) { + t.Fatalf("non-canonical badge must reject with ErrMalformed, got %v", err) + } + // The canonical form of the same badge still verifies. + canon, _ := Canonical(validBadge()) + csig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(canon))) + if _, err := Verify(canon, csig); err != nil { + t.Fatalf("canonical badge must still verify: %v", err) + } +}