Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/relay/access_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down
51 changes: 51 additions & 0 deletions cmd/relay/aws_grant_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

"github.com/zksecurity/relay/internal/access"
"github.com/zksecurity/relay/internal/store"
)

Expand Down Expand Up @@ -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))
}
24 changes: 17 additions & 7 deletions internal/access/storage_first_grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand All @@ -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
}

Expand Down
30 changes: 30 additions & 0 deletions internal/access/storage_first_grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
15 changes: 12 additions & 3 deletions internal/storagefirst/grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
8 changes: 8 additions & 0 deletions release/release-notes.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading