Skip to content

Commit 9055062

Browse files
committed
feat(bundle): add --tlog-upload flag for air-gapped KMS signing
Signed-off-by: Brian Lockwood <lockwobr@gmail.com>
1 parent 6548f17 commit 9055062

9 files changed

Lines changed: 327 additions & 13 deletions

File tree

docs/contributor/rekor-v2-signing.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ TUF v2 config (default), then a Rekor v1 URL.
114114
`KMSAttester` builds its transparency policy through the same
115115
`transparencyForOptions` path as keyless signing, so both stay in lockstep. A
116116
KMS v2 entry carries public-key verification material (no Fulcio certificate).
117+
- `--tlog-upload=false` (KMS only) skips the Rekor upload entirely for fully
118+
offline / air-gapped signing (#409). It sets `ResolveOptions.DisableTLogUpload`,
119+
which wires `NewKMSAttester(..., WithoutTransparencyLog())`; the attester's
120+
`HasRekorEntry()` then reports false and `Attest` attaches no transparency log.
121+
The flag is rejected on the keyless path, because keyless OIDC signing needs
122+
Fulcio and Rekor network access, and is mutually exclusive with `--rekor-url`
123+
and `--signing-config`: those select a transparency-log target, which cannot be
124+
reconciled with writing no entry (the no-tlog policy would silently override
125+
them, so it fails closed instead). Verify such a bundle with
126+
`aicr verify --key <uri> --insecure-ignore-tlog`.
117127

118128
`--rekor-url` and `--signing-config` are mutually exclusive, so an operator's
119129
private Rekor is never silently overridden by the v2 default.

docs/user/cli-reference.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,7 @@ aicr bundle [flags]
12751275
| `--rekor-url` | | string | Sign the `--attest` bundle to **Rekor v1** at this URL instead of the **Rekor v2 default** (a private Sigstore instance, or the public-good v1 URL). Must be an absolute `https://` URL with no embedded credentials. Also reads `AICR_REKOR_URL`. Independent of `--fulcio-url`. Mutually exclusive with `--signing-config`. |
12761276
| `--signing-config` | | string | Sign the `--attest` bundle with a custom Sigstore signing config JSON instead of the default Rekor v2 config (advanced — e.g. an edited config or a private v2 instance). Also reads `AICR_SIGNING_CONFIG`. Mutually exclusive with `--rekor-url`. |
12771277
| `--signing-key` | | string | Sign the `--attest` bundle with a KMS-backed key instead of keyless OIDC, for CI/CD environments without OIDC (Jenkins, internal pipelines). Takes a KMS URI; supported schemes are `awskms://`, `gcpkms://`, `azurekms://`, and `hashivault://`. Like keyless signing, KMS signs to Rekor v2 by default; opt out with `--rekor-url` (v1) or `--signing-config` (custom). Mutually exclusive with `--identity-token`, `--oidc-device-flow`, and `--fulcio-url` (the keyless-only flags); passing both is a validation error. See [KMS-Backed Signing](#kms-backed-signing). |
1278+
| `--tlog-upload` | | bool | Upload the signature to the Rekor transparency log (default: `true`). Set `--tlog-upload=false` to skip the Rekor upload for fully offline / air-gapped signing; this requires `--signing-key` (KMS), because keyless OIDC signing needs Fulcio and Rekor network access to mint a verifiable certificate. Mutually exclusive with `--rekor-url` and `--signing-config` (both select where the transparency-log entry goes, which contradicts writing none). Verify the resulting bundle offline with `aicr verify --key <public-key.pem> --insecure-ignore-tlog`; use a local PEM public key for a fully offline verify, since a KMS `--key` URI still makes a live `GetPublicKey` call. See [KMS-Backed Signing](#kms-backed-signing). |
12781279
| `--yes` | `--assume-yes` | bool | Skip the interactive confirmation shown before keyless signing publishes your OIDC identity (browser/device-code paths only; the banner is still printed). Reads `AICR_ASSUME_YES`. See [Privacy: identity in keyless signatures](#privacy-identity-in-keyless-signatures). |
12791280

12801281
`--image-refs` writes the published digest through a mode-`0600` temporary
@@ -2338,6 +2339,25 @@ Like keyless signing, KMS signs to **Rekor v2** by default. Opt out with
23382339
`--signing-config` to use a custom signing config; the two opt-outs are mutually
23392340
exclusive with each other but both compose with `--signing-key`.
23402341

2342+
For a fully offline / air-gapped signing host that cannot reach any Rekor
2343+
instance, add `--tlog-upload=false`. KMS signing then skips the transparency-log
2344+
upload entirely and the bundle carries no Rekor entry:
2345+
2346+
```shell
2347+
aicr bundle --recipe recipe.yaml --attest \
2348+
--signing-key awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234 \
2349+
--tlog-upload=false \
2350+
--output ./bundles
2351+
```
2352+
2353+
`--tlog-upload=false` requires `--signing-key`; it is rejected on the keyless
2354+
path, because keyless OIDC signing needs Fulcio and Rekor network access to mint
2355+
a verifiable certificate. Verify an air-gapped bundle offline with
2356+
`aicr verify --key <public-key.pem> --insecure-ignore-tlog`, which skips the
2357+
transparency-log lookup that would otherwise require network access. Use a local
2358+
PEM public key (exported once with `cosign public-key --key <kms-uri>`) for a
2359+
fully offline verify: a KMS `--key` URI still makes a live `GetPublicKey` call.
2360+
23412361
The resulting bundle uses the same Sigstore bundle format as keyless signing,
23422362
but its verification material is the signing key's public key rather than a
23432363
Fulcio certificate.

pkg/bundler/attestation/kms.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,42 @@ type KMSAttester struct {
4040

4141
// tlog, when non-nil, overrides the transparency policy that Attest would
4242
// otherwise compute from target. Used to inject a no-tlog policy for offline
43-
// testing, and reserved for a future --tlog-upload=false opt-out (#409).
43+
// testing, and wired to the --tlog-upload=false opt-out via
44+
// WithoutTransparencyLog for offline / air-gapped signing (#409).
4445
tlog TransparencyPolicy
4546
}
4647

48+
// KMSAttesterOption customizes a KMSAttester at construction time.
49+
type KMSAttesterOption func(*KMSAttester)
50+
51+
// WithoutTransparencyLog makes the attester skip the Rekor upload (offline /
52+
// air-gapped signing, #409). The resulting attester's HasRekorEntry reports
53+
// false and Attest writes no transparency-log entry.
54+
func WithoutTransparencyLog() KMSAttesterOption {
55+
return func(k *KMSAttester) { k.tlog = NewNoTLogPolicy() }
56+
}
57+
4758
// NewKMSAttester returns a KMSAttester for keyURI. Like keyless signing, KMS
4859
// signs to Rekor v2 by default (via the TUF-distributed signing config, which
4960
// also supplies the timestamp authority v2 requires) unless target opts out to a
5061
// Rekor v1 URL or a custom signing config. target is produced by
5162
// SignOptionsFromResolve so the KMS path shares the keyless path's signing-target
5263
// mapping. The transparency-log entry carries public-key verification material
5364
// (no Fulcio certificate). See #1650.
54-
func NewKMSAttester(keyURI string, target SignOptions) *KMSAttester {
55-
return &KMSAttester{
65+
//
66+
// opts customize the attester; WithoutTransparencyLog opts out of the Rekor
67+
// upload for offline / air-gapped signing (#409). With no options the Rekor
68+
// upload behavior is unchanged.
69+
func NewKMSAttester(keyURI string, target SignOptions, opts ...KMSAttesterOption) *KMSAttester {
70+
k := &KMSAttester{
5671
keyURI: keyURI,
5772
identity: NewKMSIdentity(keyURI),
5873
target: target,
5974
}
75+
for _, opt := range opts {
76+
opt(k)
77+
}
78+
return k
6079
}
6180

6281
// Attest creates a DSSE-signed in-toto SLSA provenance statement for the given

pkg/bundler/attestation/kms_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ func TestKMSAttesterContract(t *testing.T) {
3333
}
3434
}
3535

36+
// TestKMSAttesterWithoutTransparencyLog verifies the functional option opts the
37+
// attester out of the Rekor upload: HasRekorEntry reports false, while a
38+
// default (no-option) attester still reports true. See #409.
39+
func TestKMSAttesterWithoutTransparencyLog(t *testing.T) {
40+
const keyURI = "gcpkms://projects/p/locations/l/keyRings/r/cryptoKeys/k"
41+
42+
cases := []struct {
43+
name string
44+
opts []KMSAttesterOption
45+
wantRekor bool
46+
}{
47+
{"default records a Rekor entry", nil, true},
48+
{"WithoutTransparencyLog records no entry", []KMSAttesterOption{WithoutTransparencyLog()}, false},
49+
}
50+
for _, tc := range cases {
51+
t.Run(tc.name, func(t *testing.T) {
52+
a := NewKMSAttester(keyURI, SignOptions{UseTUFSigningConfig: true}, tc.opts...)
53+
if a.HasRekorEntry() != tc.wantRekor {
54+
t.Errorf("HasRekorEntry() = %v, want %v", a.HasRekorEntry(), tc.wantRekor)
55+
}
56+
if a.Identity() != keyURI {
57+
t.Errorf("Identity = %q, want %q", a.Identity(), keyURI)
58+
}
59+
})
60+
}
61+
}
62+
3663
// TestKMSAttesterAttest exercises Attest end-to-end offline by injecting a
3764
// fake key-based identity (local ECDSA signer, no cert provider) and the
3865
// no-tlog policy, so no KMS provider or Rekor call is made. It asserts the
@@ -63,4 +90,10 @@ func TestKMSAttesterAttest(t *testing.T) {
6390
if b.GetVerificationMaterial().GetPublicKey() == nil {
6491
t.Error("want public-key verification material for KMS signing")
6592
}
93+
// The no-tlog policy must produce a bundle with no transparency-log entries:
94+
// assert directly on Attest's output (HasRekorEntry only reports the policy
95+
// choice, not the emitted bundle).
96+
if got := len(b.GetVerificationMaterial().GetTlogEntries()); got != 0 {
97+
t.Errorf("no-tlog KMS attestation carries %d tlog entries, want 0", got)
98+
}
6699
}

pkg/bundler/attestation/resolver.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ type ResolveOptions struct {
7575
// CLI boundary (pkg/cli). See issue #407.
7676
SigningKey string
7777

78+
// DisableTLogUpload skips the Rekor transparency-log upload for KMS
79+
// (SigningKey) signing, enabling fully offline / air-gapped bundle
80+
// attestation (#409). The zero value (false) is the safe default: a caller
81+
// constructing ResolveOptions without setting it still gets a Rekor entry.
82+
//
83+
// It affects ONLY the KMS path. Keyless (OIDC) signing ignores this field
84+
// and always uploads to Rekor, because keyless needs Fulcio + Rekor network
85+
// access to produce a verifiable certificate; that is a fail-safe. The CLI
86+
// boundary (pkg/cli) rejects DisableTLogUpload without a SigningKey so a
87+
// keyless caller cannot silently expect an offline signature.
88+
DisableTLogUpload bool
89+
7890
// PromptWriter receives user-facing prompts emitted by the interactive
7991
// and device-code flows (verification URL + short code). Pass os.Stderr
8092
// for typical CLI behavior, io.Discard to suppress, or nil (treated as
@@ -167,7 +179,11 @@ func ResolveAttester(ctx context.Context, opts ResolveOptions) (Attester, error)
167179
return NewNoOpAttester(), nil
168180
}
169181
if opts.SigningKey != "" {
170-
return NewKMSAttester(opts.SigningKey, SignOptionsFromResolve("", opts)), nil
182+
var kopts []KMSAttesterOption
183+
if opts.DisableTLogUpload {
184+
kopts = append(kopts, WithoutTransparencyLog())
185+
}
186+
return NewKMSAttester(opts.SigningKey, SignOptionsFromResolve("", opts), kopts...), nil
171187
}
172188
token, err := ResolveOIDCToken(ctx, opts)
173189
if err != nil {
@@ -195,7 +211,11 @@ func ResolveAttesterLazy(_ context.Context, opts ResolveOptions) (Attester, erro
195211
return NewNoOpAttester(), nil
196212
}
197213
if opts.SigningKey != "" {
198-
return NewKMSAttester(opts.SigningKey, SignOptionsFromResolve("", opts)), nil
214+
var kopts []KMSAttesterOption
215+
if opts.DisableTLogUpload {
216+
kopts = append(kopts, WithoutTransparencyLog())
217+
}
218+
return NewKMSAttester(opts.SigningKey, SignOptionsFromResolve("", opts), kopts...), nil
199219
}
200220
return NewLazyKeylessAttester(opts), nil
201221
}

pkg/bundler/attestation/resolver_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,75 @@ func TestResolveAttesterLazyKMS(t *testing.T) {
240240
}
241241
}
242242

243+
// TestResolveAttesterDisableTLogUpload verifies DisableTLogUpload wires the
244+
// no-tlog override onto the KMS attester (offline / air-gapped signing) for
245+
// both the eager and lazy resolvers, and only when SigningKey is set. Keyless
246+
// signing (SigningKey empty) ignores the flag and always uploads. See #409.
247+
func TestResolveAttesterDisableTLogUpload(t *testing.T) {
248+
const keyURI = "awskms://arn:aws:kms:us-east-1:111:key/abc"
249+
250+
resolvers := []struct {
251+
name string
252+
resolve func(ResolveOptions) (Attester, error)
253+
}{
254+
{"eager", func(o ResolveOptions) (Attester, error) {
255+
return ResolveAttester(context.Background(), o)
256+
}},
257+
{"lazy", func(o ResolveOptions) (Attester, error) {
258+
return ResolveAttesterLazy(context.Background(), o)
259+
}},
260+
}
261+
262+
cases := []struct {
263+
name string
264+
opts ResolveOptions
265+
wantKMS bool // attester must (KMS) / must not (keyless) be a *KMSAttester
266+
wantRekor bool // expected HasRekorEntry()
267+
}{
268+
{
269+
// KMS + DisableTLogUpload:true → no Rekor entry.
270+
name: "kms uploads disabled",
271+
opts: ResolveOptions{Attest: true, SigningKey: keyURI, DisableTLogUpload: true},
272+
wantKMS: true,
273+
wantRekor: false,
274+
},
275+
{
276+
// KMS + DisableTLogUpload:false (default) → Rekor entry.
277+
name: "kms default uploads",
278+
opts: ResolveOptions{Attest: true, SigningKey: keyURI},
279+
wantKMS: true,
280+
wantRekor: true,
281+
},
282+
{
283+
// Keyless (no SigningKey) + DisableTLogUpload:true → still a keyless
284+
// attester that always uploads; the flag is ignored on this path.
285+
name: "keyless ignores disable and uploads",
286+
opts: ResolveOptions{Attest: true, IdentityToken: "tok", DisableTLogUpload: true},
287+
wantKMS: false,
288+
wantRekor: true,
289+
},
290+
}
291+
292+
for _, r := range resolvers {
293+
t.Run(r.name, func(t *testing.T) {
294+
for _, tc := range cases {
295+
t.Run(tc.name, func(t *testing.T) {
296+
att, err := r.resolve(tc.opts)
297+
if err != nil {
298+
t.Fatalf("resolve: %v", err)
299+
}
300+
if _, ok := att.(*KMSAttester); ok != tc.wantKMS {
301+
t.Fatalf("attester type = %T, want *KMSAttester: %v", att, tc.wantKMS)
302+
}
303+
if att.HasRekorEntry() != tc.wantRekor {
304+
t.Errorf("HasRekorEntry() = %v, want %v", att.HasRekorEntry(), tc.wantRekor)
305+
}
306+
})
307+
}
308+
})
309+
}
310+
}
311+
243312
// TestResolveAttesterLazy_DefersTokenResolution verifies the lazy entry
244313
// point does not touch the OIDC chain at construction time. Constructing
245314
// a lazy attester with a pre-canceled context plus a forced device-flow

0 commit comments

Comments
 (0)