Skip to content

test(cms): SKI-form harness, matchesSID canonicality fix, mutation-test target#14

Merged
jamestexas merged 4 commits into
mainfrom
feat/audit-hardening-r2
Jun 17, 2026
Merged

test(cms): SKI-form harness, matchesSID canonicality fix, mutation-test target#14
jamestexas merged 4 commits into
mainfrom
feat/audit-hardening-r2

Conversation

@jamestexas

Copy link
Copy Markdown
Collaborator

Summary

Round 2 of the audit-equivalent hardening on pkg/cms. Builds on PR #13:

  • Adds the CMS construction harness that lets future tests probe verifier paths the production signer can't reach.
  • Uses it to land the highest-leverage gap the mutation testing surfaced after PR feat(cms): Tier 1 behavioral fuzzers + fix two RFC 5652 bypasses #13 — the entire SKI (SubjectKeyIdentifier) form of SignerIdentifier, which was untested and contained a real interop bug.
  • Wires up reproducible audit targets: make long-fuzz, make overnight-fuzz, make mutation-test, make govulncheck.
  • Tightens one mutation-testing false positive by replacing a local compareBytes with stdlib bytes.Compare.

Findings & fixes

matchesSID rejected canonical RFC 5652 SKI form (real interop bug)

Before this PR, matchesSID only accepted EXPLICIT `[0]` wrapping of the SubjectKeyIdentifier (`A0 04 `). It tried `asn1.Unmarshal(sidRaw.Bytes, &keyID)`, which only succeeds when the SKI value is itself an OCTET STRING-tagged TLV.

RFC 5652's ASN.1 module declares `IMPLICIT TAGS`, so the canonical encoding is `80 ` with no nested OCTET STRING. That's what OpenSSL and github.com/github/ietf-cms both emit. The consequence: go-cms could not verify SKI-form CMS produced by any standards-compliant CMS producer.

Fix: in `matchesSID`, treat `sidRaw.Bytes` as the raw SKI value when the `[0]` tag is primitive (canonical IMPLICIT), and reject the constructed/EXPLICIT form outright. Rejecting EXPLICIT also closes a small malleability surface — the same logical SID had two valid DER encodings under the old code.

What's added

CMS construction harness (`pkg/cms/cms_builder_test.go`)

Internal test-only `buildTestCMS` + `cmsBuildConfig` lets any future test hand-assemble a CMS SignedData blob with explicit control over fields the production signer doesn't expose:

Knob Purpose
`SIDForm` IAS vs SKI SignerIdentifier
`SIVersion`/`SDVersion` Probe version/SID cross-checks
`EContentOID` Test non-id-data eContentType handling
`OmitAttrs` Case 2 (no signedAttributes) variant
`CorruptSKI` Force key-id mismatch
`SKIUseExplicit` Non-canonical `[0]` wrapping for malleability probes

Signatures produced are always real Ed25519 over canonical-DER `SignedAttributes` (Case 1) or content (Case 2) — verifier behavior we observe is authentic, not asn1-decode-and-stop.

SKI test suite (`pkg/cms/ski_test.go`, 8 tests)

  • `TestBuilderHappyPath_{IAS,SKI}` — harness sanity
  • `TestSKI_VersionMustBe3` / `TestIAS_VersionMustBe1` — RFC 5652 §5.3 version/SID cross-check
  • `TestSKI_KeyIdMismatchRejects` — `matchesSID` rejects bad key id
  • `TestSKI_TamperResistance` — SKI path doesn't skip signature check
  • `TestSKI_RejectsExplicitWrapping` — canonical-form enforcement
  • `TestSKI_Case2` — SKI × Case 2 intersection

Audit Makefile targets (`Makefile`)

  • `make long-fuzz` — runs every fuzz target for `FUZZTIME` each (default 10m)
  • `make overnight-fuzz` — `long-fuzz` with `FUZZTIME=1h` (~16h total burn-in)
  • `make mutation-test` — installs and runs `gremlins`
  • `make govulncheck` — installs and runs `govulncheck`

Refactor (`pkg/cms/verifier.go`)

  • Replace local `compareBytes` (22 LOC) with stdlib `bytes.Compare`. Behavior identical (lexicographic compare); eliminates the helper from the package surface and a small cluster of false-positive lived mutants.

Validation

Before this branch After
Statement coverage 77.9% 78.9%
Mutation testing efficacy 76.36% 79.76%
Mutator coverage 83.77% 85.76%
`matchesSID` v=216 mutants LIVED KILLED
Canonical SKI from OpenSSL/ietf-cms Rejected Accepted

Full suite passes under `-race` across 5 `-count` repetitions. No regressions in existing tests.

Test plan

  • CI green across all matrix entries
  • `go test ./pkg/cms -run 'TestSKI|TestBuilder' -race` passes
  • Existing CMS tests (Case 1, Case 2, OpenSSL interop) unchanged
  • `make mutation-test` reports >=79% efficacy (regression sentinel)

🤖 Generated with Claude Code

jamestexas and others added 3 commits May 19, 2026 14:43
…argets

Audit-equivalent test workflow as Makefile targets so they're scripted
and reproducible:

- `make long-fuzz`   — run every fuzz target for FUZZTIME each (default
                       10m; override e.g. FUZZTIME=30m or FUZZTIME=1h).
                       Anchored regexes (^Foo$) prevent multi-match.
- `make overnight-fuzz` — long-fuzz with FUZZTIME=1h (≈16h total at
                       current target count). For dedicated burn-in runs.
- `make mutation-test` — run gremlins mutation testing on pkg/cms;
                       installs the tool if missing. Surviving mutants
                       indicate test gaps.
- `make govulncheck` — surface stdlib/dependency CVEs reachable from
                       our call graph; installs the tool if missing.

The default target stays `help`, listing the new audit targets alongside
the existing build/test/lint/docker ones.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
compareBytes was a reimplementation of bytes.Compare with the same
contract (lexicographic compare, -1/0/1). Switching to the stdlib
function removes 22 lines of code, eliminates a small cluster of
(false-positive) surviving mutants in mutation testing, and trims one
ad-hoc helper from the package surface.

Behavior is unchanged: bytes.Compare implements exactly the
lexicographic byte ordering DER mandates for SET OF.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the largest mutation-testing gap on this branch (the
SubjectKeyIdentifier path of matchesSID and the SignerInfo
version-vs-SID cross-check) by adding a from-scratch CMS construction
harness that the production signer cannot reach.

Finding & fix:

  matchesSID previously expected EXPLICIT [0] wrapping of the SKI
  (A0 <len> 04 <ski-len> <ski>): the code unconditionally tried
  asn1.Unmarshal(sidRaw.Bytes, &keyID) which only succeeds when there's
  a nested OCTET STRING TLV inside. RFC 5652's ASN.1 module declares
  IMPLICIT TAGS, so the canonical encoding is 80 <len> <ski> with no
  nested OCTET STRING — exactly what OpenSSL and
  github.com/github/ietf-cms emit. As a result, go-cms could not verify
  SKI-form SignerInfo produced by any standards-compliant CMS producer.

  Fix: treat sidRaw.Bytes as the raw SKI value when the [0] tag is
  primitive (canonical IMPLICIT form), and reject the constructed form
  outright. Rejecting the EXPLICIT variant also closes a malleability
  surface: under the old code, the same logical SID could be encoded
  two different ways, breaking content-addressing built on CMS blob
  hashes.

New test harness (pkg/cms/cms_builder_test.go):

  - cmsBuildConfig + buildTestCMS: hand-assemble a CMS SignedData blob
    with explicit control over SID form (IAS vs SKI), SignerInfo and
    SignedData versions, EncapContentInfo OID, SKI corruption, IMPLICIT
    vs EXPLICIT SKI wrapping, and Case-1 vs Case-2 SignedAttributes.
  - The signature itself is always real Ed25519 over canonical-DER
    SignedAttributes (or content for Case 2) — verifier behaviour we
    observe is cryptographically authentic, not asn1-decode-passes-and-
    stops.

SKI test suite (pkg/cms/ski_test.go, 8 tests):

  - TestBuilderHappyPath_{IAS,SKI}: harness sanity checks.
  - TestSKI_VersionMustBe3 / TestIAS_VersionMustBe1: prove the
    SignerInfo version ↔ SID-form cross-check (verifier.go:216) rejects
    SKI+v1 and IAS+v3. Previously LIVED in mutation testing because no
    SKI test exercised the branch.
  - TestSKI_KeyIdMismatchRejects: corrupt SKI bytes must fail
    matchesSID equality.
  - TestSKI_TamperResistance: SKI-form signature must still reject
    detached-data tampering.
  - TestSKI_RejectsExplicitWrapping: documents and locks in the
    canonicality rejection.
  - TestSKI_Case2: SKI × Case 2 intersection.

Validation:

  - Full test suite passes under -race at 78.9% statement coverage
    (+1.0 from main).
  - Mutation testing: efficacy 76.36% → 79.76%, mutator coverage
    83.77% → 85.76%. The version-vs-SID cross-check mutants in
    matchesSID flip from LIVED to KILLED.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 17, 2026 17:25

Copilot AI 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.

Pull request overview

This PR hardens pkg/cms CMS verification by fixing an interoperability bug in the SKI (SubjectKeyIdentifier) SignerIdentifier path (RFC 5652 canonical IMPLICIT encoding), and adds a test-only CMS construction harness plus targeted SKI tests to cover verifier branches the production signer cannot emit. It also adds Makefile targets intended to support reproducible audit-style fuzzing/mutation/vuln scanning workflows.

Changes:

  • Fix matchesSID to accept canonical RFC 5652 SKI form ([0] IMPLICIT, primitive) and reject non-canonical EXPLICIT wrapping (malleability reduction).
  • Add cms_builder_test.go harness and ski_test.go suite to exercise SKI + version cross-checks and Case 2 intersections.
  • Add audit-focused Makefile targets (long-fuzz, overnight-fuzz, mutation-test, govulncheck) and refactor ordering compare to bytes.Compare.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
pkg/cms/verifier.go Accept canonical SKI SID encoding, reject EXPLICIT wrapping; replace custom byte-compare helper with bytes.Compare.
pkg/cms/ski_test.go Add SKI-focused tests (happy path, version cross-checks, mismatch/tamper/case2, explicit-wrapping rejection).
pkg/cms/cms_builder_test.go Add a test-only CMS builder to construct SignedData/SI variants not reachable via production signing APIs.
Makefile Add long-running fuzz/mutation/vulncheck targets and update help text.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Makefile Outdated
Comment thread Makefile Outdated
Comment thread Makefile Outdated
Comment thread pkg/cms/cms_builder_test.go Outdated
Four fixes for issues Copilot flagged on PR #14:

1. Makefile help text: overnight-fuzz claimed "~12h total" but the
   target list has 16 entries × 1h = ~16h. Update help line to match
   the code comment.

2. mutation-test target: previously `which gremlins` then bare
   `gremlins`, which fails on fresh environments where GOBIN isn't on
   PATH even after `go install` succeeds. Resolve the install path via
   `go env GOBIN`/`GOPATH/bin` and invoke the binary by absolute path.

3. govulncheck target: same fix as (2).

4. cms_builder_test.go: clarify the SubjectKeyId derivation comment.
   RFC 5280 §4.2.1.2 method 1 hashes the *contents* of the
   subjectPublicKey BIT STRING (raw key bytes), not the DER encoding of
   the BIT STRING itself. The code was already correct; the comment is
   now accurate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jamestexas jamestexas merged commit 6e19e6e into main Jun 17, 2026
7 checks passed
@jamestexas jamestexas deleted the feat/audit-hardening-r2 branch June 17, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants