feat: add UCAN handler for listing buckets - #13
Conversation
There was a problem hiding this comment.
Pull request overview
Adds first implemented UCAN RPC handler for listing buckets and introduces an internal SigV4/SigV4a verifier (plus presign helper primarily for tests), while centralizing Vault key-path construction in the vault package.
Changes:
- Add
/s3/bucket/listroute with shared SigV4/SigV4a-based authorization and bucket listing logic. - Introduce
pkg/sigv4for parsing/verifying SigV4 + SigV4a and time-bound validation, with unit tests. - Centralize Vault key path helpers (
TenantKeyPath,AccessKeyPath) and update API code to use them.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/vault/paths.go | Adds shared helpers for Vault key paths for tenants and access keys. |
| pkg/sigv4/sigv4.go | Implements signature parsing, verification dispatch, and time-bound validation. |
| pkg/sigv4/sign.go | Adds presigning utility (primarily for tests/client-side signing). |
| pkg/sigv4/hmac.go | Implements SigV4 HMAC signing/verification. |
| pkg/sigv4/ecdsa.go | Implements SigV4a key derivation and ECDSA signing/verification. |
| pkg/sigv4/canonical.go | Implements SigV4 canonical request / string-to-sign construction. |
| pkg/sigv4/sigv4_test.go | Adds known-answer and round-trip tests for SigV4/SigV4a components. |
| pkg/rpc/list.go | Adds the /s3/bucket/list handler and the unit-testable ListBuckets function. |
| pkg/rpc/list_test.go | Adds unit tests for list-buckets authorization + listing behavior. |
| pkg/rpc/auth.go | Adds shared S3 RPC authorization based on SigV4/SigV4a and Vault-backed access key secrets. |
| pkg/rpc/rpc.go | Removes the old /s3/bucket/list stub from the legacy handler file. |
| pkg/rpc/rpc_test.go | Updates command/handler registration tests for the new list handler signature. |
| pkg/fx/rpc_test.go | Updates UCAN server wiring test to construct the list handler with required dependencies. |
| pkg/api/tenants.go | Switches to centralized Vault tenant key path helper. |
| pkg/api/access_keys.go | Removes local Vault path helpers and switches to vault.TenantKeyPath / vault.AccessKeyPath. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
bajtos
left a comment
There was a problem hiding this comment.
🤖 This review was posted by Claude — automated security review. Advisory only; please verify each item before acting.
Security-focused review of the new authorization surface in this PR (the SigV4/SigV4a verifier, the shared Authorize(), and /s3/bucket/list). The crypto has good test coverage (SigV4 known-answer test, round-trips, tamper/expiry cases), uses constant-time HMAC comparison, and the vault path-helper refactor is byte-for-byte equivalent to what it replaced. The inline comments flag gaps in the auth surface; summary below, most-severe first.
Would fix before merge — authorization lifecycle bypasses (both on the shared Authorize path):
- [High] Access-key
ExpiresAtis never enforced → expired keys still authenticate. (pkg/rpc/auth.go) - [High] Tenant
Status(Disabled/WriteLocked) is never enforced → disabled tenants' keys still work. (pkg/rpc/auth.go)
Signature-coverage / DoS (medium):
- [Medium] SigV4a
X-Amz-Region-Set(andhost) not required to be inSignedHeaders→ authz region not bound to the signature for header-auth requests. (pkg/sigv4/sigv4.go) - [Medium, defense-in-depth] Verified signature isn't bound to the invoked operation. (
pkg/rpc/auth.go) - [Medium/Low, DoS] Unbounded
X-Amz-Region-Set→ one provider DB lookup per region. (pkg/rpc/auth.go)
Lower-severity / cleanup (see inline): access-key Buckets scope not applied to ListBuckets (likely by-design — please confirm against the RFC); verbatim/distinguishable auth errors returned to the caller; permission literal duplicating the pkg/api/permissions.go registry.
Two minor notes without a clean inline anchor:
- SigV4a
X-Amz-Region-Set=*(AWS's all-regions wildcard) is rejected —validateRegiondoes a literalGetByRegion("*")that never matches. Functional over-rejection, not a security hole. - Presigned requests default to
UNSIGNED-PAYLOADwhenX-Amz-Content-Sha256is absent, so the body isn't covered by the signature. Harmless for the GET-only ListBuckets, but a latent risk for future body-carrying handlers (PutObject) that reuse this sharedAuthorizepath.
Generated by Claude Code
| return nil, fmt.Errorf("invalid access key id %q: %w", sr.AccessKeyID, err) | ||
| } | ||
|
|
||
| akRec, err := accessKeys.Get(ctx, accessKeyID) |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[High] Expired access keys still authenticate — akRec.ExpiresAt is never checked.
accesskey.Record carries ExpiresAt *time.Time ("when the access key expires"), but Authorize looks the key up here and never compares it against time.Now(). The only time check, sigv4.ValidateTimeBounds (line 86), bounds the signature's freshness (presigned window / clock skew), not the credential lifetime. This RPC is authorized solely by SigV4 + the DB record + permission — no delegation-expiry gates it (the invocation is issued by the provider, and the server has no proof-verification layer).
Failure: a key whose ExpiresAt is in the past keeps listing buckets (and, as write handlers adopt this shared path, keeps full access) given any freshly-signed request. Suggest rejecting when akRec.ExpiresAt != nil && time.Now().After(*akRec.ExpiresAt).
Generated by Claude Code
| return nil, fmt.Errorf("request signature is no longer valid: %w", err) | ||
| } | ||
|
|
||
| tenantRec, err := tenants.Get(ctx, akRec.Tenant) |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[High] Disabled / write-locked tenants still authenticate — tenantRec.Status is never checked.
Authorize fetches the tenant here but reads only .Provider, never .Status. Disabled is the explicit lock-out state — DeleteTenant refuses to run unless the tenant is already Disabled ("tenant must be disabled before deletion"), so the lifecycle is Active → Disabled → delete.
Failure: an operator disables a tenant (abuse / non-payment / pending deletion) but its access keys keep authenticating and listing buckets. Because Authorize is the shared choke point for all S3 handlers, this also leaves the upcoming create/delete-bucket handlers open for disabled tenants. Suggest rejecting non-Active tenants here (at minimum Disabled; WriteLocked could still permit reads like this one).
Generated by Claude Code
| } else if auth := headers.Get("Authorization"); strings.HasPrefix(auth, "AWS4-") { | ||
| algorithm, credential, signedHeaders, signature = parseAuthorization(auth) | ||
| date = headers.Get(amzDate) | ||
| regionSet = headers.Get(amzRegionSet) |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[Medium] SigV4a authorization region isn't bound to the signature (header-auth path).
For header-authenticated SigV4a, the region that later drives validateRegion comes from this X-Amz-Region-Set header, but canonicalHeaders (canonical.go:51) only folds in headers listed in SignedHeaders, and Parse never requires x-amz-region-set (or host) to be present in that list (the only validation is the non-emptiness check at line 135). So the region-set value is not covered by the signature.
Failure: an on-path attacker replaying a captured, still-valid SigV4a header-auth request (no secret needed) within the ±15-min skew window can rewrite X-Amz-Region-Set to steer the request to a different region the tenant's provider serves — the signature still verifies. Presigned SigV4a is safe (the region-set is a signed query param). AWS mandates both host and X-Amz-Region-Set be signed; suggest rejecting requests that omit them from SignedHeaders.
Generated by Claude Code
| log.Error("loading access key secret", zap.Error(err)) | ||
| return nil, err | ||
| } | ||
| if err := sigv4.Verify(sr, secret); err != nil { |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[Medium — defense-in-depth] The verified signature isn't bound to the invoked operation.
Authorize verifies the caller signed some request (method + URL) but neither it nor ListBuckets checks that the signed request is actually a ListBuckets shape (method/path, or the ?x-id=ListBuckets marker the tests use). The UCAN command (/s3/bucket/list) and the signed args.Request are independent.
Failure: a presigned URL a client created for a different S3 operation (e.g. GetObject) verifies here and authorizes a full bucket listing if the key holds s3:ListAllMyBuckets. Practical severity is reduced by the issuer == tenantRec.Provider gate (line 99) — only the trusted provider can submit these invocations — so this guards against a buggy/confused provider rather than an arbitrary URL holder. Still worth binding the signature to the operation for shared auth infra.
Generated by Claude Code
| // validateRegion confirms the tenant's provider serves one of the regions the | ||
| // request is scoped to, returning the matched region. | ||
| func validateRegion(ctx context.Context, providers provider.Store, regions []string, tenantProvider did.DID) (string, error) { | ||
| for _, r := range regions { |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[Medium/Low — DoS] Unbounded X-Amz-Region-Set → one provider store lookup per region.
splitRegionSet applies no cap or dedup, and this loop does a sequential providers.GetByRegion (a Postgres SELECT … WHERE region=$1) for every region until one matches the tenant's provider.
Failure: a request with a large comma-separated region set forces N sequential DB round-trips, each holding a pooled connection — a ~1 MB header is hundreds of thousands of queries for a single request. In isolation this is post-auth (needs a valid signature); chained with the region-set-not-signed gap (sigv4.go) it becomes credential-less on a captured SigV4a header-auth request. Cheap fix: cap the region-set length in splitRegionSet / Parse.
Generated by Claude Code
| if opts.Cursor != nil { | ||
| listOpts = append(listOpts, bucket.WithCursor(*opts.Cursor)) | ||
| } | ||
| return buckets.ListByTenant(ctx, auth.Tenant.ID, listOpts...) |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[Low — likely by-design, please confirm against the RFC] ListBuckets ignores the access key's Buckets scope.
This returns every bucket in the tenant and never filters by auth.AccessKey.Buckets (the bucket.WithIDs(...) option exists and is used for exactly this in pkg/api/access_keys.go). Evidence suggests it is intended: permissions.go maps s3:ListAllMyBuckets → nil ("bucket-level actions … enforced directly by Ingot/Hilt"), object-level scoping is enforced via per-bucket delegation subjects rather than handler filtering, and AWS's ListAllMyBuckets is account-global and non-scopable.
Flagging only so you can confirm against the RFC that a bucket-scoped key is meant to see all of the tenant's bucket names / regions / creation dates.
Generated by Claude Code
| ok, err := ListBuckets(req.Context(), log, accessKeys, tenants, buckets, providers, secrets, req.Invocation().Issuer(), req.Task().Arguments()) | ||
| if err != nil { | ||
| log.Error("list buckets failed", zap.Error(err)) | ||
| return res.SetFailure(err) |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[Low] Verbatim, distinguishable auth errors surfaced to the caller.
Authorize returns distinct messages for "unknown access key" vs "invalid request signature" and wraps raw vault/store errors; this SetFailure(err) passes them back. If that reaches the client it enables access-key-DID enumeration and leaks internal error detail (vault/store errors).
Value is limited (access-key IDs are semi-public — they appear in every X-Amz-Credential) and impact depends on whether SetFailure serializes the message over the wire. Suggest returning a generic "access denied" to the caller and keeping the detail in logs.
Generated by Claude Code
| ) | ||
|
|
||
| // permListAllMyBuckets is the S3 permission required to list a tenant's buckets. | ||
| const permListAllMyBuckets = "s3:ListAllMyBuckets" |
There was a problem hiding this comment.
🤖 This comment was posted by Claude — automated security review. Advisory only; please verify before acting.
[Low — cleanup] Permission literal re-implements the existing registry.
This hardcodes "s3:ListAllMyBuckets" and does a manual slices.Contains rather than reusing the canonical set in pkg/api/permissions.go (s3PermissionCommands / validS3Permission). The two lists can drift over time. Minor, but consider centralizing the permission check so handlers and the creation-time validator share one source of truth.
Generated by Claude Code
bajtos
left a comment
There was a problem hiding this comment.
Besides the comments posted by Claude above (most of them seem legitimate to me), I have a few comments of my own, see below.
I'll be AFK in the next two weeks; I think it will be best to not wait for my return and ask other @fil-forge/engineering team members to approve.
| // canonicalRequest builds the AWS canonical request string per the SigV4 spec. | ||
| func (s *SignedRequest) canonicalRequest() string { |
There was a problem hiding this comment.
Have you considered using an existing SigV4-verification implementation?
According to Claude, there are two options - SeaweedFS or MinIO, but MinIO is no longer mantained.
https://github.com/seaweedfs/seaweedfs/blob/master/weed/s3api/auth_signature_v4.go
Also:
If you roll your own verifier:
- always compare with hmac.Equal/subtle.ConstantTimeCompare;
- pre-escape the URI exactly as AWS expects — per pkg.go.dev, AWS "recommend[s] that you explicitly escape the request when using this signer outside of the SDK to prevent possible signature mismatch … by setting URL.Opaque on the request … in the form of: ///";
- enforce a ≤15-minute X-Amz-Date window and reject stale/future requests to stop replay;
- test against AWS's published aws4_testsuite vectors.
There was a problem hiding this comment.
Have you considered using an existing SigV4-verification implementation?
I did, neither of those implement Sigv4a though...
test against AWS's published aws4_testsuite vectors.
Now that is interesting. That resource no longer exists on the AWS documentation site AFAICT...I have found the page on wayback machine and will use it though.
| // AccessKeyPath is the vault key under which an access key's private key is | ||
| // stored, scoped beneath its tenant. | ||
| func AccessKeyPath(tenantID, accessKeyID did.DID) string { | ||
| return TenantKeyPath(tenantID) + "/access/" + accessKeyID.String() |
There was a problem hiding this comment.
As I commented in one of the other requests, can we use a more specific name than "access"?
| return TenantKeyPath(tenantID) + "/access/" + accessKeyID.String() | |
| return TenantKeyPath(tenantID) + "/access-key/" + accessKeyID.String() |
| ok, err := ListBuckets(req.Context(), log, accessKeys, tenants, buckets, providers, secrets, req.Invocation().Issuer(), req.Task().Arguments()) | ||
| if err != nil { | ||
| log.Error("list buckets failed", zap.Error(err)) | ||
| return res.SetFailure(err) |
There was a problem hiding this comment.
Note that in Ingot, we will need to distinguish different errors so that we can return a canonical S3 error response. See https://docs.aws.amazon.com/AmazonS3/latest/developerguide/ErrorResponses.html
Something to keep in mind when designing the RPC error responses in Hilt.
Implements the `/s3/request/authorize` UCAN RPC handler and refactors shared SigV4/SigV4a request authorization into an injectable service, enabling reuse across S3 RPC handlers (e.g., list buckets) and aligning permission-to-command delegation behavior across the REST and RPC surfaces. **Changes:** - Adds a new `/s3/request/authorize` handler that authenticates an S3 request, derives a verification key, and mints per-action Forge command delegations for the gateway. - Refactors request authorization into `pkg/rpc/service/auth.Authorizer` and updates existing RPC handlers (notably bucket listing) to depend on it. - Extracts/exports S3 permission mapping utilities into `pkg/s3perm` for reuse by both REST access-key creation and RPC request authorization.
Adds docs for Claude and humans. Depends on: * #16 --------- Co-authored-by: Srdjan <stankovic.srdjo@gmail.com>
Adds a client library for Hilt that Ingot can import and use. Depends on: * #16
Adds the
/s3/bucket/listhandler.Also a sigv4 and sigv4a verifier.
Depends on: