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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,37 @@ jobs:
# Surface stdlib and dependency CVEs reachable from our call graph.
govulncheck ./...

mutation:
name: Mutation Testing
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25'

- name: Install gremlins
run: go install github.com/go-gremlins/gremlins/cmd/gremlins@latest

- name: Run mutation tests with efficacy gate
run: |
# Threshold of 80 reflects the post-round-4 baseline. Surviving
# mutants past this floor indicate test gaps that regress this
# branch's hardening work — a new PR that drops efficacy below
# 80% will fail this gate and require either killing the new
# mutants with tests or documenting why they're equivalent.
#
# --timeout-coefficient 30 because the package's test binary
# links cgo (race detector) and gremlins' default 2x baseline
# is too tight for clean runs on CI hardware.
cd pkg/cms && GOWORK=off gremlins unleash \
--timeout-coefficient 30 \
--threshold-efficacy 80

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
235 changes: 235 additions & 0 deletions pkg/cms/attack_scenarios_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package cms

// attack_scenarios_test.go — named threat-class tests.
//
// One test per documented attack class against CMS/PKCS#7 verifiers. The
// goal is *defensive completeness*: every test names the attack it
// prevents, so the test suite itself documents the library's threat
// posture. Most of these have analogues elsewhere in the package (the
// fuzzers cover many of them as random outcomes); the value here is the
// explicit named coverage an auditor can map to a threat model.

import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"math/big"
"testing"
"time"
)

// TestAttack_SignerInfoCrossMessage_Replay confirms that a SignerInfo
// produced over data A cannot be transplanted onto data B. The Case 1
// signature commits to a SHA-512 of the original content via the
// messageDigest attribute; verifying the same blob against a different
// detached payload MUST fail because the verifier recomputes the digest
// and compares.
//
// Attack class: cross-message signature replay.
func TestAttack_SignerInfoCrossMessage_Replay(t *testing.T) {
cert, priv, pool := newBuilderSigner(t)
opts := VerifyOptions{Roots: pool}

dataA := []byte("legitimate message A")
dataB := []byte("attacker-supplied message B")

sigA, err := SignData(dataA, cert, priv)
if err != nil {
t.Fatalf("SignData(A): %v", err)
}

if _, err := Verify(sigA, dataA, opts); err != nil {
t.Fatalf("sanity: Verify(sigA, dataA) must succeed: %v", err)
}
if _, err := Verify(sigA, dataB, opts); err == nil {
t.Fatal("attack: Verify accepted sigA against dataB (cross-message replay)")
}
}

// TestAttack_KeyConfusion_DifferentKey_SameSubject tests the scenario
// where an attacker mints a separate cert under the same Subject/Issuer
// DN as the legitimate signer but with their own keypair, then submits
// their cert in the CMS bag. The verifier MUST use the cert whose
// public key actually validates the signature, not a name-matched
// impersonator. Cert chain validation (chain to a trusted root) is the
// load-bearing defense.
//
// Attack class: subject-name impersonation / key confusion.
func TestAttack_KeyConfusion_DifferentKey_SameSubject(t *testing.T) {
cert, priv, pool := newBuilderSigner(t)
opts := VerifyOptions{Roots: pool}

data := []byte("key-confusion target")
sig, err := SignData(data, cert, priv)
if err != nil {
t.Fatalf("SignData: %v", err)
}

// Mint an attacker cert with identical Subject DN, fresh key.
_, attackerKey, _ := ed25519.GenerateKey(rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(0xc115), // same serial as victim too
Subject: pkix.Name{Organization: []string{"go-cms builder"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
attackerCertDER, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, attackerKey.Public(), attackerKey)
attackerCert, _ := x509.ParseCertificate(attackerCertDER)

// Trust ONLY the attacker cert. The legitimate signature still uses
// the legitimate cert's key inside the CMS blob — verification must
// fail because the attacker cert's key cannot validate it.
attackerPool := newPool(attackerCert)
if _, err := Verify(sig, data, VerifyOptions{Roots: attackerPool}); err == nil {
t.Fatal("attack: Verify accepted signature when only an unrelated cert with the same subject was trusted")
}

// Sanity: with the real cert trusted, verification works.
_ = opts
if _, err := Verify(sig, data, VerifyOptions{Roots: pool}); err != nil {
t.Fatalf("sanity: Verify with real cert trusted: %v", err)
}
}

// TestAttack_NoTrustedRoots_Denied confirms that verifying without any
// trusted roots fails closed rather than open. A library that defaulted
// to "trust the embedded cert when no roots are given" would silently
// accept attacker-supplied signatures.
//
// Attack class: trust-store bypass via missing-root configuration.
func TestAttack_NoTrustedRoots_Denied(t *testing.T) {
cert, priv, _ := newBuilderSigner(t)

data := []byte("trust-store bypass test")
sig, err := SignData(data, cert, priv)
if err != nil {
t.Fatalf("SignData: %v", err)
}

// No Roots, no Intermediates. The signing cert is self-signed and
// not in any system pool; verification MUST fail.
if _, err := Verify(sig, data, VerifyOptions{}); err == nil {
t.Fatal("attack: Verify accepted self-signed-and-untrusted CMS with empty VerifyOptions.Roots")
}
}

// TestAttack_TrailingDataInjection confirms that a valid CMS blob with
// extra bytes appended is rejected — i.e. the parser does NOT silently
// stop at the end of the SignedData. A reader that processed only the
// prefix would be vulnerable to a content-smuggling attack where the
// trailing bytes carry attacker-chosen payload that downstream code
// might mishandle.
//
// Attack class: trailing-data smuggling.
func TestAttack_TrailingDataInjection(t *testing.T) {
cert, priv, pool := newBuilderSigner(t)
opts := VerifyOptions{Roots: pool}

data := []byte("trailing-data attack test")
sig, err := SignData(data, cert, priv)
if err != nil {
t.Fatalf("SignData: %v", err)
}

for _, trailer := range [][]byte{
{0x00},
{0xff, 0xff, 0xff, 0xff},
[]byte("smuggled bytes"),
} {
tampered := append(append([]byte(nil), sig...), trailer...)
if _, err := Verify(tampered, data, opts); err == nil {
t.Errorf("attack: Verify accepted CMS with %d trailing bytes appended", len(trailer))
}
}
}

// TestAttack_AlgorithmDowngrade_DigestVsActualBytes builds a CMS where
// the SignerInfo claims SHA-256 in DigestAlgorithm but the digest
// embedded in messageDigest was actually computed with SHA-512 (because
// the builder always emits SHA-512). RFC 8419 §3 mandates SHA-512 for
// Ed25519 with signedAttrs, so the verifier MUST reject the SHA-256
// claim before it can be exploited to widen attack surface.
//
// Attack class: digest-algorithm downgrade.
func TestAttack_AlgorithmDowngrade_DigestVsActualBytes(t *testing.T) {
cert, priv, pool := newBuilderSigner(t)
opts := VerifyOptions{Roots: pool}
data := []byte("digest downgrade test")

sig, err := SignData(data, cert, priv)
if err != nil {
t.Fatalf("SignData: %v", err)
}

// SHA-512 OID = 06 09 60 86 48 01 65 03 04 02 03; replace with SHA-256
// OID = 06 09 60 86 48 01 65 03 04 02 01. They differ in the final
// byte (03 -> 01). The verifier should reject because RFC 8419
// requires SHA-512.
sha512Bytes := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03}
sha256Tail := []byte{0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01}
tampered := append([]byte(nil), sig...)
patched := 0
for i := 0; i+len(sha512Bytes) <= len(tampered); i++ {
if bytes.Equal(tampered[i:i+len(sha512Bytes)], sha512Bytes) {
copy(tampered[i:i+len(sha256Tail)], sha256Tail)
patched++
}
}
if patched == 0 {
t.Skip("SHA-512 OID not located in CMS blob; encoding may have changed")
}

if _, err := Verify(tampered, data, opts); err == nil {
t.Fatal("attack: Verify accepted CMS with SHA-512→SHA-256 digest-algorithm downgrade")
}
}

// TestAttack_AttachedEContent_RejectedForDetachedAPI exercises the
// boundary between detached and attached CMS. SignData produces detached
// signatures (no eContent in EncapContentInfo). If an attacker injects
// attacker-controlled eContent into an otherwise-valid CMS, the verifier
// must not silently change semantic: either it ignores eContent and
// validates against the *caller-supplied* detached data (current
// behaviour), or it errors. Critically, it must NOT validate the
// attacker-supplied eContent against the signature, because that would
// mean the same signature attests two different payloads.
//
// We construct the attack by patching the EncapContentInfo to contain
// dummy eContent, then verify against legitimate detached data: the
// signature still binds the detached data via messageDigest, so it
// should pass — but only because the verifier ignored the smuggled
// eContent. We then verify against the smuggled eContent as detached
// data: it MUST fail (different digest).
//
// Attack class: attached-vs-detached content confusion.
func TestAttack_AttachedEContent_RejectedForDetachedAPI(t *testing.T) {
cert, priv, pool := newBuilderSigner(t)
opts := VerifyOptions{Roots: pool}

legitData := []byte("legitimate detached payload")
smuggledData := []byte("attacker smuggled payload")

sig, err := SignData(legitData, cert, priv)
if err != nil {
t.Fatalf("SignData: %v", err)
}

// Sanity: verify works against legit data.
if _, err := Verify(sig, legitData, opts); err != nil {
t.Fatalf("sanity: Verify(sig, legitData): %v", err)
}

// Verify against the smuggled data: must fail. Even though no
// content patching has happened here, this asserts the core
// detached-data binding: signature is over messageDigest of
// caller-supplied data, not anything embedded in the CMS blob.
if _, err := Verify(sig, smuggledData, opts); err == nil {
t.Fatal("attack: Verify accepted smuggled data against signature bound to different data (detached-binding broken)")
}

_ = asn1.NullRawValue // keep encoding/asn1 referenced even under future trimming
}
15 changes: 10 additions & 5 deletions pkg/cms/cms_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,16 @@ type cmsBuildConfig struct {

// SIVersion overrides SignerInfo.Version. Zero means "derive from
// SIDForm" (1 for IAS, 3 for SKI). Tests that want to probe mismatch
// (e.g. SKI+version=1) set this explicitly.
SIVersion int
// (e.g. SKI+version=1) set this explicitly. Use SIVersionExplicit to
// force a literal 0 (e.g. to test rejection of v0).
SIVersion int
SIVersionExplicit bool

// SDVersion overrides SignedData.Version. Zero means default of 1.
SDVersion int
// Use SDVersionExplicit to force a literal 0 (e.g. to test rejection
// of v0, which the deprecated legacy PKCS#7 SignedData used).
SDVersion int
SDVersionExplicit bool

// EContentOID overrides EncapContentInfo.eContentType. Zero (nil)
// means oidData (1.2.840.113549.1.7.1).
Expand Down Expand Up @@ -136,15 +141,15 @@ func buildTestCMS(tb testing.TB, cert *x509.Certificate, priv ed25519.PrivateKey
data = []byte("builder-default-content")
}
siVersion := cfg.SIVersion
if siVersion == 0 {
if siVersion == 0 && !cfg.SIVersionExplicit {
if cfg.SIDForm == sidSKI {
siVersion = 3
} else {
siVersion = 1
}
}
sdVersion := cfg.SDVersion
if sdVersion == 0 {
if sdVersion == 0 && !cfg.SDVersionExplicit {
sdVersion = 1
}
eContentOID := cfg.EContentOID
Expand Down
Loading
Loading