diff --git a/cmd/relay/access_commands.go b/cmd/relay/access_commands.go index 5fefee7..cd17b81 100644 --- a/cmd/relay/access_commands.go +++ b/cmd/relay/access_commands.go @@ -269,6 +269,9 @@ func runGrant(args []string) error { if err != nil || ttl < time.Second || ttl%time.Second != 0 { return errors.New("--credential-ttl must be a positive whole-second duration") } + if v4 && ttl > access.MaxStorageFirstGrantLifetime { + return fmt.Errorf("storage-first --credential-ttl may be at most %s", access.MaxStorageFirstGrantLifetime) + } minimum, err := time.ParseDuration(minimumText) if err != nil || minimum <= 0 || minimum > ttl { return errors.New("--minimum-remaining must be positive and no greater than --credential-ttl") @@ -342,6 +345,9 @@ func runGrant(args []string) error { if err := grant.Validate(); err != nil { return err } + if err := grant.CheckUnexpired(time.Now().UTC()); err != nil { + return err + } if err := writeJSONNoReplace(out, grant, 0o600); err != nil { return err } diff --git a/cmd/relay/aws_grant_live_test.go b/cmd/relay/aws_grant_live_test.go index a7981ef..f236242 100644 --- a/cmd/relay/aws_grant_live_test.go +++ b/cmd/relay/aws_grant_live_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/zksecurity/relay/internal/access" "github.com/zksecurity/relay/internal/store" ) @@ -120,3 +121,53 @@ func TestAWSLiveGrantScopeAndExpiry(t *testing.T) { } t.Log("AWS explicitly rejected the expired token; coordinator control read succeeded") } + +// Dedicated-account opt-in. Mints a real 1h STS session and checks the +// storage-first remaining-time cap, which is the AWS overshoot that used to +// fail expires_at − issued_at ≤ 1h. +func TestAWSLiveOneHourRemainingTime(t *testing.T) { + if os.Getenv("RELAY_AWS_LIVE_GRANT_APPROVED") != "1" { + t.Skip("requires dedicated AWS test approval") + } + var settings coordinatorStorageSettings + if err := setupReadJSON(os.Getenv("RELAY_AWS_LIVE_SETTINGS_FILE"), &settings); err != nil { + t.Fatal(err) + } + config, err := settings.infrastructure() + if err != nil { + t.Fatal(err) + } + requireAWSLiveConfiguration(t, config) + if os.Getenv("RELAY_AWS_LIVE_CREDENTIALS_FILE") == "" { + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", freshAWSLiveCredentials(t)) + } else { + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", os.Getenv("RELAY_AWS_LIVE_CREDENTIALS_FILE")) + } + id, err := randomID() + if err != nil { + t.Fatal(err) + } + attempt := id + prefix := "submissions/" + strings.Repeat("a", 64) + "/" + attempt + "/" + issued := time.Now().UTC().Truncate(time.Second) + creds, expires, err := issueAWS(config, "one-hour-remaining-test", prefix, time.Hour) + if err != nil { + t.Fatal("could not issue 1h temporary grant") + } + if err := creds.Validate(); err != nil { + t.Fatal(err) + } + grant := access.StorageFirstGrant{ + Schema: access.GrantSchemaV2, Provider: "aws", CeremonyID: "sha256:" + strings.Repeat("a", 64), + GrantRequestID: strings.Repeat("c", 32), CheckpointDigest: "sha256:" + strings.Repeat("d", 64), + SubmissionKind: access.SubmissionKindCandidate, Phase: "phase1", Index: 1, + IdentityID: "participant-03", AttemptID: attempt, Region: config.Region, InboxBucket: config.InboxBucket, + Prefix: prefix, ManifestKey: prefix + "manifest.json", + IssuedAt: issued.Format(time.RFC3339), ExpiresAt: expires.UTC().Format(time.RFC3339), + Credentials: creds, + } + if err := grant.CheckUnexpired(time.Now().UTC()); err != nil { + t.Fatalf("honest AWS 1h session rejected: %v (issued=%s expires=%s span=%s)", err, grant.IssuedAt, grant.ExpiresAt, expires.Sub(issued)) + } + t.Logf("AWS 1h session accepted: issued=%s expires=%s span=%s remaining=%s", grant.IssuedAt, grant.ExpiresAt, expires.Sub(issued), time.Until(expires).Truncate(time.Second)) +} diff --git a/internal/access/storage_first_grant.go b/internal/access/storage_first_grant.go index b8b3295..4071414 100644 --- a/internal/access/storage_first_grant.go +++ b/internal/access/storage_first_grant.go @@ -18,8 +18,13 @@ const ( SubmissionKindRelease = "release" maxStorageFirstContributionIndex = 255 - maxStorageFirstGrantLifetime = time.Hour - maxStorageFirstGrantClockSkew = 5 * time.Minute + // MaxStorageFirstGrantLifetime is the intended upload-key window. Remaining + // validity is measured against now, not against a self-written issued_at. + MaxStorageFirstGrantLifetime = time.Hour + maxStorageFirstGrantClockSkew = 5 * time.Minute + // Provider clocks and AssumeRole round-trip can place AWS Expiration a few + // seconds past a full-hour request. This is not extra requested duration. + maxStorageFirstGrantExpirySkew = 2 * time.Minute ) // StorageFirstGrant is one temporary credential bound to one preallocated @@ -109,9 +114,6 @@ func (g StorageFirstGrant) Validate() error { if err != nil || !expires.After(issued) { return errors.New("expires_at must be canonical RFC3339 UTC and after issued_at") } - if expires.Sub(issued) > maxStorageFirstGrantLifetime { - return fmt.Errorf("storage-first upload credentials may last at most %s", maxStorageFirstGrantLifetime) - } return g.Credentials.Validate() } @@ -121,12 +123,20 @@ func (g StorageFirstGrant) CheckUnexpired(now time.Time) error { } issued, _ := time.Parse(time.RFC3339, g.IssuedAt) expires, _ := time.Parse(time.RFC3339, g.ExpiresAt) - if issued.After(now.UTC().Add(maxStorageFirstGrantClockSkew)) { + now = now.UTC() + if issued.After(now.Add(maxStorageFirstGrantClockSkew)) { return fmt.Errorf("upload credentials are future-dated beyond the allowed %s clock skew", maxStorageFirstGrantClockSkew) } - if !expires.After(now.UTC()) { + if !expires.After(now) { return fmt.Errorf("upload credentials expired at %s", g.ExpiresAt) } + start := now + if issued.After(start) { + start = issued + } + if expires.Sub(start) > MaxStorageFirstGrantLifetime+maxStorageFirstGrantExpirySkew { + return fmt.Errorf("storage-first upload credentials may remain valid at most %s from now", MaxStorageFirstGrantLifetime) + } return nil } diff --git a/internal/access/storage_first_grant_test.go b/internal/access/storage_first_grant_test.go index 035e480..a20eebe 100644 --- a/internal/access/storage_first_grant_test.go +++ b/internal/access/storage_first_grant_test.go @@ -148,3 +148,33 @@ func TestStorageFirstGrantStrictDecodeAndExpiry(t *testing.T) { t.Fatalf("boundary clock skew rejected: %v", err) } } + +func TestStorageFirstGrantRemainingLifetimeUsesNow(t *testing.T) { + now := time.Date(2026, 9, 15, 1, 0, 3, 0, time.UTC) + overshoot := validStorageFirstGrant() + // Coordinator stamped issued_at before AssumeRole; AWS Expiration is 1h from + // STS completion a few seconds later. Remaining time from now is still ~1h. + overshoot.IssuedAt = "2026-09-15T01:00:00Z" + overshoot.ExpiresAt = "2026-09-15T02:00:03Z" + if err := overshoot.Validate(); err != nil { + t.Fatalf("structurally valid overshoot grant: %v", err) + } + if err := overshoot.CheckUnexpired(now); err != nil { + t.Fatalf("AWS 1h session with a few seconds of round-trip rejected: %v", err) + } + + long := validStorageFirstGrant() + long.IssuedAt = "2026-09-15T01:00:00Z" + long.ExpiresAt = "2026-09-15T13:00:00Z" + if err := long.CheckUnexpired(now); err == nil { + t.Fatal("12h remaining upload credentials accepted") + } + + // Remaining-at-accept: a longer original window is allowed once ≤1h remains. + late := validStorageFirstGrant() + late.IssuedAt = "2026-09-15T00:00:00Z" + late.ExpiresAt = "2026-09-15T02:00:00Z" + if err := late.CheckUnexpired(time.Date(2026, 9, 15, 1, 30, 0, 0, time.UTC)); err != nil { + t.Fatalf("grant with 30m remaining rejected: %v", err) + } +} diff --git a/internal/storagefirst/grant_test.go b/internal/storagefirst/grant_test.go index 6197edd..ae5dd15 100644 --- a/internal/storagefirst/grant_test.go +++ b/internal/storagefirst/grant_test.go @@ -168,9 +168,18 @@ func TestValidateGrantBindsExactAuthenticatedSlot(t *testing.T) { func TestStorageFirstGrantRejectsExcessiveLifetime(t *testing.T) { cp := actionCheckpoint(2, "phase1-receipt-accepted", "participant-1") - grant := storageFirstBoundGrant(cp, cp.Slots[0]) - grant.ExpiresAt = time.Date(2026, 9, 15, 2, 0, 1, 0, time.UTC).Format(time.RFC3339) - if err := grant.Validate(); err == nil { + now := time.Date(2026, 9, 15, 1, 0, 1, 0, time.UTC) + overshoot := storageFirstBoundGrant(cp, cp.Slots[0]) + overshoot.ExpiresAt = time.Date(2026, 9, 15, 2, 0, 1, 0, time.UTC).Format(time.RFC3339) + if err := overshoot.Validate(); err != nil { + t.Fatalf("1s provider-clock overshoot must remain structurally valid: %v", err) + } + if err := overshoot.CheckUnexpired(now); err != nil { + t.Fatalf("honest 1h session with 1s STS lag rejected: %v", err) + } + long := storageFirstBoundGrant(cp, cp.Slots[0]) + long.ExpiresAt = time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC).Format(time.RFC3339) + if err := long.CheckUnexpired(now); err == nil { t.Fatal("overlong temporary credential accepted") } } diff --git a/release/release-notes.md b/release/release-notes.md index e00c5c5..88f033b 100644 --- a/release/release-notes.md +++ b/release/release-notes.md @@ -1,5 +1,13 @@ ## What changed +- Storage-first upload grants now cap remaining lifetime at accept + (`expires_at` versus now, plus two minutes of provider-clock allowance), + not `expires_at − issued_at`. AWS STS `Expiration` is one hour from + AssumeRole completion, so a stamp taken before that call made honest 1h + sessions fail by a few seconds. `issued_at` stays an independent stamp + and is not derived from AWS expiry. Guided flow still requests a 1h + credential TTL. This check lives in the online image, so in-progress + ceremonies on an older image keep the previous rule. - Fixed Docker participant profiles for the storage-first workflow. Setup now measures the host proof-tool companion against the approved receipt but records and executes only the image's fixed proof-tool path in Docker.