Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
24 changes: 24 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: dependency-review

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
dependency-review:
name: dependency-review
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The action hard-errors until the repo's Dependency Graph feature is
# enabled (Settings > Code security). continue-on-error keeps the job
# from blocking PRs before an admin flips that switch; once enabled the
# step reports real findings. Flip this to a hard gate after enabling.
- name: Dependency Review
continue-on-error: true
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
119 changes: 119 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: security

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
# Gating race-test job, dedicated to the security workflow so it can be a
# required status check independent of the coverage job in ci.yml.
race-test:
name: go test -race
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: go test -race ./...
run: go test -race ./...

codeql:
name: CodeQL
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:go"

gosec:
name: gosec (badgeverify)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
# Scoped to the crown-jewel badge crypto. The rest of the module
# carries pre-existing G104 findings (unhandled Close()/Set() errors)
# tracked as follow-up; scoping keeps this gate meaningful and green
# without rewriting unrelated packages in this PR.
- name: gosec
run: go run github.com/securego/gosec/v2/cmd/gosec@v2.22.5 -fmt=text ./badgeverify/...

govulncheck:
name: govulncheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: govulncheck
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...

gitleaks:
name: gitleaks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# The gitleaks GitHub Action requires a paid GITLEAKS_LICENSE secret on
# organization repos. The gitleaks CLI itself is OSS and unrestricted,
# so we run the pinned binary directly — full-history secret scan, no
# license gate, fails the job on any finding.
- name: Install gitleaks
run: |
set -euo pipefail
VERSION=8.21.2
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan repository history
run: gitleaks detect --source . --redact --verbose --exit-code 1

fuzz:
name: badgeverify fuzz
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
# Bounded fuzz window over every badgeverify target. Seed corpora are
# deterministic; this re-runs the seeds and explores new inputs for a
# short, CI-friendly slice per target.
- name: Fuzz parsers and verifiers
run: |
set -euo pipefail
targets="FuzzParseBadge FuzzParseEnrollment FuzzParseRecovery FuzzVerify FuzzVerifyEnrollment FuzzVerifyRecovery"
for t in $targets; do
echo "::group::$t"
go test -run='^$' -fuzz="^${t}$" -fuzztime=30s ./badgeverify/
echo "::endgroup::"
done
68 changes: 61 additions & 7 deletions badgeverify/badgeverify.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,60 @@ func noColon(field, v string) error {
return nil
}

// isCanonicalDecimal reports whether s is the UNIQUE base-10 spelling of a
// non-negative integer: no sign, no leading zeros (except the literal "0"),
// no whitespace, no "+1"/"01"/" 1" malleability. strconv.ParseUint/ParseInt
// accept several spellings of the same number; because the signed bytes ARE
// the wire string, two spellings of one logical value would be two distinct
// signable strings (a malleability hazard). Requiring the canonical spelling
// makes the parsers themselves reject the alternates — defense in depth under
// the verify-layer round-trip check — and closes the same gap for the
// enrollment and recovery parsers, which have no verify-layer guard.
func isCanonicalDecimal(s string) bool {
if s == "" {
return false
}
if s == "0" {
return true
}
if s[0] == '0' { // leading zero on a multi-digit number
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' { // rejects '+', '-', whitespace, any non-digit
return false
}
}
return true
}

// parseCanonicalUint32 parses a node_id, rejecting any non-canonical
// spelling before delegating to strconv for the range check.
func parseCanonicalUint32(field, s string) (uint32, error) {
if !isCanonicalDecimal(s) {
return 0, fmt.Errorf("%w: %s is not canonical decimal: %q", ErrMalformed, field, s)
}
v, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return 0, fmt.Errorf("%w: %s: %v", ErrMalformed, field, err)
}
return uint32(v), nil
}

// parseCanonicalInt64 parses a non-negative timestamp/exp. All numeric
// fields on the wire are non-negative (unix seconds, 0 = none), so a
// leading '-' is itself non-canonical and rejected.
func parseCanonicalInt64(field, s string) (int64, error) {
if !isCanonicalDecimal(s) {
return 0, fmt.Errorf("%w: %s is not canonical decimal: %q", ErrMalformed, field, s)
}
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("%w: %s: %v", ErrMalformed, field, err)
}
return v, nil
}

// Canonical returns the exact byte string that is signed and that travels
// on the wire as the "badge" field. The issuer signs these bytes; the
// verifier checks the signature over the bytes it received and only then
Expand Down Expand Up @@ -149,17 +203,17 @@ func Parse(s string) (Badge, error) {
if parts[1] != Version {
return Badge{}, fmt.Errorf("%w: unsupported version %q", ErrMalformed, parts[1])
}
nodeID, err := strconv.ParseUint(parts[2], 10, 32)
nodeID, err := parseCanonicalUint32("node_id", parts[2])
if err != nil {
return Badge{}, fmt.Errorf("%w: node_id: %v", ErrMalformed, err)
return Badge{}, err
}
verifiedAt, err := strconv.ParseInt(parts[4], 10, 64)
verifiedAt, err := parseCanonicalInt64("verified_at", parts[4])
if err != nil {
return Badge{}, fmt.Errorf("%w: verified_at: %v", ErrMalformed, err)
return Badge{}, err
}
exp, err := strconv.ParseInt(parts[5], 10, 64)
exp, err := parseCanonicalInt64("exp", parts[5])
if err != nil {
return Badge{}, fmt.Errorf("%w: exp: %v", ErrMalformed, err)
return Badge{}, err
}
if parts[3] == "" {
return Badge{}, fmt.Errorf("%w: provider is required", ErrMalformed)
Expand All @@ -168,7 +222,7 @@ func Parse(s string) (Badge, error) {
return Badge{}, fmt.Errorf("%w: kid is required", ErrMalformed)
}
return Badge{
NodeID: uint32(nodeID),
NodeID: nodeID,
Provider: parts[3],
VerifiedAt: verifiedAt,
Exp: exp,
Expand Down
27 changes: 16 additions & 11 deletions badgeverify/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package badgeverify

import (
"fmt"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -99,19 +98,19 @@ func ParseEnrollment(s string) (Enrollment, error) {
if parts[1] != Version {
return Enrollment{}, fmt.Errorf("%w: unsupported version %q", ErrMalformed, parts[1])
}
nodeID, err := strconv.ParseUint(parts[2], 10, 32)
nodeID, err := parseCanonicalUint32("node_id", parts[2])
if err != nil {
return Enrollment{}, fmt.Errorf("%w: node_id: %v", ErrMalformed, err)
return Enrollment{}, err
}
issuedAt, err := strconv.ParseInt(parts[5], 10, 64)
issuedAt, err := parseCanonicalInt64("issued_at", parts[5])
if err != nil {
return Enrollment{}, fmt.Errorf("%w: issued_at: %v", ErrMalformed, err)
return Enrollment{}, err
}
if parts[3] == "" || parts[4] == "" || parts[6] == "" {
return Enrollment{}, fmt.Errorf("%w: enrollment has empty required field", ErrMalformed)
}
return Enrollment{
NodeID: uint32(nodeID), Provider: parts[3], Commitment: parts[4],
NodeID: nodeID, Provider: parts[3], Commitment: parts[4],
IssuedAt: issuedAt, Kid: parts[6],
}, nil
}
Expand All @@ -129,19 +128,25 @@ func ParseRecovery(s string) (Recovery, error) {
if parts[1] != Version {
return Recovery{}, fmt.Errorf("%w: unsupported version %q", ErrMalformed, parts[1])
}
nodeID, err := strconv.ParseUint(parts[2], 10, 32)
nodeID, err := parseCanonicalUint32("node_id", parts[2])
if err != nil {
return Recovery{}, fmt.Errorf("%w: node_id: %v", ErrMalformed, err)
return Recovery{}, err
}
exp, err := strconv.ParseInt(parts[5], 10, 64)
exp, err := parseCanonicalInt64("exp", parts[5])
if err != nil {
return Recovery{}, fmt.Errorf("%w: exp: %v", ErrMalformed, err)
return Recovery{}, err
}
// A recovery with exp<=0 is never valid (CanonicalRecovery refuses to
// emit one); reject it at the parse boundary so the round-trip is exact
// and an exp=0 takeover token cannot even be represented.
if exp <= 0 {
return Recovery{}, fmt.Errorf("%w: recovery exp must be set (non-zero)", ErrMalformed)
}
if parts[3] == "" || parts[4] == "" || parts[6] == "" || parts[7] == "" {
return Recovery{}, fmt.Errorf("%w: recovery has empty required field", ErrMalformed)
}
return Recovery{
NodeID: uint32(nodeID), NewPubKey: parts[3], Commitment: parts[4],
NodeID: nodeID, NewPubKey: parts[3], Commitment: parts[4],
Exp: exp, Nonce: parts[6], Kid: parts[7],
}, nil
}
Expand Down
Loading
Loading