Files
certctl/internal/crypto/encryption_property_test.go
T
shankar0123 95d0d85391 Bundle Q (Coverage Audit Closure): property-based pilot + hygiene — L-001/L-002/L-003/L-004/I-001 closed
Five small closures wrapping the Low-tier and Info-tier audit findings.

Q.1 — cmd/cli round-out (L-001 closed)
======================================
cmd/cli/dispatch_test.go: ~30 dispatch tests across handleCerts /
handleAgents / handleJobs / handleImport / handleStatus. httptest.NewTLSServer
mocks the API; cli.NewClient(_, _, _, _, true) constructs an
insecure-skip-verify client. Each test pins the missing-args usage-print
path AND the happy-path delegation. Result: 7.1% -> 63.5% coverage
(gate: >=30%).

Q.2 — awssm round-out (L-002 closed)
======================================
internal/connector/discovery/awssm/awssm_edge_test.go: New() default
constructor, extractKeyInfo (ECDSA/Ed25519/unknown — was RSA-only),
processSecret filter arms (NamePrefix mismatch / TagFilter mismatch /
empty-value / GetSecretValue error), realSMClient stub-contract pin
(ListSecrets / GetSecretValue / NewRealSMClient), and EmailAddresses
SAN extraction. Result: 78.2% -> 96.0% coverage (gate: >=85%).

Q.3 — Property-based testing pilot (L-003 closed)
======================================
gopter@v0.2.11 added to go.mod (test-only).

internal/crypto/encryption_property_test.go:
- TestProperty_EncryptDecryptRoundTrip — 50 successful tests,
  DecryptIfKeySet(EncryptIfKeySet(x, k), k) == x
- TestProperty_WrongPassphraseRejected — 30 successful tests,
  AEAD never returns nil-error AND bytes-equal plaintext under
  wrong passphrase
Both skipped under -short to keep developer loop fast (PBKDF2 600k
rounds × 50 iters ≈ 15s on -race CI).

internal/pkcs7/length_property_test.go:
- TestProperty_ASN1LengthRoundTrip — three sub-properties:
  decodeLength(encode(x)) == x for x ∈ [0, 2³¹−1]; short-form
  invariant (length<128 → 1 byte == length); long-form invariant
  (length>=128 → high bit set + N bytes follow). 500 successful
  tests in <10ms.

Q.4 — Architecture diagram multi-agent update (L-004 closed)
======================================
docs/qa-test-guide.md::Architecture: ASCII diagram updated to show
'certctl-agent (×N)' + callout explaining seed_demo.sql provisions
12 agent rows (1 active, 2 retired, 9 reserved/sentinel) for Parts
04, 05, 55 + FSM coverage. Operators running parallel-agent topologies
guided to AGENT_COUNT=N + 'make qa-stats'.

Q.5 — Test-naming CI guard (I-001 closed)
======================================
.github/workflows/ci.yml: Test-naming convention guard added after
the QA-doc seed-count drift guard. Greps for func Test<X>( missing
the <X>_<Scenario> suffix. Prints first 20 non-conformant as
::warning:: annotations. continue-on-error: true (informational).
Excludes TestMain + TestProperty_*. Promotion to hard-fail tracked
as I-001-extended.

Verification
======================================
- python3 yaml.safe_load on ci.yml: OK
- go vet ./cmd/cli/... ./internal/connector/discovery/awssm/...
  ./internal/crypto/... ./internal/pkcs7/...: clean
- go test -short -count=1 across all four packages: PASS
- go test -count=1 (full property tests): PASS
  - crypto 15.4s (50 + 30 × 600k PBKDF2)
  - pkcs7 5ms

Audit deliverables
======================================
- gap-backlog.md: strikethroughs on L-001/L-002/L-003/L-004/I-001
  with per-finding closure note
- closure-plan.md: ticks Bundle Q [x] with per-item breakdown

Closes: L-001, L-002, L-003, L-004, I-001
Bundle: Q (Property-Based + Hygiene)
2026-04-27 18:36:47 +00:00

106 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package crypto
import (
"bytes"
"testing"
"github.com/leanovate/gopter"
"github.com/leanovate/gopter/gen"
"github.com/leanovate/gopter/prop"
)
// Bundle Q (L-003 closure): property-based testing pilot.
//
// Two properties pinned with gopter:
//
// 1. Round-trip — DecryptIfKeySet(EncryptIfKeySet(x, k), k) == x for any
// plaintext x and non-empty passphrase k. This is the core encryption
// invariant; mutation testing on AES-GCM would benefit from this kind
// of generative coverage in addition to the existing example-based
// tests, because randomly-generated edge cases (zero-length plaintext,
// plaintext containing the v2/v3 magic byte, very long plaintext) get
// exercised automatically.
//
// 2. Wrong-passphrase rejection — DecryptIfKeySet(blob, wrongKey) must
// never return a nil error AND non-empty plaintext. AEAD authentication
// guarantees this; the property test makes the guarantee testable
// under generative inputs rather than handpicked vectors.
//
// gopter is a non-blocking pilot — `MinSuccessfulTests` is 200 by default
// and these properties run in <50ms at -short. CI keeps them in the regular
// test stream (no separate gating).
func TestProperty_EncryptDecryptRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping property-based test in -short mode (PBKDF2 600k rounds × 50 iters > short budget)")
}
parameters := gopter.DefaultTestParameters()
parameters.MinSuccessfulTests = 50 // 50 × 600k PBKDF2 ≈ 4-5s on -race CI
properties := gopter.NewProperties(parameters)
properties.Property("DecryptIfKeySet(EncryptIfKeySet(x, k), k) == x", prop.ForAll(
func(plaintext []byte, passphrase string) bool {
// Empty passphrase is the documented sentinel — skip.
if passphrase == "" {
return true
}
blob, ok, err := EncryptIfKeySet(plaintext, passphrase)
if err != nil || !ok {
t.Logf("EncryptIfKeySet(_, %q): err=%v ok=%v", passphrase, err, ok)
return false
}
recovered, err := DecryptIfKeySet(blob, passphrase)
if err != nil {
t.Logf("DecryptIfKeySet round-trip: err=%v plaintext=%v passphrase=%q", err, plaintext, passphrase)
return false
}
return bytes.Equal(recovered, plaintext)
},
// Plaintext: arbitrary byte slices including empty.
gen.SliceOf(gen.UInt8()),
// Passphrase: ASCII alpha, length 1..63 (avoid pathological lengths
// blowing up PBKDF2 budgets in the property runner).
gen.AlphaString().SuchThat(func(s string) bool {
return len(s) > 0 && len(s) < 64
}),
))
properties.TestingRun(t)
}
func TestProperty_WrongPassphraseRejected(t *testing.T) {
if testing.Short() {
t.Skip("skipping property-based test in -short mode (PBKDF2 cost)")
}
parameters := gopter.DefaultTestParameters()
parameters.MinSuccessfulTests = 30 // 30 × 600k PBKDF2 × 2 (encrypt+decrypt) ≈ 5s
properties := gopter.NewProperties(parameters)
properties.Property("Decrypt with wrong passphrase never returns plaintext", prop.ForAll(
func(plaintext []byte, k1, k2 string) bool {
if k1 == "" || k2 == "" || k1 == k2 {
return true
}
blob, _, err := EncryptIfKeySet(plaintext, k1)
if err != nil {
return false
}
recovered, err := DecryptIfKeySet(blob, k2)
// AEAD must reject. Either err != nil (expected), or — in the
// astronomically-unlikely case of a tag collision — recovered
// must NOT equal the original plaintext. Bytes-equal-but-no-error
// is a security-relevant invariant violation.
if err == nil && bytes.Equal(recovered, plaintext) {
t.Logf("AEAD failed to reject wrong passphrase: plaintext=%v k1=%q k2=%q", plaintext, k1, k2)
return false
}
return true
},
gen.SliceOf(gen.UInt8()),
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) < 64 }),
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && len(s) < 64 }),
))
properties.TestingRun(t)
}