diff --git a/changelog/unreleased/issue-2134 b/changelog/unreleased/issue-2134 new file mode 100644 index 000000000..1886967f8 --- /dev/null +++ b/changelog/unreleased/issue-2134 @@ -0,0 +1,19 @@ +Enhancement: Support B2 API keys restricted to hiding but not deleting files + +When the B2 backend does not have the necessary permissions to permanently +delete files, it now automatically falls back to hiding files. This allows +using restic with an application key which is not allowed to delete files. +This prevents an attacker to delete backups with the API key used by restic. + +To use this feature create an application key without the deleteFiles +capability. It is recommended to restrict the key to just one bucket. +For example using the b2 command line tool: + + b2 create-key --bucket listBuckets,readFiles,writeFiles,listFiles + +Alternatively, you can use the S3 backend to access B2, as described +in the documentation. In this mode, files are also only hidden instead +of being deleted permanently. + +https://github.com/restic/restic/issues/2134 +https://github.com/restic/restic/pull/2398 diff --git a/internal/backend/b2/b2.go b/internal/backend/b2/b2.go index 0cbc1aa10..40dbbf893 100644 --- a/internal/backend/b2/b2.go +++ b/internal/backend/b2/b2.go @@ -18,6 +18,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/kurin/blazer/b2" + "github.com/kurin/blazer/base" ) // b2Backend is a backend which stores its data on Backblaze B2. @@ -28,6 +29,8 @@ type b2Backend struct { listMaxItems int layout.Layout sem sema.Semaphore + + canDelete bool } // Billing happens in 1000 item granlarity, but we are more interested in reducing the number of network round trips @@ -104,6 +107,7 @@ func Open(ctx context.Context, cfg Config, rt http.RoundTripper) (restic.Backend }, listMaxItems: defaultListMaxItems, sem: sem, + canDelete: true, } return be, nil @@ -297,11 +301,27 @@ func (be *b2Backend) Remove(ctx context.Context, h restic.Handle) error { // the retry backend will also repeat the remove method up to 10 times for i := 0; i < 3; i++ { obj := be.bucket.Object(be.Filename(h)) - err := obj.Delete(ctx) - if err == nil { - // keep deleting until we are sure that no leftover file versions exist - continue + + var err error + if be.canDelete { + err = obj.Delete(ctx) + if err == nil { + // keep deleting until we are sure that no leftover file versions exist + continue + } + + code, _ := base.Code(err) + if code == 401 { // unauthorized + // fallback to hide if not allowed to delete files + be.canDelete = false + debug.Log("Removing %v failed, falling back to b2_hide_file.", h) + continue + } + } else { + // hide adds a new file version hiding all older ones, thus retries are not necessary + err = obj.Hide(ctx) } + // consider a file as removed if b2 informs us that it does not exist if b2.IsNotExist(err) { return nil