Skip to content

Commit 6548f17

Browse files
authored
feat(verify): offline / air-gapped verification (--insecure-ignore-tlog) (#1798)
Signed-off-by: Brian Lockwood <lockwobr@gmail.com>
1 parent d1f62de commit 6548f17

7 files changed

Lines changed: 259 additions & 12 deletions

File tree

docs/user/cli-reference.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2649,6 +2649,7 @@ aicr verify <bundle-dir> [flags]
26492649
| `--certificate-identity-regexp` | string | | Override the certificate identity pattern for binary attestation verification. Must contain `"NVIDIA/aicr"`. For testing only. |
26502650
| `--key` | string | | Verify a key-signed bundle attestation against a KMS key URI (`awskms://` \| `gcpkms://` \| `azurekms://` \| `hashivault://`) or a local PEM public-key file. This is the counterpart to `bundle --signing-key`. It coexists with `--certificate-identity-regexp`, which pins the binary attestation; the two verify different attestations. |
26512651
| `--trust-root` | string | | Verify the bundle attestation against a private Sigstore trusted root (a `trusted_root.json` from a self-hosted Fulcio/Rekor). Additive to AICR's built-in public-good root, so NVIDIA-signed and privately-signed bundles both verify. Composes with `--key` and `--certificate-identity-regexp`. The verify counterpart to `bundle --fulcio-url`/`--rekor-url`. |
2652+
| `--insecure-ignore-tlog` | bool | `false` | Offline/air-gapped verification: skip the transparency-log (and observer-timestamp) requirement so a bundle signed with `bundle --signing-key ... --tlog-upload=false` verifies against `--key` with no transparency-log network calls. A local PEM `--key` is then fully offline; a KMS `--key` URI still makes a live `GetPublicKey` call to resolve the key (export a PEM with `cosign public-key` for a truly offline verify). Requires `--key`; the air-gapped path is key-based, not keyless. Named "insecure" because, with no transparency log, there is no trusted timestamp proving when the signature was made. Does not affect the binary attestation, which always requires a transparency log. |
26522653
| `--format` | string | `text` | Output format: `text` or `json`. |
26532654

26542655
#### Trust Levels
@@ -2699,11 +2700,18 @@ aicr verify ./bundles/<bundle-dir> --key ./bundle-signer.pub
26992700

27002701
# Verify a privately-signed bundle against an org trusted root
27012702
aicr verify ./my-bundle --trust-root ./trusted_root.json
2703+
2704+
# Verify an offline/air-gapped bundle: no transparency log, no network calls.
2705+
# Sign on a connected host (signing needs KMS access), then export the public key once:
2706+
aicr bundle -r recipe.yaml --attest --signing-key awskms://alias/my-key --tlog-upload=false -o ./bundles
2707+
cosign public-key --key awskms://alias/my-key > bundle-signer.pub
2708+
# Verify anywhere offline against the exported PEM (a KMS --key URI would make a live GetPublicKey call):
2709+
aicr verify ./bundles/<bundle-dir> --key ./bundle-signer.pub --insecure-ignore-tlog
27022710
```
27032711

27042712
> **`--key` network behavior:** Resolving a **KMS URI** (`awskms://`, `gcpkms://`, `azurekms://`, `hashivault://`) makes network calls to the KMS provider to fetch the public key, so credentials for that provider must be available in the environment. A **local PEM** public-key file is read from disk with no provider calls; export it once with `cosign public-key --key <kms-uri>` (or your provider's console) and verify anywhere.
27052713
>
2706-
> Resolving the key is only part of verification: by default the bundle's Rekor transparency-log entry is also checked. Its inclusion proof is embedded in the bundle, so no live Rekor call is made, but the check needs the Sigstore trusted root. That root is loaded from the local cache and falls back to the embedded trusted root on a cache miss, so no network fetch happens on the verify path and `aicr trust update` is not required for offline use. Verification that drops the transparency-log requirement entirely, for true air-gapped use, is tracked in [#1154](https://github.com/NVIDIA/aicr/issues/1154).
2714+
> Resolving the key is only part of verification: by default the bundle's Rekor transparency-log entry is also checked. Its inclusion proof is embedded in the bundle, so no live Rekor call is made, but the check needs the Sigstore trusted root. That root is loaded from the local cache and falls back to the embedded trusted root on a cache miss, so no network fetch happens on the verify path and `aicr trust update` is not required for offline use. For a bundle signed without a transparency-log entry at all (`bundle --signing-key ... --tlog-upload=false`), pass `--insecure-ignore-tlog` alongside `--key` to drop the transparency-log requirement entirely; verification then runs with no network calls and no trusted timestamp, so use it only for true air-gapped bundles you signed yourself.
27072715
>
27082716
> **Stale root:** If verification fails with certificate chain errors, run `aicr trust update` to refresh the Sigstore trusted root.
27092717

pkg/bundler/attestation/verifying_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ package attestation
1616

1717
import (
1818
"context"
19+
"crypto/ecdsa"
20+
"crypto/elliptic"
21+
"crypto/rand"
1922
stderrors "errors"
2023
"strings"
2124
"testing"
2225

2326
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
27+
"github.com/sigstore/sigstore-go/pkg/root"
2428
"github.com/sigstore/sigstore-go/pkg/verify"
2529

2630
"github.com/NVIDIA/aicr/pkg/errors"
@@ -194,3 +198,63 @@ func TestRequireTLogPolicy_VerifierOptions(t *testing.T) {
194198
t.Fatalf("VerifierOptions() returned %d options, want 2", len(opts))
195199
}
196200
}
201+
202+
func TestNoTLogVerifyPolicy_VerifierOptions(t *testing.T) {
203+
opts := NewNoTLogVerifyPolicy().VerifierOptions()
204+
// The offline policy carries exactly one option (WithNoObserverTimestamps),
205+
// which sigstore-go requires be specified exclusively.
206+
if len(opts) != 1 {
207+
t.Fatalf("VerifierOptions() returned %d options, want 1", len(opts))
208+
}
209+
if opts[0] == nil {
210+
t.Fatal("VerifierOptions()[0] is nil")
211+
}
212+
}
213+
214+
// TestNoTLogVerifyPolicy_RoundTrip proves the offline relaxation is load-bearing:
215+
// a key-signed bundle produced with NewNoTLogPolicy() (no Rekor entry, no
216+
// timestamp) verifies fully offline with NewNoTLogVerifyPolicy(), while the same
217+
// bundle FAILS under the default NewRequireTLogPolicy() (which demands a tlog
218+
// inclusion proof / observer timestamp the air-gapped bundle does not carry).
219+
func TestNoTLogVerifyPolicy_RoundTrip(t *testing.T) {
220+
ctx := context.Background()
221+
222+
// One key drives both the offline signer and the matching key verifier.
223+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
224+
if err != nil {
225+
t.Fatalf("generate key: %v", err)
226+
}
227+
signID := &fakeKeyIdentity{signer: &localECDSASigner{priv: priv}, uri: "awskms://test/key"}
228+
229+
res, err := SignStatementWith(ctx, validStatementJSON(t), signID, NewNoTLogPolicy())
230+
if err != nil {
231+
t.Fatalf("SignStatementWith (offline, no tlog): %v", err)
232+
}
233+
234+
// Build the key verification identity from the public half. Inject an empty
235+
// trust-root source (not the default public-good root, which would require a
236+
// TUF fetch) to keep the round trip fully offline: a key-based, no-timestamp
237+
// verify needs only the key material, exactly the air-gapped guarantee.
238+
offlineSrc := func(context.Context) (root.TrustedMaterial, error) {
239+
return root.TrustedMaterialCollection{}, nil
240+
}
241+
pemPath := writeTempPubPEM(t, &priv.PublicKey)
242+
verID, err := NewKeyVerificationIdentity(ctx, pemPath, offlineSrc)
243+
if err != nil {
244+
t.Fatalf("NewKeyVerificationIdentity: %v", err)
245+
}
246+
247+
// validStatementJSON binds subject checksums.txt to an all-zero sha256, so
248+
// the artifact digest is 32 zero bytes.
249+
digest := make([]byte, 32)
250+
251+
if _, err := VerifyStatementWith(ctx, res.BundleJSON, verID, NewNoTLogVerifyPolicy(), digest); err != nil {
252+
t.Fatalf("offline verify with NewNoTLogVerifyPolicy() must succeed, got %v", err)
253+
}
254+
255+
// The relaxation is load-bearing: the same tlog-less bundle must fail the
256+
// default policy, which requires a transparency-log / observer timestamp.
257+
if _, err := VerifyStatementWith(ctx, res.BundleJSON, verID, NewRequireTLogPolicy(), digest); err == nil {
258+
t.Fatal("verify with NewRequireTLogPolicy() must fail for a tlog-less bundle, got nil error")
259+
}
260+
}

pkg/bundler/attestation/verifytransparency.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import (
2424
// records a Rekor entry.
2525
//
2626
// The offline relaxation (no transparency log, key-based verification of an
27-
// air-gapped signature) is tracked as #1154 and will add a sibling policy
28-
// here, mirroring how noTLogPolicy complements rekorPolicy on the signing side.
27+
// air-gapped signature) is #1154, now implemented as noTLogVerifyPolicy below,
28+
// mirroring how noTLogPolicy complements rekorPolicy on the signing side.
2929
type requireTLogPolicy struct{}
3030

3131
// NewRequireTLogPolicy returns a VerifyTransparencyPolicy that requires a
@@ -38,3 +38,33 @@ func (requireTLogPolicy) VerifierOptions() []verify.VerifierOption {
3838
verify.WithObserverTimestamps(1),
3939
}
4040
}
41+
42+
// noTLogVerifyPolicy is the offline/air-gapped verification dual of noTLogPolicy
43+
// (the signing side, #409): it requires no transparency-log inclusion proof and
44+
// no observer timestamp, so a key-signed bundle with neither can verify with no
45+
// transparency-log network calls. This policy governs only the transparency/
46+
// timestamp axis; whether verification is fully offline also depends on the
47+
// verification identity and trusted-material source (a local PEM key + offline
48+
// trusted root is fully offline, whereas a KMS key URI still resolves remotely).
49+
// INSECURE relative to the default: without a tlog/timestamp there is no trusted
50+
// proof of WHEN the signature was made. Only valid for key-based (--key)
51+
// verification of an air-gapped signature.
52+
//
53+
// verify.WithNoObserverTimestamps() sets the sigstore-go verifier's
54+
// allowNoTimestamp flag, which the verifier's config validation requires to be
55+
// specified exclusively (no WithTransparencyLog / WithObserverTimestamps
56+
// alongside it); it is only usable for key-based, not certificate-based,
57+
// verification, which is exactly the air-gapped KMS/PEM path here.
58+
type noTLogVerifyPolicy struct{}
59+
60+
// NewNoTLogVerifyPolicy returns a VerifyTransparencyPolicy that requires no
61+
// transparency-log inclusion proof and no observer timestamp, so an air-gapped
62+
// key-signed bundle verifies with no transparency-log network calls (fully
63+
// offline use additionally requires an offline key and trusted-material source).
64+
func NewNoTLogVerifyPolicy() VerifyTransparencyPolicy { return noTLogVerifyPolicy{} }
65+
66+
func (noTLogVerifyPolicy) VerifierOptions() []verify.VerifierOption {
67+
return []verify.VerifierOption{
68+
verify.WithNoObserverTimestamps(),
69+
}
70+
}

pkg/bundler/verifier/verifier.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,19 @@ type VerifyOptions struct {
154154
// Composable with Key. Does NOT affect the binary attestation, which is
155155
// always NVIDIA-public-CI-signed and stays pinned to the public-good root.
156156
TrustRoot string
157+
158+
// IgnoreTLog enables offline/air-gapped verification of the key-signed
159+
// bundle attestation: it skips the transparency-log (and observer-timestamp)
160+
// requirement so a bundle produced by `bundle --signing-key ... --tlog-upload=false`
161+
// (#409) verifies with no transparency-log network calls. Full offline
162+
// operation additionally requires a local PEM Key: a KMS Key URI still makes a
163+
// live GetPublicKey call via NewKeyVerificationIdentity to resolve the key.
164+
// ONLY valid with Key set (the air-gapped path is key-based, not keyless);
165+
// Verify rejects it otherwise. INSECURE relative to the default: without a
166+
// tlog/timestamp there is no trusted proof of when the signature was made.
167+
// Does NOT affect the keyless or binary-attestation paths, which always
168+
// require a transparency log.
169+
IgnoreTLog bool
157170
}
158171

159172
// resolveBundleTrustRoot returns the TrustedRootSource for the bundle
@@ -262,17 +275,30 @@ func verifyWithDependencies(
262275
if err := ctx.Err(); err != nil {
263276
return nil, errors.Wrap(errors.ErrCodeTimeout, "context cancelled before verification", err)
264277
}
278+
279+
// Resolve and validate options before touching the filesystem so a malformed
280+
// request (e.g. IgnoreTLog without Key) fails with InvalidRequest regardless
281+
// of whether the bundle directory exists, rather than being masked by a
282+
// NotFound from the os.Stat below.
283+
if opts == nil {
284+
opts = &VerifyOptions{}
285+
}
286+
// Offline/air-gapped verification is key-based only: rejecting the transparency
287+
// log without a public key would leave the keyless path with no trusted
288+
// timestamp and nothing to bind the signature to. Fail closed here (not just
289+
// in the CLI) so library callers get the same guarantee.
290+
if opts.IgnoreTLog && opts.Key == "" {
291+
return nil, errors.New(errors.ErrCodeInvalidRequest,
292+
"IgnoreTLog (offline verification) requires Key: the air-gapped path is key-based")
293+
}
294+
265295
if _, err := os.Stat(bundleDir); err != nil {
266296
if os.IsNotExist(err) {
267297
return nil, errors.New(errors.ErrCodeNotFound, "bundle directory not found: "+bundleDir)
268298
}
269299
return nil, errors.Wrap(errors.ErrCodeInternal, "cannot access bundle directory", err)
270300
}
271301

272-
// Resolve options
273-
if opts == nil {
274-
opts = &VerifyOptions{}
275-
}
276302
identityPattern := opts.CertificateIdentityRegexp
277303
if identityPattern == "" {
278304
identityPattern = TrustedRepositoryPattern
@@ -360,7 +386,7 @@ func verifyStagedSnapshot(
360386
err error
361387
)
362388
if opts.Key != "" {
363-
bundleCreator, err = verifyKeySignedBundle(ctx, bundleAttestPath, checksumDigest, opts.Key, bundleSource)
389+
bundleCreator, err = verifyKeySignedBundle(ctx, bundleAttestPath, checksumDigest, opts.Key, bundleSource, opts.IgnoreTLog)
364390
} else {
365391
bundleCreator, err = verifySigstoreBundle(ctx, bundleAttestPath, checksumDigest, bundleSource)
366392
}
@@ -700,8 +726,12 @@ func verifySigstoreBundle(ctx context.Context, bundlePath string, artifactDigest
700726
// verifyKeySignedBundle verifies a public-key-signed bundle attestation (#407
701727
// KMS signing or a local PEM) against the key named by keyRef, binding it to
702728
// artifactDigest. Returns the key identity (KMS URI or pem:<fp>) as the bundle
703-
// creator, since a key-signed bundle has no certificate SAN.
704-
func verifyKeySignedBundle(ctx context.Context, bundlePath string, artifactDigest []byte, keyRef string, src attestation.TrustedRootSource) (string, error) {
729+
// creator, since a key-signed bundle has no certificate SAN. When ignoreTLog is
730+
// true (offline/air-gapped, #1154), the transparency-log requirement is relaxed
731+
// so a bundle signed with `bundle --signing-key ... --tlog-upload=false` (#409)
732+
// verifies with no transparency-log network calls (a local PEM keyRef is then
733+
// fully offline; a KMS URI keyRef still resolves the key remotely).
734+
func verifyKeySignedBundle(ctx context.Context, bundlePath string, artifactDigest []byte, keyRef string, src attestation.TrustedRootSource, ignoreTLog bool) (string, error) {
705735
id, err := attestation.NewKeyVerificationIdentity(ctx, keyRef, src)
706736
if err != nil {
707737
return "", err // already classified (ErrCodeInvalidRequest / ErrCodeUnavailable)
@@ -710,7 +740,11 @@ func verifyKeySignedBundle(ctx context.Context, bundlePath string, artifactDiges
710740
if err != nil {
711741
return "", err // already coded by readBoundedFileContext
712742
}
713-
signer, err := attestation.VerifyStatementWith(ctx, data, id, attestation.NewRequireTLogPolicy(), artifactDigest)
743+
tlogPolicy := attestation.NewRequireTLogPolicy()
744+
if ignoreTLog {
745+
tlogPolicy = attestation.NewNoTLogVerifyPolicy()
746+
}
747+
signer, err := attestation.VerifyStatementWith(ctx, data, id, tlogPolicy, artifactDigest)
714748
if err != nil {
715749
return "", err
716750
}

pkg/bundler/verifier/verifier_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,51 @@ func TestVerify_NilOptions(t *testing.T) {
185185
}
186186
}
187187

188+
// TestVerify_IgnoreTLogRequiresKey pins the defense-in-depth guard: offline
189+
// verification (IgnoreTLog) is key-based only, so Verify must reject the option
190+
// with ErrCodeInvalidRequest when Key is empty, before any bundle inspection.
191+
func TestVerify_IgnoreTLogRequiresKey(t *testing.T) {
192+
dir := createTestBundle(t)
193+
194+
tests := []struct {
195+
name string
196+
opts *VerifyOptions
197+
wantErr bool
198+
}{
199+
{"ignore tlog without key", &VerifyOptions{IgnoreTLog: true}, true},
200+
{"ignore tlog with key", &VerifyOptions{IgnoreTLog: true, Key: "awskms://test/key"}, false},
201+
{"no ignore tlog, no key", &VerifyOptions{}, false},
202+
}
203+
for _, tt := range tests {
204+
t.Run(tt.name, func(t *testing.T) {
205+
_, err := Verify(context.Background(), dir, tt.opts)
206+
if tt.wantErr {
207+
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
208+
t.Fatalf("Verify() error = %v, want ErrCodeInvalidRequest", err)
209+
}
210+
return
211+
}
212+
// The non-error cases here assert only that the guard did NOT trip;
213+
// downstream verification (e.g. a bogus KMS key) may still fail, so we
214+
// only require the error, if any, is not the guard's InvalidRequest.
215+
if err != nil && strings.Contains(err.Error(), "offline verification") {
216+
t.Fatalf("Verify() unexpectedly tripped IgnoreTLog guard: %v", err)
217+
}
218+
})
219+
}
220+
221+
// The guard runs before the bundle-directory stat, so an invalid
222+
// IgnoreTLog/Key combination is reported as InvalidRequest even when the
223+
// directory does not exist (it must not be masked by NotFound).
224+
t.Run("guard precedes NotFound for a missing directory", func(t *testing.T) {
225+
_, err := Verify(context.Background(), filepath.Join(t.TempDir(), "does-not-exist"),
226+
&VerifyOptions{IgnoreTLog: true})
227+
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
228+
t.Fatalf("Verify() error = %v, want ErrCodeInvalidRequest", err)
229+
}
230+
})
231+
}
232+
188233
func TestExtractToolVersion(t *testing.T) {
189234
t.Run("valid bundle with tool version", func(t *testing.T) {
190235
// Build a minimal sigstore bundle JSON with a DSSE envelope

pkg/cli/bundle_verify.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ Require a minimum CLI version (bare version defaults to >= semantics):
6565
Verify a privately-signed bundle against an org trusted root:
6666
aicr verify ./my-bundle --trust-root ./trusted_root.json
6767
68+
Verify an offline/air-gapped bundle (no transparency-log network calls; use a
69+
local PEM key for fully offline operation, since a KMS URI still resolves remotely):
70+
aicr verify ./bundle --key ./bundle-signer.pub --insecure-ignore-tlog
71+
6872
Output as JSON:
6973
aicr verify ./my-bundle --format json
7074
`,
@@ -99,6 +103,10 @@ Output as JSON:
99103
Name: "trust-root",
100104
Usage: "Verify the bundle attestation against a private Sigstore trusted root (a trusted_root.json from a self-hosted Fulcio/Rekor). Additive to AICR's built-in public-good root, so NVIDIA-signed and privately-signed bundles both verify. Composes with --key and --certificate-identity-regexp. The verify counterpart to `bundle --fulcio-url`/`--rekor-url`.",
101105
},
106+
&cli.BoolFlag{
107+
Name: "insecure-ignore-tlog",
108+
Usage: "Skip transparency-log verification for an offline/air-gapped bundle signed with `bundle --signing-key ... --tlog-upload=false`. Verifies the signature against --key with no transparency-log network calls. Requires --key (the air-gapped path is key-based); a local PEM key is fully offline, while a KMS URI still makes a live GetPublicKey call to resolve the key. \"insecure\" because, with no transparency log, there is no trusted timestamp proving when the signature was made.",
109+
},
102110
withCompletions(&cli.StringFlag{
103111
Name: flagFormat,
104112
Value: verifyFormatText,
@@ -139,6 +147,14 @@ func runBundleVerifyCmd(ctx context.Context, cmd *cli.Command) error {
139147
}
140148
verifyOpts.Key = cmd.String("key")
141149
verifyOpts.TrustRoot = cmd.String("trust-root")
150+
verifyOpts.IgnoreTLog = cmd.Bool("insecure-ignore-tlog")
151+
152+
// Offline/air-gapped verification is key-based only: reject the flag combo up
153+
// front so the user gets a clear message instead of a downstream failure.
154+
if verifyOpts.IgnoreTLog && verifyOpts.Key == "" {
155+
return errors.New(errors.ErrCodeInvalidRequest,
156+
"--insecure-ignore-tlog requires --key: offline verification is key-based (verify a bundle signed with `bundle --signing-key ... --tlog-upload=false`)")
157+
}
142158

143159
// Run verification
144160
result, err := verifier.Verify(ctx, absDir, verifyOpts)

0 commit comments

Comments
 (0)