refactor(bundler): expose SignStatement primitive for predicate-agnostic keyless signing#852
Conversation
…tic keyless signing Extract the Sigstore Fulcio + Rekor keyless-signing plumbing from KeylessAttester.Attest into a reusable SignStatement primitive that takes pre-built in-toto Statement bytes. The Attest method keeps its existing public contract — it now builds the SLSA Provenance v1 Statement (caller-specific) and delegates the DSSE wrap, ephemeral keypair, Fulcio cert issuance, Rekor entry, and Sigstore-bundle marshal (caller-agnostic) to the new function. Motivation: The recipe-test-attestation pipeline landing under NVIDIA#754 needs to sign an in-toto Statement carrying a different predicate type (https://aicr.nvidia.com/recipe-evidence/v1) but the same DSSE + Fulcio + Rekor + protobuf-JSON output shape. Without this split, that pipeline would duplicate ~80 lines of identical signing plumbing in pkg/evidence/attestation/signer.go. Extracting now means the second caller is a thin client of the bundler's primitive. API surface: - SignOptions{OIDCToken, FulcioURL, RekorURL} - SignedAttestation{BundleJSON, Identity, Issuer, RekorLogIndex} - SignStatement(ctx, statementJSON []byte, opts SignOptions) (*SignedAttestation, error) The new return type also exposes the Fulcio cert's OIDC issuer claim and the Rekor inclusion-proof log index — a strict superset of the existing identity-only extraction. Callers that only need identity can ignore the extras; pkg/evidence/attestation will consume all three to populate its pointer file's signer block. Internal cleanup: - extractSignerClaims (identity + issuer) replaces extractSignerIdentity as the canonical extractor; extractSignerIdentity is retained as a thin wrapper so the existing keyless_test.go assertions stand unchanged. - extractIssuerExtension parses the Fulcio OIDC-issuer X.509 extension (OIDs 1.3.6.1.4.1.57264.1.1 legacy and .1.8 current). - extractRekorLogIndex returns the first transparency-log entry's index, or 0 when no Rekor entry was created. Tests cover input validation, identity-from-email and identity-from-URI extraction (matching the two Fulcio OIDC modes), issuer extension parsing, Rekor log-index extraction, and the empty-bundle / nil fallbacks. The signing call itself still needs a live Sigstore stack and remains exercised only via integration tests. Related: NVIDIA#754, ADR-007 (`docs/design/007-recipe-evidence.md`)
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Enterprise Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe PR extracts Sigstore keyless DSSE signing into a new SignStatement function with exported SignOptions and SignedAttestation types, implements helpers to extract signer identity, Fulcio issuer, and the first Rekor log index from a signed protobuf Bundle, updates KeylessAttester.Attest to call SignStatement and set k.identity, and adds unit tests covering validation, claim extraction, and log-index selection. Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pkg/bundler/attestation/signing_test.go`:
- Around line 91-116: Combine the three repetitive tests into one table-driven
test named TestExtractRekorLogIndex: create a slice of test cases each
containing a name, input *protobundle.Bundle (cases: bundle with two TlogEntries
{LogIndex:42,...}, bundle with empty VerificationMaterial, and bundle with nil
VerificationMaterial), and expected int result (42, 0, 0); then range over cases
and use t.Run(tc.name, func(t *testing.T) { if got :=
extractRekorLogIndex(tc.bundle); got != tc.want { t.Errorf("LogIndex = %d, want
%d", got, tc.want) } }). Ensure you reference the existing function
extractRekorLogIndex and the
protobundle.Bundle/VerificationMaterial/TransparencyLogEntry types when
constructing case inputs.
In `@pkg/bundler/attestation/signing.go`:
- Around line 189-200: extractIssuerExtension currently treats both OIDs
identically and returns string(ext.Value), which corrupts OID
1.3.6.1.4.1.57264.1.8 values; change extractIssuerExtension to detect the OID
and for the current OID ("1.3.6.1.4.1.57264.1.8") call asn1.Unmarshal into a Go
string (handle/unmarshal error by returning empty or falling back), while
keeping the legacy OID ("1.3.6.1.4.1.57264.1.1") behavior as string(ext.Value);
import/use the encoding/asn1 package and return the decoded UTF8 string for the
current OID.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: fcd214fb-be8d-413b-bf72-d2345e987d07
📒 Files selected for processing (3)
pkg/bundler/attestation/keyless.gopkg/bundler/attestation/signing.gopkg/bundler/attestation/signing_test.go
extractIssuerExtension treated both the legacy (OID 1.3.6.1.4.1.57264.1.1) and current (OID 1.3.6.1.4.1.57264.1.8) Fulcio issuer extensions as raw UTF-8 bytes. The current OID is actually a DER-encoded ASN.1 UTF8String (per the Sigstore Fulcio OID spec), so the returned issuer string carried the leading ASN.1 tag/length prefix and could not be compared against the expected issuer URL. Decode the current-OID value via asn1.Unmarshal; keep raw-bytes handling for the legacy OID where the unwrapped form is correct. Consolidate the three TestExtractRekorLogIndex_* funcs into one table-driven test, and update the test cert helpers to stamp the issuer using a shared helper that DER-encodes the value the same way Fulcio does.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pkg/bundler/attestation/signing_test.go`:
- Around line 72-82: Add table-driven tests in
pkg/bundler/attestation/signing_test.go to cover the other branches of
extractIssuerExtension: create cases for the legacy OID encoding (the older
"...1.1" form) and for an ASN.1 decode failure (malformed extension bytes), then
call extractSignerClaims (or directly extractIssuerExtension) for each case and
assert the returned issuer/identity match the expected outcomes for those
branches; update TestExtractSignerClaims_PullsIssuerFromExtension (or add a new
table-driven test function) to iterate cases, include descriptive case names,
and assert both success and error-path behavior so all branches are exercised.
In `@pkg/bundler/attestation/signing.go`:
- Line 139: The info-level log at the signing step currently emits the signer
identity (variable identity) which may contain PII; change the log so identity
is not written at info level: either remove identity from the slog.Info call
entirely and keep a generic success message ("in-toto statement signed",
"rekorLogIndex", rekorIndex), or move identity into a debug-level log (use
slog.Debug with "identity", identity) or redact it (e.g., replace with a
hashed/obfuscated value) before logging; update the call to the
slog.Info/slog.Debug invocation in signing.go (the slog.Info(...) line)
accordingly.
- Around line 117-126: The sign.Bundle call can hang on network I/O; wrap it in
a child context with a timeout using the repo timeout constant from pkg/defaults
(e.g., defaults.RepoTimeout) before calling sign.Bundle in signing.go, pass that
child context via the Context field in sign.BundleOptions, and ensure you call
the returned cancel function (defer cancel()) to avoid leaks; update references
around bundle, err := sign.Bundle(...) to use the timed context instead of the
original ctx.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 5262c5e5-fc0a-413e-87bc-d89fe0e834f6
📒 Files selected for processing (2)
pkg/bundler/attestation/signing.gopkg/bundler/attestation/signing_test.go
Two cleanups in pkg/bundler/attestation.SignStatement and one test
addition:
- Wrap the sign.Bundle call in context.WithTimeout against a new
defaults.SigstoreSignTimeout (2 minutes) so a hung Fulcio or
Rekor peer cannot block the CLI indefinitely. The shorter caller
deadline still wins if it is already attached.
- Demote the post-sign INFO line so it carries only the public
rekorLogIndex; the Fulcio SAN (identity) and OIDC issuer URL are
moved to DEBUG. Identity is user PII for interactive OIDC and
shouldn't show up in default-level CLI output; callers that need
it for auditing still receive both via SignedAttestation.
- Add TestExtractIssuerExtension covering both Fulcio OIDs, a
malformed-ASN.1 value, and the no-issuer-extension case.
mchmarny
left a comment
There was a problem hiding this comment.
Clean refactor — the split between predicate-agnostic signing and the SLSA-specific Attest wrapper is the right cut for the upcoming recipe-evidence pipeline. ASN.1-aware handling of the current Fulcio OID and the new Sigstore signing timeout are both improvements over the prior code, not just relocations. Tests cover the new extractors well.
One medium concern worth resolving before merge: the PII rationale you added to SignStatement (identity at Debug only) is undermined by KeylessAttester.Attest still logging identity at Info — pick one policy and apply it consistently.
Two low/nit items: extractIssuerExtension returns whichever Fulcio OID appears first in the cert rather than preferring the current OID, and extractSignerIdentity is now production code maintained only for tests. Neither blocks merge.
CI: test/lint/e2e jobs all green; gate and a few scanners (CodeQL, Grype, ClamAV) still in progress.
… precedence Three cleanups in pkg/bundler/attestation: * Remove the extractSignerIdentity wrapper. It was a one-line shim around extractSignerClaims kept for the older tests; migrate the tests to call extractSignerClaims directly so the package has one parsing surface instead of two. * Demote the post-sign INFO line in KeylessAttester.Attest so the Fulcio SAN identity flows to Debug only. Mirrors the SignStatement contract — identity is user PII for interactive OIDC. Callers that need the value still read it back via KeylessAttester.Identity(). * Rework extractIssuerExtension from a single-pass switch to a two-pass scan that prefers the current OID (1.3.6.1.4.1.57264.1.8) over the legacy one (1.3.6.1.4.1.57264.1.1) regardless of X.509 extension ordering. A single-pass switch makes the result order-dependent for transitional certs that carry both. Add two table entries pinning the ordering invariant in both directions.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
pkg/bundler/attestation/keyless_test.go (1)
100-194: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick winCollapse the
extractSignerClaimsvariants into a table-driven test.These cases only vary by bundle fixture and expected identity, so keeping them split makes the helper coverage harder to extend now that
extractSignerClaimsis the shared production surface.As per coding guidelines, "Use table-driven tests for functions with multiple test cases."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pkg/bundler/attestation/keyless_test.go` around lines 100 - 194, Collapse the many TestExtractSignerClaims_* functions into a single table-driven test that iterates cases containing a name, input bundle (constructed with createTestCert, buildTestBundle or inline protobundle fixtures for nil/NoCert/Invalid/Chain/EmptyChain), and the expected identity string, and for each case call t.Run(case.name, func(t *testing.T){ got, _ := extractSignerClaims(case.bundle); if got != case.want { t.Errorf(...) } }); ensure you include cases for Email, URI, NilBundlePointer, NoCert, InvalidCertDER, CertChain, EmptyCertChain and NoSAN and reuse existing helpers (createTestCert, buildTestBundle) so coverage remains identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pkg/bundler/attestation/signing.go`:
- Around line 134-136: The error returned when sign.Bundle fails should
distinguish deadline exceeded from general unavailability: inside the code
handling the sign.Bundle call (using signCtx and returning via errors.Wrap), use
stderrors.Is(err, context.DeadlineExceeded) to detect a timeout and wrap/return
errors.ErrCodeTimeout in that case; otherwise keep returning
errors.ErrCodeUnavailable. Update the error-returning branch around
sign.Bundle/signCtx to use this check and return the appropriate wrapped error
(errors.ErrCodeTimeout vs errors.ErrCodeUnavailable).
- Around line 223-230: The ASN.1 decode in the loop over cert.Extensions
currently ignores trailing bytes from asn1.Unmarshal; change the asn1.Unmarshal
call in the block that processes ext.Value to capture the returned rest (e.g.,
rest, err := asn1.Unmarshal(...)), and if err != nil OR len(rest) != 0, treat it
as a failure: log the same debug message (including "oid" and "error"/context)
and return "" so extensions with trailing bytes are rejected; this touches the
code around the ext.Value/asn1.Unmarshal call in signing.go.
---
Outside diff comments:
In `@pkg/bundler/attestation/keyless_test.go`:
- Around line 100-194: Collapse the many TestExtractSignerClaims_* functions
into a single table-driven test that iterates cases containing a name, input
bundle (constructed with createTestCert, buildTestBundle or inline protobundle
fixtures for nil/NoCert/Invalid/Chain/EmptyChain), and the expected identity
string, and for each case call t.Run(case.name, func(t *testing.T){ got, _ :=
extractSignerClaims(case.bundle); if got != case.want { t.Errorf(...) } });
ensure you include cases for Email, URI, NilBundlePointer, NoCert,
InvalidCertDER, CertChain, EmptyCertChain and NoSAN and reuse existing helpers
(createTestCert, buildTestBundle) so coverage remains identical.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Enterprise
Run ID: 40795582-b2eb-49d0-a763-4a34df2066bc
📒 Files selected for processing (4)
pkg/bundler/attestation/keyless.gopkg/bundler/attestation/keyless_test.gopkg/bundler/attestation/signing.gopkg/bundler/attestation/signing_test.go
…te issuer extensions Two correctness fixes in pkg/bundler/attestation, plus a tidy-up: * SignStatement now distinguishes context.DeadlineExceeded from a generic Sigstore failure. A SigstoreSignTimeout-bounded deadline surfaces as ErrCodeTimeout (HTTP 504 at the API boundary, telling the caller "the signing flow took too long"), while Fulcio refusals and Rekor 5xx still flow through as ErrCodeUnavailable (502). * extractIssuerExtension now inspects asn1.Unmarshal's rest return and rejects any extension carrying trailing bytes after a well-formed UTF8String. A tag-stuffed value that decodes cleanly for the first segment but appends extra data could otherwise be honored silently, masking either truncation or an attempt to smuggle additional content. Adds a table case pinning the rejection. * Consolidate the seven TestExtractSignerClaims_* identity tests in keyless_test.go into one table-driven TestExtractSignerClaims_Identity covering email/URI SAN, single Certificate vs X509CertificateChain, nil/missing/malformed cert, empty chain, and no-SAN cases. Pure refactor — same coverage.
Replace the duplicated DSSE/Fulcio/Rekor plumbing in pkg/evidence/attestation/signer.go with a thin call to bundler/attestation.SignStatement (the predicate-agnostic primitive landed in NVIDIA#852). KeylessSigner.Sign now packages its OIDC token and URLs into bundleattest.SignOptions and converts the bundler's SignedAttestation back into the local SignResult shape that pointer files and the builder consume. Wins: - ~150 LoC of duplicated signing ceremony removed (sign.Bundle, ephemeral keypair, Fulcio/Rekor wiring, protojson marshal, extractSignerClaims, extractIssuerExtension, extractRekorLogIndex). - Inherits the bundler primitive's improvements automatically: SigstoreSignTimeout deadline, ErrCodeTimeout vs ErrCodeUnavailable classification, ASN.1-aware OIDC issuer extraction with current/legacy OID precedence, PII-safe Debug-only identity logging. - DefaultFulcioURL/DefaultRekorURL are now consts pointing at the bundler's so the two packages cannot drift on the Sigstore public-good URLs. KeylessSigner, NoOpSigner, SignBundle, and SignResult retain the same public shape so the rest of the package (builder, pointer, OCI push) is unaffected.
Replace the duplicated DSSE/Fulcio/Rekor plumbing in pkg/evidence/attestation/signer.go with a thin call to bundler/attestation.SignStatement (the predicate-agnostic primitive landed in NVIDIA#852). KeylessSigner.Sign now packages its OIDC token and URLs into bundleattest.SignOptions and converts the bundler's SignedAttestation back into the local SignResult shape that pointer files and the builder consume. Wins: - ~150 LoC of duplicated signing ceremony removed (sign.Bundle, ephemeral keypair, Fulcio/Rekor wiring, protojson marshal, extractSignerClaims, extractIssuerExtension, extractRekorLogIndex). - Inherits the bundler primitive's improvements automatically: SigstoreSignTimeout deadline, ErrCodeTimeout vs ErrCodeUnavailable classification, ASN.1-aware OIDC issuer extraction with current/legacy OID precedence, PII-safe Debug-only identity logging. - DefaultFulcioURL/DefaultRekorURL are now consts pointing at the bundler's so the two packages cannot drift on the Sigstore public-good URLs. KeylessSigner, NoOpSigner, SignBundle, and SignResult retain the same public shape so the rest of the package (builder, pointer, OCI push) is unaffected.
Replace the duplicated DSSE/Fulcio/Rekor plumbing in pkg/evidence/attestation/signer.go with a thin call to bundler/attestation.SignStatement (the predicate-agnostic primitive landed in NVIDIA#852). KeylessSigner.Sign now packages its OIDC token and URLs into bundleattest.SignOptions and converts the bundler's SignedAttestation back into the local SignResult shape that pointer files and the builder consume. Wins: - ~150 LoC of duplicated signing ceremony removed (sign.Bundle, ephemeral keypair, Fulcio/Rekor wiring, protojson marshal, extractSignerClaims, extractIssuerExtension, extractRekorLogIndex). - Inherits the bundler primitive's improvements automatically: SigstoreSignTimeout deadline, ErrCodeTimeout vs ErrCodeUnavailable classification, ASN.1-aware OIDC issuer extraction with current/legacy OID precedence, PII-safe Debug-only identity logging. - DefaultFulcioURL/DefaultRekorURL are now consts pointing at the bundler's so the two packages cannot drift on the Sigstore public-good URLs. KeylessSigner, NoOpSigner, SignBundle, and SignResult retain the same public shape so the rest of the package (builder, pointer, OCI push) is unaffected.
Summary
Extract the Sigstore Fulcio + Rekor keyless-signing plumbing from
KeylessAttester.Attestinto a reusableSignStatementprimitive inpkg/bundler/attestation, so the upcoming recipe-evidence pipeline (#754) can share it instead of duplicating ~80 lines of identical code.Motivation / Context
ADR-007 (recipe-test-attestation, #754) introduces a second producer of in-toto Statements signed via Sigstore keyless OIDC — same DSSE envelope, same Fulcio cert flow, same Rekor inclusion proof, same protobuf-JSON bundle — but with a different predicate type (
https://aicr.nvidia.com/recipe-evidence/v1). Today, all of that plumbing lives insideKeylessAttester.Attestand is hard-wired to the SLSA Provenance v1 predicate the bundler emits. Without this split,pkg/evidence/attestation/signer.gowould copy/paste the same 80 LoC.This refactor isolates the predicate-agnostic half so the second producer becomes a thin client of the existing primitive. It is intentionally a pure refactor — no observable behavior change for
aicr bundle --attest.Fixes: N/A
Related: #754, ADR-007 (
docs/design/007-recipe-evidence.md)Type of Change
Component(s) Affected
pkg/bundler,pkg/component/*)Implementation Notes
New API surface in
pkg/bundler/attestation:SignStatementtakes pre-built in-toto Statement bytes (so callers own the predicate) and returns the full Sigstore bundle plus the identity claims extracted from the Fulcio certificate. The returnedSignedAttestationis a strict superset of whatAttestused to surface internally — adding the OIDC issuer and Rekor log index — so the new evidence pointer file can populate its full signer block without re-parsing the certificate.Internal cleanup, also in this PR:
extractSignerClaims(identity + issuer) replacesextractSignerIdentityas the canonical extractor; the old function is retained as a thin wrapper so existing assertions inkeyless_test.gostand unchanged.extractIssuerExtensionparses the Fulcio OIDC-issuer X.509 extension (OIDs1.3.6.1.4.1.57264.1.1legacy and.1.8current).extractRekorLogIndexreturns the first transparency-log entry's index, or 0 when no Rekor entry was created.KeylessAttester.Attestkeeps its public signature and semantics: it still builds the SLSA Provenance v1 Statement, then delegates DSSE wrap + Fulcio + Rekor + bundle marshal toSignStatement. Existing callers see no change.Considered and deferred: promoting the primitive to its own top-level package (e.g.,
pkg/sigstore). Decided against in this PR to keep the diff small; the only consumer outsidepkg/bundler/attestationwill bepkg/evidence/attestation(PR-Z), which can live with an import alias.Testing
Per-package results on
pkg/bundler/attestation:go test ./pkg/bundler/attestation/...— PASSgolangci-lint run— 0 issuesSignStatementitself is at 16% — the live Fulcio/Rekor call path still requires integration tests, same as before this PR)New
signing_test.gocovers:SignStatementinput validation (empty statement, missing OIDC token)extractSignerClaimsfor email-SAN certs (interactive OIDC) and URI-SAN certs (workload OIDC, e.g. GitHub Actions)extractIssuerExtensionfor the current Fulcio OID (.1.8)extractRekorLogIndexfor present/absent/nilVerificationMaterialcasesRisk Assessment
Rollout notes: Pure internal refactor. No CLI flag, API, on-disk artifact, or wire-format changes.
aicr bundle --attestcontinues to produce byte-identical Sigstore bundles for the same input.Checklist
make testwith-race)make lint)git commit -S)