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
3 changes: 3 additions & 0 deletions .changes/unreleased/Changed-20261102-120000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Changed
body: "**Breaking:** `(*base.B2).CreateKey`, `CreateKeyMultiBucket`, and `(*base.Bucket).GetDownloadAuthorization` now reject negative lifetimes, positive lifetimes below one second, and lifetimes of 1000 days or more with `base.ErrInvalidDuration`. This also applies to `b2.Lifetime`, `b2.Deadline`, `Bucket.AuthToken`, and `Object.AuthURL`. Pass a lifetime of at least one second (longer fractional values truncate to whole seconds) and below 1000 days for keys, or zero for a non-expiring key; download authorization requires 1 to 604800 seconds."
time: 2026-11-02T12:00:00.000000-06:00
10 changes: 8 additions & 2 deletions b2/b2.go
Original file line number Diff line number Diff line change
Expand Up @@ -928,14 +928,20 @@ func (b *Bucket) getObject(ctx context.Context, name string, ssec *SSECKey) (*Ob

// AuthToken returns an authorization token that can be used to access objects
// in a private bucket. Only objects that begin with prefix can be accessed.
// The token expires after the given duration.
// The token expires after the given duration. Negative, sub-second or excessive
// lifetimes return base.ErrInvalidDuration. Longer fractional durations
// truncate to whole seconds. Pass at least one second; the server rejects zero
// for download authorization.
func (b *Bucket) AuthToken(ctx context.Context, prefix string, valid time.Duration) (string, error) {
return b.b.getDownloadAuthorization(ctx, prefix, valid, "")
}

// AuthURL returns a URL for the given object with embedded token and,
// possibly, b2ContentDisposition arguments. Leave b2cd blank for no content
// disposition.
// disposition. Negative, sub-second or excessive lifetimes return
// base.ErrInvalidDuration. Longer fractional durations truncate to whole
// seconds. Pass at least one second; the server rejects zero for download
// authorization.
func (o *Object) AuthURL(ctx context.Context, valid time.Duration, b2cd string) (*url.URL, error) {
token, err := o.b.b.getDownloadAuthorization(ctx, o.name, valid, b2cd)
if err != nil {
Expand Down
11 changes: 8 additions & 3 deletions b2/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,21 @@ type keyOptions struct {
// KeyOption specifies desired properties for application keys.
type KeyOption func(*keyOptions)

// Lifetime requests a key with the given lifetime.
// Lifetime requests a key with the given lifetime. A zero lifetime creates a
// non-expiring key. Otherwise the lifetime must be at least one second and
// below 1000 days; longer fractional durations truncate to whole seconds.
// CreateKey returns base.ErrInvalidDuration if invalid.
func Lifetime(d time.Duration) KeyOption {
return func(k *keyOptions) {
k.lifetime = d
}
}

// Deadline requests a key that expires after the given date.
// Deadline requests a key that expires after the given date. The duration from
// the time Deadline is called must satisfy Lifetime's bounds; CreateKey rejects
// past deadlines and deadlines less than one second away.
func Deadline(t time.Time) KeyOption {
d := t.Sub(time.Now())
d := time.Until(t)
return Lifetime(d)
}

Expand Down
130 changes: 130 additions & 0 deletions b2/lifetime_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright 2026, the Blazer authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package b2

import (
"context"
"errors"
"net/http"
"testing"
"time"

"github.com/Backblaze/blazer/base"
)

func TestCredentialLifetimeValidationWire(t *testing.T) {
// Lifetime bounds: https://www.backblaze.com/apidocs/b2-create-key
// and https://www.backblaze.com/apidocs/b2-get-download-authorization.
srv, client := newCreateKeyWire(t)
bucket := createKeyWireBucket(t, srv, client)
srv.Reply("b2_get_download_authorization", http.StatusOK, `{"authorizationToken":"download-token"}`)
ctx := context.Background()
for _, method := range []struct {
name string
path string
call func(time.Duration) error
}{
{"client key", "/b2api/v3/b2_create_key", func(valid time.Duration) error {
_, err := client.CreateKey(ctx, "key", Lifetime(valid))
return err
}},
{"bucket key", "/b2api/v3/b2_create_key", func(valid time.Duration) error {
_, err := bucket.CreateKey(ctx, "key", Lifetime(valid))
return err
}},
{"multi-bucket key", "/b2api/v4/b2_create_key", func(valid time.Duration) error {
_, err := client.CreateKey(ctx, "key", BucketIDs("bucket-a", "bucket-b"), Lifetime(valid))
return err
}},
{"auth token", "/b2api/v4/b2_get_download_authorization", func(valid time.Duration) error {
_, err := bucket.AuthToken(ctx, "prefix/", valid)
return err
}},
{"auth URL", "/b2api/v4/b2_get_download_authorization", func(valid time.Duration) error {
_, err := bucket.Object("object").AuthURL(ctx, valid, "")
return err
}},
} {
t.Run(method.name, func(t *testing.T) {
for _, valid := range []time.Duration{
500 * time.Millisecond, -500 * time.Millisecond, -time.Nanosecond, -time.Second,
86400000 * time.Second, 2147483648 * time.Second,
} {
t.Run(valid.String(), func(t *testing.T) {
before := len(srv.Requests())
err := method.call(valid)
if !errors.Is(err, base.ErrInvalidDuration) {
t.Errorf("error = %v, want base.ErrInvalidDuration", err)
}
if got := len(srv.Requests()) - before; got != 0 {
t.Errorf("HTTP requests = %d, want 0", got)
}
})
}
t.Run("one second", func(t *testing.T) {
before := len(srv.Requests())
if err := method.call(time.Second); err != nil {
t.Fatal(err)
}
requests := srv.Requests()[before:]
if len(requests) != 1 || requests[0].Path != method.path {
t.Fatalf("credential requests = %v, want one to %s", requests, method.path)
}
if got, want := requests[0].JSON["validDurationInSeconds"], float64(1); got != want {
t.Errorf("validDurationInSeconds = %v, want %v", got, want)
}
})
t.Run("one and a half seconds", func(t *testing.T) {
before := len(srv.Requests())
if err := method.call(1500 * time.Millisecond); err != nil {
t.Fatal(err)
}
requests := srv.Requests()[before:]
if len(requests) != 1 || requests[0].Path != method.path {
t.Fatalf("credential requests = %v, want one to %s", requests, method.path)
}
if got, want := requests[0].JSON["validDurationInSeconds"], float64(1); got != want {
t.Errorf("validDurationInSeconds = %v, want %v", got, want)
}
})
})
}
t.Run("future deadline", func(t *testing.T) {
deadline := time.Now().Add(time.Hour)
before := len(srv.Requests())
if _, err := client.CreateKey(ctx, "key", Deadline(deadline)); err != nil {
t.Fatal(err)
}
requests := srv.Requests()[before:]
if len(requests) != 1 || requests[0].Path != "/b2api/v3/b2_create_key" {
t.Fatalf("credential requests = %v, want one to /b2api/v3/b2_create_key", requests)
}
// Deadline and CreateKey each read the clock. Their reads can straddle a
// one-second truncation boundary, so either whole-second value is correct.
if got := requests[0].JSON["validDurationInSeconds"]; got != float64(3599) && got != float64(3600) {
t.Errorf("validDurationInSeconds = %v, want 3599 or 3600", got)
}
})
t.Run("past deadline", func(t *testing.T) {
before := len(srv.Requests())
_, err := client.CreateKey(ctx, "key", Deadline(time.Now().Add(-500*time.Millisecond)))
if !errors.Is(err, base.ErrInvalidDuration) {
t.Errorf("error = %v, want base.ErrInvalidDuration", err)
}
if got := len(srv.Requests()) - before; got != 0 {
t.Errorf("HTTP requests = %d, want 0", got)
}
})
}
59 changes: 51 additions & 8 deletions base/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -1745,13 +1745,41 @@ func (b *Bucket) ListFileVersions(ctx context.Context, count int, startName, sta
return files, b2resp.NextName, b2resp.NextID, nil
}

// GetDownloadAuthorization wraps b2_get_download_authorization.
// ErrInvalidDuration reports a negative, sub-second or excessive key or
// download-authorization lifetime.
var ErrInvalidDuration = errors.New("b2: invalid duration")

// validDurationSeconds converts a lifetime to wire seconds without int overflow.
func validDurationSeconds(valid time.Duration) (int, error) {
if valid < 0 {
return 0, fmt.Errorf("%w %v: must not be negative", ErrInvalidDuration, valid)
}
if valid > 0 && valid < time.Second {
return 0, fmt.Errorf("%w %v: must be at least one second", ErrInvalidDuration, valid)
}
// b2_create_key requires less than 1000 days; this also fits a 32-bit int.
// https://www.backblaze.com/apidocs/b2-create-key
if valid >= 1000*24*time.Hour {
return 0, fmt.Errorf("%w %v: must be less than 1000 days", ErrInvalidDuration, valid)
}
return int(valid / time.Second), nil
}

// GetDownloadAuthorization wraps b2_get_download_authorization. Negative
// durations, positive durations below one second and lifetimes of 1000 days or
// more return ErrInvalidDuration before sending a request. Longer fractional
// durations truncate to whole seconds. The server requires a duration from one
// second to one week; zero is still sent for the server to reject.
func (b *Bucket) GetDownloadAuthorization(ctx context.Context, prefix string, valid time.Duration, contentDisposition string) (string, error) {
validSeconds, err := validDurationSeconds(valid)
if err != nil {
return "", err
}
state := b.b2.snapshot()
b2req := &b2types.GetDownloadAuthorizationRequest{
BucketID: b.ID,
Prefix: prefix,
Valid: int(valid.Seconds()),
Valid: validSeconds,
ContentDisposition: contentDisposition,
}
b2resp := &b2types.GetDownloadAuthorizationResponse{}
Expand Down Expand Up @@ -2067,31 +2095,46 @@ func keyBucketIDs(key b2types.Key) []string {
}

// CreateKey wraps v3 b2_create_key, producing a single-bucket key (or, with an
// empty bucketID, an unrestricted one) that v3-only clients can still use. For
// a Multi-Bucket Application Key, use CreateKeyMultiBucket.
// empty bucketID, an unrestricted one) that v3-only clients can still use. A
// zero duration creates a non-expiring key; otherwise the lifetime must be at
// least one second and below 1000 days. Longer fractional durations truncate
// to whole seconds. Invalid lifetimes return ErrInvalidDuration before sending
// a request. For a Multi-Bucket Application Key, use CreateKeyMultiBucket.
func (b *B2) CreateKey(ctx context.Context, name string, caps []string, valid time.Duration, bucketID string, prefix string) (*Key, error) {
validSeconds, err := validDurationSeconds(valid)
if err != nil {
return nil, err
}
state := b.snapshot()
b2req := &b2types.CreateKeyRequestV3{
AccountID: state.accountID,
Capabilities: caps,
Name: name,
Valid: int(valid.Seconds()),
Valid: validSeconds,
BucketID: bucketID,
Prefix: prefix,
}
return b.doCreateKeyWithState(ctx, state, b2types.V3api, b2req)
}

// CreateKeyMultiBucket wraps v4 b2_create_key, producing a Multi-Bucket
// Application Key. Such keys are unusable by v3-only clients; for v3
// compatibility, create one CreateKey per bucket instead.
// Application Key. A zero duration creates a non-expiring key; otherwise the
// lifetime must be at least one second and below 1000 days. Longer fractional
// durations truncate to whole seconds. Invalid lifetimes return
// ErrInvalidDuration before sending a request. Such keys are unusable by
// v3-only clients; for v3 compatibility, create one CreateKey per bucket
// instead.
func (b *B2) CreateKeyMultiBucket(ctx context.Context, name string, caps []string, valid time.Duration, bucketIDs []string, prefix string) (*Key, error) {
validSeconds, err := validDurationSeconds(valid)
if err != nil {
return nil, err
}
state := b.snapshot()
b2req := &b2types.CreateKeyRequestV4{
AccountID: state.accountID,
Capabilities: caps,
Name: name,
Valid: int(valid.Seconds()),
Valid: validSeconds,
BucketIDs: bucketIDs,
Prefix: prefix,
}
Expand Down
Loading
Loading