diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03ea243 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# Hilt + +Tenant-management service for the Forge network. Hilt owns tenants, their access +keys, and their buckets — plus the UCAN delegations and key material that back +them. It exposes two APIs and talks to one external service: + +- **Tenant REST API** (`pkg/api`, echo) — partner-facing CRUD for tenants and + access keys, guarded by a pre-shared partner key. +- **Hilt UCAN RPC API** (`pkg/rpc`, ucantone server mounted at `POST /`) — the + `/s3/*` commands Ingot (the S3 gateway) invokes: `/s3/request/authorize`, + `/s3/bucket/{create,delete,info,list}`. +- **Sprue** (the Forge upload service) — Hilt calls it to provision/inspect a + bucket's storage space (`pkg/client`). + +Module: `github.com/fil-forge/hilt` (Go 1.26). Sibling repos it builds on: +`ucantone` (UCAN primitives: `did`, `multikey`, `ucan/delegation`, `binding`, +`server`, `execution`), `libforge` (bound `commands/*`, `identity`, ucan helpers), +and `sprue` (the upload service; mirror its patterns where relevant). + +## Commands + +- Build / vet / test: `go build ./... && go vet ./... && go test ./...`. Run all + three after changes — this is the standard loop. +- Run locally: `go run ./cmd serve` (flags: `--storage=memory --vault=memory` to + avoid external deps; see `cmd/main.go` / `pkg/config`). +- Postgres and Vault-backed tests use testcontainers and **skip when Docker is + unavailable** (`internal/testutil`). `go test ./...` passes without Docker but + only exercises the memory backends; run with Docker for full coverage. +- Editor/LSP diagnostics can lag after cross-file or cross-package edits — + `go build` / `go vet` are authoritative, prefer them over stale squiggles. + +## Layout + +- `cmd/main.go` — cobra entrypoint (`serve`). +- `pkg/fx` — uber-fx wiring. `AppModule` picks the storage (`memory`/`postgres`) + and vault (`memory`/`hashicorp`) backend from config; `ProvideConfigs` splits + `config.Config` into injectable sub-configs; handlers/services are registered + here. DI is **by type** — a constructor just declares the deps it needs and the + provider must exist in the graph. +- `pkg/config` — viper config: file + `HILT_` env prefix (`.`→`_`) + cobra flags. +- `pkg/api` — Tenant REST handlers + the partner-key auth middleware. +- `pkg/rpc` — UCAN S3 command handlers; `pkg/rpc/service/auth` is the shared + `Authorizer` service. +- `pkg/sigv4` — stdlib-only SigV4 / SigV4a verification, key derivation + (`DeriveKey`), and local verification (`VerifyWithKey`). +- `pkg/s3perm` — S3-permission → Forge-command mapping (shared by `api` and `rpc`). +- `pkg/store/{tenant,accesskey,bucket,delegation,provider}` — each an interface + with `memory` and `postgres` backends. +- `pkg/vault` (`memory`, `hashicorp`) — private-key storage; `paths.go` has the + key path helpers (`TenantKeyPath`, `AccessKeyPath`). +- `pkg/client` — clients for external services (the Sprue `UploadClient`). +- `pkg/migrations` — goose SQL migrations run on startup (unless skipped). +- `internal/testutil` — test-only helpers (random DIDs/issuers, testcontainers). + +## Conventions + +- **New packages go under `pkg/`**, not `internal/` (only test helpers live in + `internal/`). +- **Stores**: an interface in `pkg/store/` with `memory` + `postgres` + implementations kept in lockstep and exercised by one backend-parametrized test + suite (`_test.go`). Add a method to all three (interface + both backends) + and cover it in that suite. +- **RPC handlers** follow one shape: a `NewHandler(logger, deps…) server.Route` + constructor that returns the libforge bound command's `.Route(...)`, whose closure + extracts `req.Invocation().Issuer()` / `req.Task().Arguments()` and delegates to an + **exported, testable** function (`ctx, logger, deps…, issuer, args`). That function + returns `(*OK, []ucan.Delegation, error)`; the closure calls `res.SetFailure(err)` + or `res.SetSuccess(ok)`, and attaches any delegation blocks via + `res.SetMetadata(container.New(container.WithDelegations(blocks...)))` (the result's + delegation map carries only CIDs — the blocks ride back in the container). +- **Use libforge bound commands** (`.Command`, `.Route`, `.Invoke`, `.Unpack`) — do + not hand-write command strings with `command.MustParse`. +- **Authorization**: signature-bearing S3 commands authenticate via the + `auth.Authorizer` service (SigV4/SigV4a verify + time bounds + issuer == tenant's + provider + region served by that provider). Command-specific S3-permission checks + stay in each handler. `/s3/bucket/info` is an unauthenticated lookup (no signed + request). +- **Identities & keys**: tenants are secp256k1 → did:plc; access keys and buckets + are ed25519 → did:key. Build issuers with `multikey.NewIssuer(did, signer)`. Bucket + keys are **ephemeral** — used once to sign the bucket→tenant root delegation, then + discarded (never vaulted). Delegations are issued with `ucan/delegation.Delegate`; + proof chains come from `delegation.Store.ProofChain`. +- **Terminology**: a UCAN delegation is *issued* (or *re-delegated*), never *minted*. + Use "issue" in prose, comments, and commit messages. +- **Config**: surface new settings as a sub-config field, wire it through + `pkg/fx/config.go` `ProvideConfigs`, and add a cobra flag in `cmd/main.go`. +- **fx graph validation**: when a config change alters which modules `AppModule` + (`pkg/fx/app.go`) wires — a new backend or any config-driven module selection — + add a case to `pkg/fx/app_test.go` covering each permutation, asserting + `fx.ValidateApp(appfx.AppModule(cfg), fx.NopLogger)` returns no error (and errors + for an invalid/unknown selection). This proves every module combination yields a + graph with all dependencies satisfied, without starting the app. + +## Security + +Key material is sensitive. **Never log or echo private keys, secrets, or seed +material — log DIDs only.** The vault stores raw key bytes; only DIDs and CIDs +should appear in logs, errors, or test output. Secret-bearing CLI flags (vault +token, AppRole secret, partner key, etc.) warn to prefer `HILT_*` env vars or the +config file over process args; keep that guidance. Use placeholders (never real +values) for keys/tokens in examples and generated config. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 5af7071..22cf4ea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,95 @@ # 🗡️ Hilt Service for managing tenants of Ingot and their secret keys. Hilt implements the Tenant API, provides a UCAN API for retrieving proof chains for invocations into the Forge network and speaks to the Forge upload service. + +## Configuration + +`hilt serve` is configured from three sources, highest precedence first: +**command-line flag → `HILT_*` environment variable → config file → built-in +default**. The config file is YAML, selected with `-c/--config` (default: a +`config.yaml` in the working directory, then `/etc/hilt/config.yaml`). Every key +has an env var: `HILT_` + the key uppercased with `.` replaced by `_` (e.g. +`storage.postgres.dsn` → `HILT_STORAGE_POSTGRES_DSN`). Config-file keys are the +dotted paths below (nested YAML), e.g. `storage: { postgres: { dsn: ... } }`. + +Secrets (partner key, Vault token, AppRole secret ID) should be provided via env +var or config file, **not** flags, to avoid exposing them in process args. + +### Identity (UCAN RPC service identity) + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `identity.key_file` | `--identity-key-file` | `HILT_IDENTITY_KEY_FILE` | _(ephemeral key)_ | +| `identity.service_id` | `--identity-service-id` | `HILT_IDENTITY_SERVICE_ID` | _(key's did:key)_ | + +`key_file` is a PEM-encoded Ed25519 key; when unset an ephemeral key is generated +(its DID changes each restart). `service_id` optionally wraps the key with a +`did:web` (e.g. `did:web:hilt.example.com`). + +### Server + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `server.host` | `--host` | `HILT_SERVER_HOST` | `127.0.0.1` | +| `server.port` | `--port` | `HILT_SERVER_PORT` | `8080` | + +### Logging + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `log.level` | _(none)_ | `HILT_LOG_LEVEL` | `info` | + +### Storage + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `storage.type` | `--storage` | `HILT_STORAGE_TYPE` | `postgres` | +| `storage.postgres.dsn` | `--postgres-dsn` | `HILT_STORAGE_POSTGRES_DSN` | `postgres://hilt:hilt@localhost:5432/hilt?sslmode=disable` | +| `storage.postgres.max_conns` | _(none)_ | `HILT_STORAGE_POSTGRES_MAX_CONNS` | `10` | +| `storage.postgres.min_conns` | _(none)_ | `HILT_STORAGE_POSTGRES_MIN_CONNS` | `0` | +| `storage.postgres.skip_migrations` | `--skip-migrations` | `HILT_STORAGE_POSTGRES_SKIP_MIGRATIONS` | `false` | + +`storage.type` is `postgres` or `memory`. Postgres keys apply when +`type=postgres`; migrations run on startup unless `skip_migrations` is set. + +### Vault (private-key storage) + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `vault.type` | `--vault` | `HILT_VAULT_TYPE` | `hashicorp` | +| `vault.hashicorp.address` | `--hashicorp-address` | `HILT_VAULT_HASHICORP_ADDRESS` | `http://127.0.0.1:8200` | +| `vault.hashicorp.mount` | `--hashicorp-mount` | `HILT_VAULT_HASHICORP_MOUNT` | `secret` | +| `vault.hashicorp.auth_method` | `--hashicorp-auth-method` | `HILT_VAULT_HASHICORP_AUTH_METHOD` | `approle` | +| `vault.hashicorp.token` | `--hashicorp-token` | `HILT_VAULT_HASHICORP_TOKEN` | _(none)_ — **secret** | +| `vault.hashicorp.approle.role_id` | `--hashicorp-approle-role-id` | `HILT_VAULT_HASHICORP_APPROLE_ROLE_ID` | _(none)_ | +| `vault.hashicorp.approle.secret_id` | `--hashicorp-approle-secret-id` | `HILT_VAULT_HASHICORP_APPROLE_SECRET_ID` | _(none)_ — **secret** | +| `vault.hashicorp.approle.mount` | `--hashicorp-approle-mount` | `HILT_VAULT_HASHICORP_APPROLE_MOUNT` | `approle` | + +`vault.type` is `hashicorp` or `memory`. HashiCorp keys apply when +`type=hashicorp`; `auth_method` is `approle` or `token` (use `token` with +`vault.hashicorp.token`, or `approle` with the role/secret IDs). + +### PLC directory + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `plc.directory` | `--plc-directory` | `HILT_PLC_DIRECTORY` | `https://plc.directory` | + +### Tenant API auth + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `auth.partner_key` | `--partner-key` | `HILT_AUTH_PARTNER_KEY` | _(none)_ — **secret** | + +Pre-shared bearer token required on Tenant API requests. + +### Upload service (Sprue) + +| Key | Flag | Env var | Default | +| --- | --- | --- | --- | +| `upload.service_id` | `--upload-service-id` | `HILT_UPLOAD_SERVICE_ID` | `did:web:upload.forgery.network` | +| `upload.service_url` | `--upload-service-url` | `HILT_UPLOAD_SERVICE_URL` | `https://upload.forgery.network` | +| `upload.product_id` | `--upload-product-id` | `HILT_UPLOAD_PRODUCT_ID` | `did:web:hilt.forgery.network` | + +The Sprue service DID + HTTP endpoint Hilt calls to provision bucket space, and +the product/plan DID tenants are registered under. diff --git a/cmd/main.go b/cmd/main.go index ee83b8c..ad0ba79 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -57,6 +57,12 @@ func main() { // auth config serveCmd.Flags().String("partner-key", "", "partner bearer key required on Tenant API requests (prefer HILT_AUTH_PARTNER_KEY env var or config file to avoid exposing via process args)") + // upload service config + serveCmd.Flags().String("upload-service-id", "did:web:upload.forgery.network", "Upload service DID") + serveCmd.Flags().String("upload-service-url", "https://upload.forgery.network", "Upload service HTTP endpoint") + serveCmd.Flags().String("upload-product-id", "did:web:hilt.forgery.network", "Upload service product/plan DID that tenants are registered under") + serveCmd.Flags().String("upload-proofs", "", "Upload service proofs: an encoded UCAN container or a path to a file containing one") + rootCmd.AddCommand(serveCmd) rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (default: looks for config.yaml in current dir)") diff --git a/internal/testutil/alias.go b/internal/testutil/alias.go deleted file mode 100644 index 9edd4e3..0000000 --- a/internal/testutil/alias.go +++ /dev/null @@ -1,27 +0,0 @@ -package testutil - -import ( - "testing" - - "github.com/fil-forge/libforge/testutil" - "github.com/ipfs/go-cid" -) - -var ( - Alice = testutil.Alice - Bob = testutil.Bob - Carol = testutil.Carol - Mallory = testutil.Mallory - RandomBytes = testutil.RandomBytes - RandomDID = testutil.RandomDID - RandomSigner = testutil.RandomSigner - RandomIssuer = testutil.RandomIssuer -) - -func RandomCID(t *testing.T) cid.Cid { - return testutil.RandomCID(t) -} - -func Must[T any](val T, err error) func(*testing.T) T { - return testutil.Must(val, err) -} diff --git a/pkg/api/access_keys.go b/pkg/api/access_keys.go index 75de2a9..47c837c 100644 --- a/pkg/api/access_keys.go +++ b/pkg/api/access_keys.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" + "github.com/fil-forge/hilt/pkg/s3perm" "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/accesskey" "github.com/fil-forge/hilt/pkg/store/bucket" @@ -24,18 +25,6 @@ import ( const maxAccessKeyNameLength = 100 -// vaultTenantKeyPath is the vault key under which a tenant's private key is -// stored. -func vaultTenantKeyPath(tenantDID did.DID) string { - return "/tenant/" + tenantDID.String() -} - -// vaultAccessKeyPath is the vault key under which an access key's private key is -// stored. It MUST match the path used by the tenant delete cascade. -func vaultAccessKeyPath(tenantDID, accessKeyDID did.DID) string { - return vaultTenantKeyPath(tenantDID) + "/access/" + accessKeyDID.String() -} - // NewCreateAccessKeyHandler handles POST /tenants/{tenantId}/access-keys — // create an S3 access-key pair (returns the secret once only) and issue the // tenant→access-key UCAN delegations for the requested permissions. @@ -62,7 +51,7 @@ func NewCreateAccessKeyHandler( return echo.NewHTTPError(http.StatusUnprocessableEntity, "at least one permission is required") } for _, p := range req.Permissions { - if !validS3Permission(p) { + if !s3perm.Valid(p) { return echo.NewHTTPError(http.StatusUnprocessableEntity, "unknown permission: "+p) } } @@ -78,7 +67,7 @@ func NewCreateAccessKeyHandler( // Load the tenant signer up front: it is required to issue delegations and // its absence is unrecoverable, so fail before creating any state. - tenantKeyBytes, err := secrets.Read(ctx, vaultTenantKeyPath(tenantRec.ID)) + tenantKeyBytes, err := secrets.Read(ctx, vault.TenantKeyPath(tenantRec.ID)) if err != nil { log.Error("reading tenant key", zap.Error(err)) return echo.NewHTTPError(http.StatusInternalServerError, "internal error") @@ -131,22 +120,26 @@ func NewCreateAccessKeyHandler( } log = log.With(zap.Stringer("access_key", accessKeyDID)) - vaultPath := vaultAccessKeyPath(tenantRec.ID, accessKeyDID) + vaultPath := vault.AccessKeyPath(tenantRec.ID, accessKeyDID) if err := secrets.Write(ctx, vaultPath, signer.Bytes()); err != nil { log.Error("storing access key", zap.Error(err)) return echo.NewHTTPError(http.StatusInternalServerError, "internal error") } // Best-effort rollback of the (idempotent) state created below, so a - // partial failure leaves nothing behind and is retryable. + // partial failure leaves nothing behind and is retryable. Cleanup runs on a + // context detached from the request (values retained, cancellation/deadline + // dropped) so a client disconnect — which cancels ctx — cannot abort the + // rollback partway and leave orphaned state. rollback := func() { - if err := delegations.DeleteByAudience(ctx, accessKeyDID); err != nil { + cleanupCtx := context.WithoutCancel(ctx) + if err := delegations.DeleteByAudience(cleanupCtx, accessKeyDID); err != nil { log.Warn("rollback: deleting delegations", zap.Error(err)) } - if err := accessKeys.Delete(ctx, accessKeyDID); err != nil { + if err := accessKeys.Delete(cleanupCtx, accessKeyDID); err != nil { log.Warn("rollback: deleting access key", zap.Error(err)) } - if err := secrets.Delete(ctx, vaultPath); err != nil { + if err := secrets.Delete(cleanupCtx, vaultPath); err != nil { log.Warn("rollback: deleting access key from vault", zap.Error(err)) } } @@ -169,7 +162,7 @@ func NewCreateAccessKeyHandler( } var dels []ucan.Delegation for _, sub := range subjects { - for _, cmd := range commandsForPermissions(req.Permissions) { + for _, cmd := range s3perm.CommandsFor(req.Permissions...) { d, err := delegation.Delegate(issuer, accessKeyDID, sub, cmd, opts...) if err != nil { rollback() @@ -340,7 +333,7 @@ func NewDeleteAccessKeyHandler( log.Error("deleting access key delegations", zap.Error(err)) return echo.NewHTTPError(http.StatusInternalServerError, "internal error") } - if err := secrets.Delete(ctx, vaultAccessKeyPath(tenantRec.ID, accessKeyDID)); err != nil { + if err := secrets.Delete(ctx, vault.AccessKeyPath(tenantRec.ID, accessKeyDID)); err != nil { log.Warn("removing access key from vault", zap.Error(err)) } if err := accessKeys.Delete(ctx, accessKeyDID); err != nil { diff --git a/pkg/api/access_keys_test.go b/pkg/api/access_keys_test.go index 72c965a..cb0d59a 100644 --- a/pkg/api/access_keys_test.go +++ b/pkg/api/access_keys_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/api" "github.com/fil-forge/hilt/pkg/store" accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" @@ -18,6 +17,7 @@ import ( tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" "github.com/fil-forge/hilt/pkg/vault" vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "github.com/fil-forge/libforge/testutil" "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/did/plc" "github.com/fil-forge/ucantone/multikey/secp256k1" @@ -119,7 +119,7 @@ func TestCreateAccessKeyHandler(t *testing.T) { require.Equal(t, []did.DID{deps.bucketID}, storedRec.Buckets) // Private key in the vault. - _, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()+"/access/"+akDID.String()) + _, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()+"/access-key/"+akDID.String()) require.NoError(t, err) // 4 delegations: /content/retrieve + /blob/add + /index/add + /upload/add, diff --git a/pkg/api/tenants.go b/pkg/api/tenants.go index 8775189..be44f8b 100644 --- a/pkg/api/tenants.go +++ b/pkg/api/tenants.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" + "github.com/fil-forge/hilt/pkg/client" "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/accesskey" "github.com/fil-forge/hilt/pkg/store/bucket" @@ -30,6 +31,7 @@ func NewProvisionTenantHandler( providers provider.Store, secrets vault.Vault, plcClient *plc.DirectoryClient, + upload *client.UploadClient, ) Route { log := logger.With(zap.String("handler", "ProvisionTenant")) return NewRoute(http.MethodPut, "/tenants/:tenantId", func(c echo.Context) error { @@ -90,7 +92,7 @@ func NewProvisionTenantHandler( // Persist the private key before publishing so it is never lost. Store // the multiformat-tagged bytes (signer.Bytes()) so the key type is // recoverable on decode rather than assuming secp256k1. - vaultKey := vaultTenantKeyPath(tenantID) + vaultKey := vault.TenantKeyPath(tenantID) if err := secrets.Write(ctx, vaultKey, signer.Bytes()); err != nil { log.Error("storing tenant key", zap.Error(err)) return echo.NewHTTPError(http.StatusInternalServerError, "internal error") @@ -99,14 +101,34 @@ func NewProvisionTenantHandler( // Publish the genesis operation to register the did:plc. if err := plcClient.Update(ctx, tenantID, genesis); err != nil { log.Error("publishing genesis operation", zap.Error(err)) - _ = secrets.Delete(ctx, vaultKey) // best-effort cleanup of the orphaned key + // Detached context so a client disconnect can't cancel the cleanup. + if err := secrets.Delete(context.WithoutCancel(ctx), vaultKey); err != nil { + log.Error("cleaning up orphaned tenant key", zap.Error(err)) + } return echo.NewHTTPError(http.StatusBadGateway, "failed to register tenant DID") } + // Register the tenant as a customer with the upload service (Sprue). Done + // before recording the tenant so a failed registration returns an error + // and is retried on the next call, rather than being short-circuited by + // the idempotency check above (which keys on the stored tenant record). + details := map[string]string{"external_id": externalID, "region": req.Region} + if err := upload.RegisterCustomer(ctx, tenantID, upload.Product, details); err != nil { + log.Error("registering tenant with upload service", zap.Error(err)) + // Detached context so a client disconnect can't cancel the cleanup. + if err := secrets.Delete(context.WithoutCancel(ctx), vaultKey); err != nil { + log.Error("cleaning up orphaned tenant key", zap.Error(err)) + } + return echo.NewHTTPError(http.StatusBadGateway, "failed to register tenant with upload service") + } + // Record the tenant. if err := tenants.Add(ctx, tenantID, externalID, prov.ID, req.DisplayName, tenant.Active); err != nil { if errors.Is(err, store.ErrRecordExists) { - _ = secrets.Delete(ctx, vaultKey) // best-effort cleanup of the orphaned key + // Detached context so a client disconnect can't cancel the cleanup. + if err := secrets.Delete(context.WithoutCancel(ctx), vaultKey); err != nil { + log.Error("cleaning up orphaned tenant key", zap.Error(err)) + } // Concurrent create with the same external id: return the winner. if rec, gerr := tenants.GetByExternalID(ctx, externalID); gerr == nil { return c.JSON(http.StatusOK, tenantResponse(rec)) @@ -220,7 +242,7 @@ func NewDeleteTenantHandler( return echo.NewHTTPError(http.StatusConflict, "tenant must be disabled before deletion") } - tenantKey := vaultTenantKeyPath(rec.ID) + tenantKey := vault.TenantKeyPath(rec.ID) // Deactivate the did:plc first — it requires the (still-present) tenant // key. Aborting here leaves all local state intact for a retry. @@ -240,7 +262,7 @@ func NewDeleteTenantHandler( log.Error("deleting access key delegations", zap.Error(err)) return echo.NewHTTPError(http.StatusInternalServerError, "internal error") } - if err := secrets.Delete(ctx, vaultAccessKeyPath(rec.ID, ak.ID)); err != nil { + if err := secrets.Delete(ctx, vault.AccessKeyPath(rec.ID, ak.ID)); err != nil { log.Warn("removing access key from vault", zap.Error(err)) } if err := accessKeys.Delete(ctx, ak.ID); err != nil { diff --git a/pkg/api/tenants_test.go b/pkg/api/tenants_test.go index d0415f9..a617ea3 100644 --- a/pkg/api/tenants_test.go +++ b/pkg/api/tenants_test.go @@ -3,13 +3,14 @@ package api_test import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" "net/url" "testing" - "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/api" + "github.com/fil-forge/hilt/pkg/client" "github.com/fil-forge/hilt/pkg/store" accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" @@ -20,11 +21,17 @@ import ( tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" "github.com/fil-forge/hilt/pkg/vault" vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + customercmds "github.com/fil-forge/libforge/commands/customer" + "github.com/fil-forge/libforge/testutil" + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/binding" "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/did/plc" "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/server" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/container" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" @@ -36,17 +43,25 @@ type provisionDeps struct { providers provider.Store vault vault.Vault plcPosts int + + // Sprue (upload service) stub state. + product did.DID + customerAdds int + lastAddArgs *customercmds.AddArguments + sprueFailure bool // when true the stub /customer/add handler returns a failure } // setupProvision builds an echo server with the provision handler wired to -// memory stores/vault and a PLC directory client pointed at an httptest server -// that accepts genesis operations (no real PLC network). +// memory stores/vault, a PLC directory client pointed at an httptest server that +// accepts genesis operations, and an upload client pointed at an in-process +// Sprue stub that handles /customer/add (no real PLC or Sprue network). func setupProvision(t *testing.T) (*echo.Echo, *provisionDeps) { t.Helper() deps := &provisionDeps{ tenants: tenantmemory.New(), providers: providermemory.New(), vault: vaultmemory.New(), + product: testutil.RandomDID(t), } plcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -62,7 +77,33 @@ func setupProvision(t *testing.T) (*echo.Echo, *provisionDeps) { plcClient, err := plc.NewDirectoryClient(*endpoint) require.NoError(t, err) - route := api.NewProvisionTenantHandler(zap.NewNop(), deps.tenants, deps.providers, deps.vault, plcClient) + // Sprue stub: Hilt (the client's issuer) holds a /customer/add delegation + // from Sprue, and the in-process server records each invocation. + sprue := testutil.RandomIssuer(t) + hilt := testutil.RandomIssuer(t) + dlg, err := customercmds.Add.Delegate(sprue, hilt.DID(), sprue.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + srv := server.NewHTTP(sprue) + srv.Handle(customercmds.Add.Command, customercmds.Add.Handler( + func(req *binding.Request[*customercmds.AddArguments], res *binding.Response[*customercmds.AddOK]) error { + deps.customerAdds++ + deps.lastAddArgs = req.Task().Arguments() + if deps.sprueFailure { + return res.SetFailure(errors.New("sprue rejected")) + } + return res.SetSuccess(&customercmds.AddOK{}) + })) + + sprueURL, err := url.Parse("http://sprue.test") + require.NoError(t, err) + upload, err := client.NewUploadClient(sprue.DID(), *sprueURL, hilt, proofs, + client.WithProduct(deps.product), + client.WithHTTPClient(&http.Client{Transport: srv})) + require.NoError(t, err) + + route := api.NewProvisionTenantHandler(zap.NewNop(), deps.tenants, deps.providers, deps.vault, plcClient, upload) e := echo.New() e.Add(route.Method, route.Path, route.Handler) return e, deps @@ -103,6 +144,14 @@ func TestProvisionTenantHandler(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, key) require.Equal(t, 1, deps.plcPosts) + + // The tenant was registered as a customer with Sprue, keyed by its + // did:plc, under the configured product, with the tenant details. + require.Equal(t, 1, deps.customerAdds) + require.NotNil(t, deps.lastAddArgs) + require.Equal(t, stored.ID, deps.lastAddArgs.Customer) + require.Equal(t, deps.product, deps.lastAddArgs.Product) + require.Equal(t, map[string]string{"external_id": "tenant-1", "region": "us-east-1"}, deps.lastAddArgs.Details) }) t.Run("is idempotent on the external id", func(t *testing.T) { @@ -117,13 +166,29 @@ func TestProvisionTenantHandler(t *testing.T) { second := provisionRequest(t, e, "tenant-2", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"}) require.Equal(t, http.StatusOK, second.Code) - // No new key minted/published on the idempotent call. + // No new key minted/published, and no re-registration, on the idempotent call. require.Equal(t, 1, deps.plcPosts) + require.Equal(t, 1, deps.customerAdds) again, err := deps.tenants.GetByExternalID(ctx, "tenant-2") require.NoError(t, err) require.Equal(t, stored.ID, again.ID) }) + t.Run("upload service failure aborts provisioning", func(t *testing.T) { + e, deps := setupProvision(t) + require.NoError(t, deps.providers.Add(ctx, testutil.RandomDID(t), "us-east-1")) + deps.sprueFailure = true + + rec := provisionRequest(t, e, "tenant-6", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"}) + require.Equal(t, http.StatusBadGateway, rec.Code) + + // Registration was attempted but no tenant record was written, so the + // operation is retryable. + require.Equal(t, 1, deps.customerAdds) + _, err := deps.tenants.GetByExternalID(ctx, "tenant-6") + require.ErrorIs(t, err, store.ErrRecordNotFound) + }) + t.Run("unknown region is rejected", func(t *testing.T) { e, _ := setupProvision(t) rec := provisionRequest(t, e, "tenant-3", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "nowhere"}) @@ -340,7 +405,7 @@ func TestDeleteTenantHandler(t *testing.T) { require.NoError(t, deps.buckets.Add(ctx, bucketID, deps.tenantID, "b1")) akID := testutil.RandomDID(t) require.NoError(t, deps.accessKeys.Add(ctx, akID, deps.tenantID, "k1", nil, []string{"s3:GetObject"}, nil)) - akVaultKey := "/tenant/" + deps.tenantID.String() + "/access/" + akID.String() + akVaultKey := "/tenant/" + deps.tenantID.String() + "/access-key/" + akID.String() require.NoError(t, deps.vault.Write(ctx, akVaultKey, []byte("ak-key"))) require.NoError(t, deps.delegations.PutBatch(ctx, []ucan.Delegation{makeDelegation(t, deps.tenantID)})) require.NoError(t, deps.delegations.PutBatch(ctx, []ucan.Delegation{makeDelegation(t, akID)})) diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..64ec1db --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,137 @@ +package client + +import ( + "context" + "fmt" + "net/url" + + "github.com/fil-forge/hilt/pkg/lib/zapucan" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + s3req "github.com/fil-forge/libforge/commands/s3/request" + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/client" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/invocation" + "go.uber.org/zap" +) + +// Client invokes Hilt's S3 UCAN RPC commands (the caller is typically Ingot). +// Construct it with [New]; each method invokes one /s3/* command. Commands whose +// result carries re-delegated proof chains (AuthorizeRequest, CreateBucket, +// BucketInfo) also return the response container so the caller can extract the +// delegation blocks via [ucan.Container.Delegations]. +type Client struct { + ServiceID did.DID // Hilt's DID (invocation subject + audience) + Issuer ucan.Issuer // default invocation issuer (e.g. Ingot) + Proofs ucanlib.ProofStore // supplies the Hilt→issuer proof chains + Executor execution.Executor + Logger *zap.Logger +} + +// New creates a Client for Hilt's UCAN RPC API at serviceURL, identified by +// serviceID (Hilt's DID). issuer signs invocations and proofs supplies the +// delegation chains from Hilt to the issuer; both are defaults, overridable per +// call with [WithIssuer] / [WithProofs]. +func New(serviceID did.DID, serviceURL url.URL, issuer ucan.Issuer, proofs ucanlib.ProofStore, opts ...Option) (*Client, error) { + cfg := &clientConfig{logger: zap.NewNop()} + for _, opt := range opts { + opt(cfg) + } + + var httpExecutor execution.Executor + var err error + if cfg.httpClient != nil { + httpExecutor, err = client.NewHTTP(&serviceURL, client.WithHTTPClient(cfg.httpClient)) + } else { + httpExecutor, err = client.NewHTTP(&serviceURL) + } + if err != nil { + return nil, fmt.Errorf("creating HTTP executor: %w", err) + } + + if issuer == nil { + return nil, fmt.Errorf("issuer is required") + } + if proofs == nil { + proofs = ucanlib.NewContainerProofStore(container.New()) + } + + return &Client{ + ServiceID: serviceID, + Issuer: issuer, + Proofs: proofs, + Executor: httpExecutor, + Logger: cfg.logger, + }, nil +} + +// AuthorizeRequest invokes /s3/request/authorize. The returned container carries +// the delegations Hilt re-delegated to the invocation issuer. +func (c *Client) AuthorizeRequest(ctx context.Context, req s3.Request, opts ...MethodOption) (*s3req.AuthorizeOK, ucan.Container, error) { + return invoke(ctx, c, s3req.Authorize, &s3req.AuthorizeArguments{Request: req}, opts...) +} + +// CreateBucket invokes /s3/bucket/create. The returned container carries the +// delegation chains that now grant the access key access to the new bucket. +func (c *Client) CreateBucket(ctx context.Context, req s3.Request, opts ...MethodOption) (*s3req.AuthorizeOK, ucan.Container, error) { + return invoke(ctx, c, s3bkt.Create, &s3bkt.CreateArguments{Request: req}, opts...) +} + +// BucketInfo invokes /s3/bucket/info for the named bucket and access key. The +// returned container carries the bucket→access-key delegation chains. +func (c *Client) BucketInfo(ctx context.Context, name string, accessKey did.DID, opts ...MethodOption) (*s3bkt.InfoOK, ucan.Container, error) { + return invoke(ctx, c, s3bkt.Info, &s3bkt.InfoArguments{Name: name, AccessKey: accessKey}, opts...) +} + +// DeleteBucket invokes /s3/bucket/delete. It returns no delegations. +func (c *Client) DeleteBucket(ctx context.Context, req s3.Request, opts ...MethodOption) error { + _, _, err := invoke(ctx, c, s3bkt.Delete, &s3bkt.DeleteArguments{Request: req}, opts...) + return err +} + +// ListBuckets invokes /s3/bucket/list. It returns no delegations. +func (c *Client) ListBuckets(ctx context.Context, req s3.Request, opts ...MethodOption) (*s3bkt.ListOK, error) { + ok, _, err := invoke(ctx, c, s3bkt.List, &s3bkt.ListArguments{Request: req}, opts...) + return ok, err +} + +// invoke runs one command: it fetches the proof chain from the issuer to the Hilt +// service, signs and sends the invocation (subject = audience = Hilt), and +// unpacks the receipt. It returns the typed result and the response container so +// callers can extract any delegations Hilt attached. +func invoke[A, O binding.CBORValue](ctx context.Context, c *Client, cmd binding.Binding[A, O], args A, opts ...MethodOption) (O, ucan.Container, error) { + var zero O + cfg := &methodConfig{issuer: c.Issuer, proofs: c.Proofs} + for _, opt := range opts { + opt(cfg) + } + + proofs, links, err := cfg.proofs.ProofChain(ctx, cfg.issuer.DID(), cmd.Command, c.ServiceID) + if err != nil { + return zero, nil, fmt.Errorf("getting proof chain: %w", err) + } + inv, err := cmd.Invoke(cfg.issuer, c.ServiceID, args, + invocation.WithAudience(c.ServiceID), + invocation.WithProofs(links...), + ) + if err != nil { + return zero, nil, fmt.Errorf("invoking %s: %w", cmd.Command, err) + } + log := zapucan.WithInvocation(c.Logger, inv) + log.Debug("executing invocation") + res, err := c.Executor.Execute(execution.NewRequest(ctx, inv, execution.WithDelegations(proofs...))) + if err != nil { + log.Error("failed to execute invocation", zap.Error(err)) + return zero, nil, fmt.Errorf("executing %s invocation: %w", cmd.Command, err) + } + ok, err := cmd.Unpack(res.Receipt()) + if err != nil { + return zero, nil, fmt.Errorf("unpacking %s result: %w", cmd.Command, err) + } + return ok, res.Metadata(), nil +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..14bbb29 --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,199 @@ +package client_test + +import ( + "errors" + "net/http" + "net/url" + "testing" + + "github.com/fil-forge/hilt/pkg/client" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + s3req "github.com/fil-forge/libforge/commands/s3/request" + "github.com/fil-forge/libforge/testutil" + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/stretchr/testify/require" +) + +// newHiltClient builds a Client whose transport is the given in-process server, +// exercising New itself. +func newHiltClient(t *testing.T, hilt ucan.Issuer, srv *server.HTTPServer, issuer ucan.Issuer, proofs ucanlib.ProofStore) *client.Client { + t.Helper() + u, err := url.Parse("http://hilt.test") + require.NoError(t, err) + c, err := client.New(hilt.DID(), *u, issuer, proofs, client.WithHTTPClient(&http.Client{Transport: srv})) + require.NoError(t, err) + return c +} + +// rootProofs returns a proof store holding a root delegation from hilt to issuer +// for cmd (subject == issuer == hilt), authorizing issuer to invoke cmd on hilt. +func rootProofs[A, O binding.CBORValue](t *testing.T, cmd binding.Binding[A, O], hilt ucan.Issuer, issuer did.DID) ucanlib.ProofStore { + t.Helper() + dlg, err := cmd.Delegate(hilt, issuer, hilt.DID()) + require.NoError(t, err) + return ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) +} + +func TestClientAuthorizeRequest(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + bucketDID := testutil.RandomDID(t) + + // A delegation Hilt attaches to the response for the caller to extract. + attached, err := s3req.Authorize.Delegate(hilt, ingot.DID(), hilt.DID()) + require.NoError(t, err) + + var gotSub, gotAud did.DID + srv := server.NewHTTP(hilt) + srv.Handle(s3req.Authorize.Command, s3req.Authorize.Handler( + func(req *binding.Request[*s3req.AuthorizeArguments], res *binding.Response[*s3req.AuthorizeOK]) error { + gotSub, gotAud = req.Invocation().Subject(), req.Invocation().Audience() + if err := res.SetMetadata(container.New(container.WithDelegations(attached))); err != nil { + return err + } + return res.SetSuccess(&s3req.AuthorizeOK{Bucket: bucketDID}) + })) + + c := newHiltClient(t, hilt, srv, ingot, rootProofs(t, s3req.Authorize, hilt, ingot.DID())) + ok, ctr, err := c.AuthorizeRequest(t.Context(), s3.Request{Method: "GET", URL: "https://s3.fil.one/bucket/key"}) + require.NoError(t, err) + + require.Equal(t, bucketDID, ok.Bucket) + require.Equal(t, hilt.DID(), gotSub) + require.Equal(t, hilt.DID(), gotAud) + _, found := ctr.Delegation(attached.Link()) + require.True(t, found, "response container should carry the attached delegation") +} + +func TestClientCreateBucket(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + bucketDID := testutil.RandomDID(t) + + attached, err := s3bkt.Create.Delegate(hilt, ingot.DID(), hilt.DID()) + require.NoError(t, err) + + srv := server.NewHTTP(hilt) + srv.Handle(s3bkt.Create.Command, s3bkt.Create.Handler( + func(req *binding.Request[*s3bkt.CreateArguments], res *binding.Response[*s3req.AuthorizeOK]) error { + if err := res.SetMetadata(container.New(container.WithDelegations(attached))); err != nil { + return err + } + return res.SetSuccess(&s3req.AuthorizeOK{Bucket: bucketDID}) + })) + + c := newHiltClient(t, hilt, srv, ingot, rootProofs(t, s3bkt.Create, hilt, ingot.DID())) + ok, ctr, err := c.CreateBucket(t.Context(), s3.Request{Method: "PUT", URL: "https://s3.fil.one/bucket"}) + require.NoError(t, err) + + require.Equal(t, bucketDID, ok.Bucket) + _, found := ctr.Delegation(attached.Link()) + require.True(t, found) +} + +func TestClientBucketInfo(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + bucketDID := testutil.RandomDID(t) + akDID := testutil.RandomDID(t) + + attached, err := s3bkt.Info.Delegate(hilt, ingot.DID(), hilt.DID()) + require.NoError(t, err) + + var gotArgs *s3bkt.InfoArguments + srv := server.NewHTTP(hilt) + srv.Handle(s3bkt.Info.Command, s3bkt.Info.Handler( + func(req *binding.Request[*s3bkt.InfoArguments], res *binding.Response[*s3bkt.InfoOK]) error { + gotArgs = req.Task().Arguments() + if err := res.SetMetadata(container.New(container.WithDelegations(attached))); err != nil { + return err + } + return res.SetSuccess(&s3bkt.InfoOK{ID: bucketDID}) + })) + + c := newHiltClient(t, hilt, srv, ingot, rootProofs(t, s3bkt.Info, hilt, ingot.DID())) + ok, ctr, err := c.BucketInfo(t.Context(), "mybucket", akDID) + require.NoError(t, err) + + require.Equal(t, bucketDID, ok.ID) + require.Equal(t, "mybucket", gotArgs.Name) + require.Equal(t, akDID, gotArgs.AccessKey) + _, found := ctr.Delegation(attached.Link()) + require.True(t, found) +} + +func TestClientListBuckets(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + + srv := server.NewHTTP(hilt) + srv.Handle(s3bkt.List.Command, s3bkt.List.Handler( + func(req *binding.Request[*s3bkt.ListArguments], res *binding.Response[*s3bkt.ListOK]) error { + return res.SetSuccess(&s3bkt.ListOK{Owner: s3bkt.Owner{DisplayName: "Acme"}}) + })) + + c := newHiltClient(t, hilt, srv, ingot, rootProofs(t, s3bkt.List, hilt, ingot.DID())) + ok, err := c.ListBuckets(t.Context(), s3.Request{Method: "GET", URL: "https://us-west-2.s3.fil.one/"}) + require.NoError(t, err) + require.Equal(t, "Acme", ok.Owner.DisplayName) +} + +func TestClientDeleteBucket(t *testing.T) { + t.Run("success", func(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + + srv := server.NewHTTP(hilt) + srv.Handle(s3bkt.Delete.Command, s3bkt.Delete.Handler( + func(req *binding.Request[*s3bkt.DeleteArguments], res *binding.Response[*s3bkt.DeleteOK]) error { + return res.SetSuccess(&s3bkt.DeleteOK{}) + })) + + c := newHiltClient(t, hilt, srv, ingot, rootProofs(t, s3bkt.Delete, hilt, ingot.DID())) + require.NoError(t, c.DeleteBucket(t.Context(), s3.Request{Method: "DELETE", URL: "https://s3.fil.one/bucket"})) + }) + + t.Run("failure receipt", func(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + + srv := server.NewHTTP(hilt) + srv.Handle(s3bkt.Delete.Command, s3bkt.Delete.Handler( + func(req *binding.Request[*s3bkt.DeleteArguments], res *binding.Response[*s3bkt.DeleteOK]) error { + return res.SetFailure(errors.New("not empty")) + })) + + c := newHiltClient(t, hilt, srv, ingot, rootProofs(t, s3bkt.Delete, hilt, ingot.DID())) + require.Error(t, c.DeleteBucket(t.Context(), s3.Request{Method: "DELETE", URL: "https://s3.fil.one/bucket"})) + }) +} + +func TestClientErrors(t *testing.T) { + hilt := testutil.RandomIssuer(t) + ingot := testutil.RandomIssuer(t) + req := s3.Request{Method: "GET", URL: "https://s3.fil.one/bucket/key"} + + t.Run("proof chain error", func(t *testing.T) { + srv := server.NewHTTP(hilt) + c := newHiltClient(t, hilt, srv, ingot, nil) + _, _, err := c.AuthorizeRequest(t.Context(), req, client.WithProofs(errProofStore{err: errors.New("boom")})) + require.Error(t, err) + require.Contains(t, err.Error(), "getting proof chain") + }) + + t.Run("execution error", func(t *testing.T) { + u, err := url.Parse("http://hilt.test") + require.NoError(t, err) + c, err := client.New(hilt.DID(), *u, ingot, rootProofs(t, s3req.Authorize, hilt, ingot.DID()), + client.WithHTTPClient(&http.Client{Transport: errRoundTripper{}})) + require.NoError(t, err) + _, _, err = c.AuthorizeRequest(t.Context(), req) + require.Error(t, err) + }) +} diff --git a/pkg/client/management/management.go b/pkg/client/management/management.go new file mode 100644 index 0000000..9fce3eb --- /dev/null +++ b/pkg/client/management/management.go @@ -0,0 +1,201 @@ +// Package management provides a REST client for Hilt's tenant and access-key +// management API (the handlers in pkg/api). It authenticates with the partner +// key as an HTTP bearer token and speaks plain JSON — it is not a UCAN client +// (cf. the UCAN clients in the parent pkg/client package). +package management + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/fil-forge/hilt/pkg/api" + "go.uber.org/zap" +) + +// Option configures a [Client]. +type Option func(*config) + +type config struct { + httpClient *http.Client + logger *zap.Logger +} + +// WithHTTPClient sets the HTTP client used for requests. A nil client is +// ignored (the default [http.DefaultClient] is kept). +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *config) { + if httpClient != nil { + cfg.httpClient = httpClient + } + } +} + +// WithLogger sets the logger. A nil logger is ignored (a no-op logger is kept). +func WithLogger(logger *zap.Logger) Option { + return func(cfg *config) { + if logger != nil { + cfg.logger = logger + } + } +} + +// Client is a REST client for the Hilt management API. +type Client struct { + baseURL url.URL + partnerKey string + httpClient *http.Client + logger *zap.Logger +} + +// NewClient creates a management API client that targets baseURL and +// authenticates with partnerKey (sent as "Authorization: Bearer "). +func NewClient(baseURL url.URL, partnerKey string, opts ...Option) *Client { + cfg := &config{httpClient: http.DefaultClient, logger: zap.NewNop()} + for _, opt := range opts { + opt(cfg) + } + return &Client{ + baseURL: baseURL, + partnerKey: partnerKey, + httpClient: cfg.httpClient, + logger: cfg.logger, + } +} + +// APIError is returned when the server responds with an unexpected status code. +// It carries the HTTP status and the server's error message so callers can +// branch on the status (e.g. 404 Not Found, 409 Conflict). +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + if e.Message == "" { + return fmt.Sprintf("management: unexpected status %d", e.StatusCode) + } + return fmt.Sprintf("management: status %d: %s", e.StatusCode, e.Message) +} + +// Tenants + +// ProvisionTenant provisions (or, idempotently, returns) the tenant with the +// given external id. +func (c *Client) ProvisionTenant(ctx context.Context, tenantID string, req api.ProvisionTenantRequest) (api.Tenant, error) { + var t api.Tenant + err := c.do(ctx, http.MethodPut, []string{"tenants", tenantID}, req, &t, http.StatusOK, http.StatusCreated) + return t, err +} + +// GetTenant retrieves the tenant with the given external id. +func (c *Client) GetTenant(ctx context.Context, tenantID string) (api.Tenant, error) { + var t api.Tenant + err := c.do(ctx, http.MethodGet, []string{"tenants", tenantID}, nil, &t, http.StatusOK) + return t, err +} + +// UpdateTenantStatus updates the access mode of the tenant. +func (c *Client) UpdateTenantStatus(ctx context.Context, tenantID string, status api.TenantStatus) error { + return c.do(ctx, http.MethodPost, []string{"tenants", tenantID, "status"}, + api.UpdateTenantStatusRequest{Status: status}, nil, http.StatusNoContent) +} + +// DeleteTenant permanently deletes the tenant. It is idempotent server-side. +func (c *Client) DeleteTenant(ctx context.Context, tenantID string) error { + return c.do(ctx, http.MethodDelete, []string{"tenants", tenantID}, nil, nil, http.StatusNoContent) +} + +// Access keys + +// CreateAccessKey creates an S3 access key for the tenant. The returned +// [api.CreatedAccessKey] is the only time the secret access key is exposed. +func (c *Client) CreateAccessKey(ctx context.Context, tenantID string, req api.CreateAccessKeyRequest) (api.CreatedAccessKey, error) { + var k api.CreatedAccessKey + err := c.do(ctx, http.MethodPost, []string{"tenants", tenantID, "access-keys"}, req, &k, http.StatusCreated) + return k, err +} + +// ListAccessKeys lists the tenant's access keys (secrets are never included). +func (c *Client) ListAccessKeys(ctx context.Context, tenantID string) ([]api.AccessKey, error) { + var list api.AccessKeyList + err := c.do(ctx, http.MethodGet, []string{"tenants", tenantID, "access-keys"}, nil, &list, http.StatusOK) + return list.Items, err +} + +// GetAccessKey retrieves metadata for a single access key. +func (c *Client) GetAccessKey(ctx context.Context, tenantID, accessKeyID string) (api.AccessKey, error) { + var k api.AccessKey + err := c.do(ctx, http.MethodGet, []string{"tenants", tenantID, "access-keys", accessKeyID}, nil, &k, http.StatusOK) + return k, err +} + +// DeleteAccessKey revokes an access key. It is idempotent server-side. +func (c *Client) DeleteAccessKey(ctx context.Context, tenantID, accessKeyID string) error { + return c.do(ctx, http.MethodDelete, []string{"tenants", tenantID, "access-keys", accessKeyID}, nil, nil, http.StatusNoContent) +} + +// do executes a single request: it builds the URL from path segments (JoinPath +// escapes them), sets auth/JSON headers, sends the (optional) JSON body, checks +// the status against wantStatus, and decodes the response into out when non-nil. +func (c *Client) do(ctx context.Context, method string, segments []string, body, out any, wantStatus ...int) error { + u := c.baseURL.JoinPath(segments...) + + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.partnerKey) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + c.logger.Debug("executing management request", zap.String("method", method), zap.String("url", u.String())) + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing %s %s: %w", method, u.String(), err) + } + defer resp.Body.Close() + + if !slices.Contains(wantStatus, resp.StatusCode) { + return apiErrorFromResponse(resp) + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + } + return nil +} + +// apiErrorFromResponse builds an [APIError] from a non-2xx response, reading the +// echo default error shape ({"message": "..."}) and falling back to the raw body. +func apiErrorFromResponse(resp *http.Response) error { + apiErr := &APIError{StatusCode: resp.StatusCode} + data, _ := io.ReadAll(resp.Body) + var envelope struct { + Message string `json:"message"` + } + if err := json.Unmarshal(data, &envelope); err == nil && envelope.Message != "" { + apiErr.Message = envelope.Message + } else { + apiErr.Message = strings.TrimSpace(string(data)) + } + return apiErr +} diff --git a/pkg/client/management/management_test.go b/pkg/client/management/management_test.go new file mode 100644 index 0000000..b18ef3f --- /dev/null +++ b/pkg/client/management/management_test.go @@ -0,0 +1,188 @@ +package management_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/fil-forge/hilt/pkg/api" + "github.com/fil-forge/hilt/pkg/client/management" + "github.com/stretchr/testify/require" +) + +const testPartnerKey = "secret-partner-key" + +// newClient builds a client pointed at an httptest server whose handler is fn. +// fn should assert the request and write the canned response. +func newClient(t *testing.T, fn http.HandlerFunc) *management.Client { + t.Helper() + srv := httptest.NewServer(fn) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + require.NoError(t, err) + return management.NewClient(*u, testPartnerKey, management.WithHTTPClient(srv.Client())) +} + +// assertAuth checks the partner-key bearer header is present. +func assertAuth(t *testing.T, r *http.Request) { + t.Helper() + require.Equal(t, "Bearer "+testPartnerKey, r.Header.Get("Authorization")) +} + +func TestManagementClient(t *testing.T) { + ctx := context.Background() + + t.Run("ProvisionTenant returns the created tenant (201)", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodPut, r.Method) + require.Equal(t, "/tenants/acme", r.URL.Path) + var body api.ProvisionTenantRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, "Acme", body.DisplayName) + require.Equal(t, "us-east-1", body.Region) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(api.Tenant{TenantID: "acme", DisplayName: "Acme", Status: api.TenantStatusActive}) + }) + got, err := c.ProvisionTenant(ctx, "acme", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"}) + require.NoError(t, err) + require.Equal(t, "acme", got.TenantID) + require.Equal(t, api.TenantStatusActive, got.Status) + }) + + t.Run("ProvisionTenant accepts idempotent 200", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(api.Tenant{TenantID: "acme"}) + }) + got, err := c.ProvisionTenant(ctx, "acme", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"}) + require.NoError(t, err) + require.Equal(t, "acme", got.TenantID) + }) + + t.Run("GetTenant decodes the tenant", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/tenants/acme", r.URL.Path) + _ = json.NewEncoder(w).Encode(api.Tenant{TenantID: "acme", Status: api.TenantStatusWriteLocked}) + }) + got, err := c.GetTenant(ctx, "acme") + require.NoError(t, err) + require.Equal(t, api.TenantStatusWriteLocked, got.Status) + }) + + t.Run("UpdateTenantStatus sends the status and expects 204", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/tenants/acme/status", r.URL.Path) + var body api.UpdateTenantStatusRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, api.TenantStatusDisabled, body.Status) + w.WriteHeader(http.StatusNoContent) + }) + require.NoError(t, c.UpdateTenantStatus(ctx, "acme", api.TenantStatusDisabled)) + }) + + t.Run("DeleteTenant expects 204", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/tenants/acme", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }) + require.NoError(t, c.DeleteTenant(ctx, "acme")) + }) + + t.Run("CreateAccessKey returns the secret (201)", func(t *testing.T) { + expires := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC) + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/tenants/acme/access-keys", r.URL.Path) + var body api.CreateAccessKeyRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Equal(t, "ci", body.Name) + require.Equal(t, []string{"s3:GetObject"}, body.Permissions) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(api.CreatedAccessKey{ + AccessKey: api.AccessKey{AccessKeyID: "AKID", Name: "ci", ExpiresAt: &expires}, + SecretAccessKey: "SECRET", + }) + }) + got, err := c.CreateAccessKey(ctx, "acme", api.CreateAccessKeyRequest{Name: "ci", Permissions: []string{"s3:GetObject"}}) + require.NoError(t, err) + require.Equal(t, "AKID", got.AccessKeyID) + require.Equal(t, "SECRET", got.SecretAccessKey) + require.NotNil(t, got.ExpiresAt) + }) + + t.Run("ListAccessKeys returns the items", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/tenants/acme/access-keys", r.URL.Path) + _ = json.NewEncoder(w).Encode(api.AccessKeyList{Items: []api.AccessKey{{AccessKeyID: "a"}, {AccessKeyID: "b"}}}) + }) + got, err := c.ListAccessKeys(ctx, "acme") + require.NoError(t, err) + require.Len(t, got, 2) + require.Equal(t, "a", got[0].AccessKeyID) + }) + + t.Run("GetAccessKey decodes the key", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/tenants/acme/access-keys/AKID", r.URL.Path) + _ = json.NewEncoder(w).Encode(api.AccessKey{AccessKeyID: "AKID", Name: "ci"}) + }) + got, err := c.GetAccessKey(ctx, "acme", "AKID") + require.NoError(t, err) + require.Equal(t, "ci", got.Name) + }) + + t.Run("DeleteAccessKey expects 204", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/tenants/acme/access-keys/AKID", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }) + require.NoError(t, c.DeleteAccessKey(ctx, "acme", "AKID")) + }) + + t.Run("non-2xx returns an APIError carrying status and message", func(t *testing.T) { + c := newClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "tenant not found"}) + }) + _, err := c.GetTenant(ctx, "missing") + require.Error(t, err) + var apiErr *management.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode) + require.Equal(t, "tenant not found", apiErr.Message) + }) + + t.Run("transport error is surfaced", func(t *testing.T) { + u, err := url.Parse("http://management.test") + require.NoError(t, err) + c := management.NewClient(*u, testPartnerKey, + management.WithHTTPClient(&http.Client{Transport: errRoundTripper{}})) + _, err = c.GetTenant(ctx, "acme") + require.Error(t, err) + }) +} + +type errRoundTripper struct{} + +func (errRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("transport boom") +} diff --git a/pkg/client/upload.go b/pkg/client/upload.go index d5ab677..b581610 100644 --- a/pkg/client/upload.go +++ b/pkg/client/upload.go @@ -7,6 +7,7 @@ import ( "net/url" "github.com/fil-forge/hilt/pkg/lib/zapucan" + blobcmds "github.com/fil-forge/libforge/commands/blob" customercmds "github.com/fil-forge/libforge/commands/customer" providercmds "github.com/fil-forge/libforge/commands/provider" ucanlib "github.com/fil-forge/libforge/ucan" @@ -14,34 +15,80 @@ import ( "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/execution" "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" "github.com/fil-forge/ucantone/ucan/invocation" "go.uber.org/zap" ) -type UploadClientOption func(*UploadClientConfig) +type Option func(*clientConfig) -type UploadClientConfig struct { +type clientConfig struct { httpClient *http.Client + logger *zap.Logger + product did.DID } -func WithHTTPClient(httpClient *http.Client) UploadClientOption { - return func(cfg *UploadClientConfig) { +func WithHTTPClient(httpClient *http.Client) Option { + return func(cfg *clientConfig) { cfg.httpClient = httpClient } } +// WithProduct sets the default product/plan DID used when registering customers +// (see [UploadClient.RegisterCustomer]). +func WithProduct(product did.DID) Option { + return func(cfg *clientConfig) { + cfg.product = product + } +} + +func WithLogger(logger *zap.Logger) Option { + return func(cfg *clientConfig) { + if logger != nil { + cfg.logger = logger + } + } +} + +type MethodOption func(*methodConfig) + +type methodConfig struct { + issuer ucan.Issuer + proofs ucanlib.ProofStore +} + +func WithIssuer(iss ucan.Issuer) MethodOption { + return func(cfg *methodConfig) { + if iss != nil { + cfg.issuer = iss + } + } +} + +func WithProofs(proofs ucanlib.ProofStore) MethodOption { + return func(cfg *methodConfig) { + if proofs != nil { + cfg.proofs = proofs + } + } +} + type UploadClient struct { ServiceID did.DID + Issuer ucan.Issuer Proofs ucanlib.ProofStore + Product did.DID Executor execution.Executor Logger *zap.Logger } // NewUploadClient creates a new [UploadClient] for interacting with the upload -// service. The proofs parameter is used to provide proofs for UCAN invocations -// made by the client. -func NewUploadClient(serviceID did.DID, serviceURL url.URL, proofs ucanlib.ProofStore, logger *zap.Logger, opts ...UploadClientOption) (*UploadClient, error) { - cfg := &UploadClientConfig{} +// service. The issuer and proofs parameters are used as the default issuer and +// proof set if none are provided as individual method options. +func NewUploadClient(serviceID did.DID, serviceURL url.URL, issuer ucan.Issuer, proofs ucanlib.ProofStore, opts ...Option) (*UploadClient, error) { + cfg := &clientConfig{ + logger: zap.NewNop(), + } for _, opt := range opts { opt(cfg) } @@ -57,25 +104,38 @@ func NewUploadClient(serviceID did.DID, serviceURL url.URL, proofs ucanlib.Proof return nil, fmt.Errorf("creating HTTP executor: %w", err) } + if issuer == nil { + return nil, fmt.Errorf("issuer is required") + } + if proofs == nil { + proofs = ucanlib.NewContainerProofStore(container.New()) + } + return &UploadClient{ ServiceID: serviceID, + Issuer: issuer, Proofs: proofs, + Product: cfg.product, Executor: httpExecutor, - Logger: logger, + Logger: cfg.logger, }, nil } // RegisterCustomer registers a new customer with the upload service. -func (c *UploadClient) RegisterCustomer(ctx context.Context, issuer ucan.Issuer, id did.DID, product did.DID, details map[string]string) error { - proofs, proofLinks, err := c.Proofs.ProofChain(ctx, issuer.DID(), customercmds.Add.Command, c.ServiceID) +func (c *UploadClient) RegisterCustomer(ctx context.Context, customer did.DID, product did.DID, details map[string]string, opts ...MethodOption) error { + cfg := &methodConfig{issuer: c.Issuer, proofs: c.Proofs} + for _, opt := range opts { + opt(cfg) + } + proofs, proofLinks, err := cfg.proofs.ProofChain(ctx, cfg.issuer.DID(), customercmds.Add.Command, c.ServiceID) if err != nil { return fmt.Errorf("getting proof chain: %w", err) } inv, err := customercmds.Add.Invoke( - issuer, + cfg.issuer, c.ServiceID, &customercmds.AddArguments{ - Customer: id, + Customer: customer, Product: product, Details: details, }, @@ -87,11 +147,15 @@ func (c *UploadClient) RegisterCustomer(ctx context.Context, issuer ucan.Issuer, } log := zapucan.WithInvocation(c.Logger, inv) log.Debug("executing invocation") - _, err = c.Executor.Execute(execution.NewRequest(ctx, inv, execution.WithDelegations(proofs...))) + res, err := c.Executor.Execute(execution.NewRequest(ctx, inv, execution.WithDelegations(proofs...))) if err != nil { log.Error("failed to execute register customer invocation", zap.Error(err)) return fmt.Errorf("executing register customer invocation: %w", err) } + if _, err := customercmds.Add.Unpack(res.Receipt()); err != nil { + log.Error("failed to unpack register customer result", zap.Error(err)) + return fmt.Errorf("unpacking register customer result: %w", err) + } return nil } @@ -124,3 +188,41 @@ func (c *UploadClient) ProvisionSpace(ctx context.Context, account ucan.Issuer, } return addOK.ID, nil } + +// SpaceEmpty checks whether the given space is empty (contains no blobs). +func (c *UploadClient) SpaceEmpty(ctx context.Context, space did.DID, opts ...MethodOption) (bool, error) { + cfg := &methodConfig{issuer: c.Issuer, proofs: c.Proofs} + for _, opt := range opts { + opt(cfg) + } + proofs, proofLinks, err := cfg.proofs.ProofChain(ctx, cfg.issuer.DID(), blobcmds.List.Command, space) + if err != nil { + return false, fmt.Errorf("getting proof chain: %w", err) + } + size := uint64(1) + inv, err := blobcmds.List.Invoke( + cfg.issuer, + space, + &blobcmds.ListArguments{ + Size: &size, + }, + invocation.WithAudience(c.ServiceID), + invocation.WithProofs(proofLinks...), + ) + if err != nil { + return false, fmt.Errorf("invoking list blobs: %w", err) + } + log := zapucan.WithInvocation(c.Logger, inv) + log.Debug("executing invocation") + res, err := c.Executor.Execute(execution.NewRequest(ctx, inv, execution.WithDelegations(proofs...))) + if err != nil { + log.Error("failed to execute list blobs invocation", zap.Error(err)) + return false, fmt.Errorf("executing list blobs invocation: %w", err) + } + listOK, err := blobcmds.List.Unpack(res.Receipt()) + if err != nil { + log.Error("failed to unpack list blobs result", zap.Error(err)) + return false, fmt.Errorf("unpacking list blobs result: %w", err) + } + return len(listOK.Results) == 0, nil +} diff --git a/pkg/client/upload_test.go b/pkg/client/upload_test.go index 89fd312..184b110 100644 --- a/pkg/client/upload_test.go +++ b/pkg/client/upload_test.go @@ -7,10 +7,11 @@ import ( "net/url" "testing" - "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/client" + blobcmds "github.com/fil-forge/libforge/commands/blob" customercmds "github.com/fil-forge/libforge/commands/customer" providercmds "github.com/fil-forge/libforge/commands/provider" + "github.com/fil-forge/libforge/testutil" ucanlib "github.com/fil-forge/libforge/ucan" "github.com/fil-forge/ucantone/binding" "github.com/fil-forge/ucantone/did" @@ -19,16 +20,15 @@ import ( "github.com/fil-forge/ucantone/ucan/container" "github.com/ipfs/go-cid" "github.com/stretchr/testify/require" - "go.uber.org/zap" ) // newClient builds an UploadClient whose transport is the given in-process // server, exercising NewUploadClient itself. -func newClient(t *testing.T, service ucan.Issuer, srv *server.HTTPServer, proofs ucanlib.ProofStore) *client.UploadClient { +func newClient(t *testing.T, service ucan.Issuer, srv *server.HTTPServer, issuer ucan.Issuer, proofs ucanlib.ProofStore) *client.UploadClient { t.Helper() u, err := url.Parse("http://upload.test") require.NoError(t, err) - c, err := client.NewUploadClient(service.DID(), *u, proofs, zap.NewNop(), + c, err := client.NewUploadClient(service.DID(), *u, issuer, proofs, client.WithHTTPClient(&http.Client{Transport: srv})) require.NoError(t, err) return c @@ -72,8 +72,8 @@ func TestRegisterCustomer(t *testing.T) { return res.SetSuccess(&customercmds.AddOK{}) })) - c := newClient(t, service, srv, proofs) - err = c.RegisterCustomer(t.Context(), alice, customerDID, product, details) + c := newClient(t, service, srv, alice, proofs) + err = c.RegisterCustomer(t.Context(), customerDID, product, details) require.NoError(t, err) require.Equal(t, customerDID, gotArgs.Customer) @@ -87,8 +87,8 @@ func TestRegisterCustomer(t *testing.T) { alice := testutil.RandomIssuer(t) srv := server.NewHTTP(service) - c := newClient(t, service, srv, errProofStore{err: errors.New("boom")}) - err := c.RegisterCustomer(t.Context(), alice, testutil.RandomDID(t), testutil.RandomDID(t), nil) + c := newClient(t, service, srv, alice, errProofStore{err: errors.New("boom")}) + err := c.RegisterCustomer(t.Context(), testutil.RandomDID(t), testutil.RandomDID(t), nil) require.Error(t, err) require.Contains(t, err.Error(), "getting proof chain") }) @@ -103,11 +103,11 @@ func TestRegisterCustomer(t *testing.T) { u, err := url.Parse("http://upload.test") require.NoError(t, err) - c, err := client.NewUploadClient(service.DID(), *u, proofs, zap.NewNop(), + c, err := client.NewUploadClient(service.DID(), *u, alice, proofs, client.WithHTTPClient(&http.Client{Transport: errRoundTripper{}})) require.NoError(t, err) - err = c.RegisterCustomer(t.Context(), alice, testutil.RandomDID(t), testutil.RandomDID(t), nil) + err = c.RegisterCustomer(t.Context(), testutil.RandomDID(t), testutil.RandomDID(t), nil) require.Error(t, err) }) } @@ -129,7 +129,7 @@ func TestProvisionSpace(t *testing.T) { })) // ProvisionSpace is self-issued and does not consult the proof store. - c := newClient(t, service, srv, nil) + c := newClient(t, service, srv, account, nil) id, err := c.ProvisionSpace(t.Context(), account, space) require.NoError(t, err) require.Equal(t, "sub-123", id) @@ -150,9 +150,120 @@ func TestProvisionSpace(t *testing.T) { return res.SetFailure(errors.New("nope")) })) - c := newClient(t, service, srv, nil) + c := newClient(t, service, srv, account, nil) id, err := c.ProvisionSpace(t.Context(), account, space) require.Error(t, err) require.Empty(t, id) }) } + +func TestSpaceEmpty(t *testing.T) { + // listServer builds an in-process server whose /blob/list handler returns + // the given results, capturing the invocation for assertions. + newListServer := func(t *testing.T, service ucan.Issuer, results []blobcmds.ListBlobItem) (*server.HTTPServer, func() (*blobcmds.ListArguments, did.DID, did.DID)) { + t.Helper() + var gotArgs *blobcmds.ListArguments + var gotSub, gotAud did.DID + srv := server.NewHTTP(service) + srv.Handle(blobcmds.List.Command, blobcmds.List.Handler( + func(req *binding.Request[*blobcmds.ListArguments], res *binding.Response[*blobcmds.ListOK]) error { + gotArgs = req.Task().Arguments() + gotSub = req.Invocation().Subject() + gotAud = req.Invocation().Audience() + return res.SetSuccess(&blobcmds.ListOK{Results: results}) + })) + return srv, func() (*blobcmds.ListArguments, did.DID, did.DID) { return gotArgs, gotSub, gotAud } + } + + t.Run("empty", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + space := testutil.RandomIssuer(t) + + // space delegates /blob/list to alice (root: subject == issuer == space). + // The proof chain is looked up scoped to the space. + dlg, err := blobcmds.List.Delegate(space, alice.DID(), space.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + srv, captured := newListServer(t, service, nil) + + c := newClient(t, service, srv, alice, proofs) + empty, err := c.SpaceEmpty(t.Context(), space.DID(), client.WithIssuer(alice), client.WithProofs(proofs)) + require.NoError(t, err) + require.True(t, empty) + + gotArgs, gotSub, gotAud := captured() + require.NotNil(t, gotArgs.Size) + require.Equal(t, uint64(1), *gotArgs.Size) + require.Equal(t, space.DID(), gotSub) + require.Equal(t, service.DID(), gotAud) + }) + + t.Run("not empty", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + space := testutil.RandomIssuer(t) + + dlg, err := blobcmds.List.Delegate(space, alice.DID(), space.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + srv, _ := newListServer(t, service, []blobcmds.ListBlobItem{{}}) + + c := newClient(t, service, srv, alice, nil) + empty, err := c.SpaceEmpty(t.Context(), space.DID(), client.WithIssuer(alice), client.WithProofs(proofs)) + require.NoError(t, err) + require.False(t, empty) + }) + + t.Run("proof chain error", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + srv := server.NewHTTP(service) + + c := newClient(t, service, srv, alice, nil) + _, err := c.SpaceEmpty(t.Context(), testutil.RandomDID(t), client.WithIssuer(alice), client.WithProofs(errProofStore{err: errors.New("boom")})) + require.Error(t, err) + require.Contains(t, err.Error(), "getting proof chain") + }) + + t.Run("execution error", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + space := testutil.RandomIssuer(t) + + dlg, err := blobcmds.List.Delegate(space, alice.DID(), space.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + u, err := url.Parse("http://upload.test") + require.NoError(t, err) + c, err := client.NewUploadClient(service.DID(), *u, alice, nil, + client.WithHTTPClient(&http.Client{Transport: errRoundTripper{}})) + require.NoError(t, err) + + _, err = c.SpaceEmpty(t.Context(), space.DID(), client.WithIssuer(alice), client.WithProofs(proofs)) + require.Error(t, err) + }) + + t.Run("failure receipt", func(t *testing.T) { + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + space := testutil.RandomIssuer(t) + + dlg, err := blobcmds.List.Delegate(space, alice.DID(), space.DID()) + require.NoError(t, err) + proofs := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + + srv := server.NewHTTP(service) + srv.Handle(blobcmds.List.Command, blobcmds.List.Handler( + func(req *binding.Request[*blobcmds.ListArguments], res *binding.Response[*blobcmds.ListOK]) error { + return res.SetFailure(errors.New("nope")) + })) + + c := newClient(t, service, srv, alice, nil) + _, err = c.SpaceEmpty(t.Context(), space.DID(), client.WithIssuer(alice), client.WithProofs(proofs)) + require.Error(t, err) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ef38cfd..345dc61 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -37,6 +37,23 @@ type Config struct { Vault VaultConfig `mapstructure:"vault"` PLC PLCConfig `mapstructure:"plc"` Auth AuthConfig `mapstructure:"auth"` + Upload UploadConfig `mapstructure:"upload"` +} + +// UploadConfig holds settings for the Sprue upload service, which Hilt calls to +// provision a bucket's storage space. +type UploadConfig struct { + // ServiceID is the Sprue service's DID (e.g. "did:web:sprue.example.com"). + ServiceID string `mapstructure:"service_id"` + // ServiceURL is the Sprue service's HTTP endpoint. + ServiceURL string `mapstructure:"service_url"` + // ProductID is the Sprue product/plan DID that tenants are registered under + // when Hilt provisions them (the /customer/add product argument). + ProductID string `mapstructure:"product_id"` + // Proofs is the UCAN delegation container the upload client presents to Sprue + // — either an inline (codec-prefixed) encoded container or a path to a file + // containing one. Empty means no proofs (only self-issued calls will work). + Proofs string `mapstructure:"proofs"` } // IdentityConfig holds the Hilt service identity used to sign and receive UCAN @@ -145,6 +162,10 @@ func SetDefaults(v *viper.Viper) { v.SetDefault("vault.hashicorp.approle.mount", "approle") v.SetDefault("plc.directory", "https://plc.directory") + + v.SetDefault("upload.service_id", "did:web:upload.forgery.network") + v.SetDefault("upload.service_url", "https://upload.forgery.network") + v.SetDefault("upload.product_id", "did:web:hilt.forgery.network") } // BindEnvVars sets up environment variable binding with the HILT_ prefix. @@ -176,6 +197,10 @@ func BindFlags(v *viper.Viper, flags *pflag.FlagSet) error { "vault.hashicorp.approle.mount": "hashicorp-approle-mount", "plc.directory": "plc-directory", "auth.partner_key": "partner-key", + "upload.service_id": "upload-service-id", + "upload.service_url": "upload-service-url", + "upload.product_id": "upload-product-id", + "upload.proofs": "upload-proofs", } for key, name := range bindings { if f := flags.Lookup(name); f != nil { diff --git a/pkg/fx/config.go b/pkg/fx/config.go index 3812604..99e8b25 100644 --- a/pkg/fx/config.go +++ b/pkg/fx/config.go @@ -23,6 +23,7 @@ type Configs struct { Hashicorp config.HashicorpConfig PLC config.PLCConfig Auth config.AuthConfig + Upload config.UploadConfig } // ProvideConfigs provides the individual fields of the config. @@ -37,5 +38,6 @@ func ProvideConfigs(cfg *config.Config) Configs { Hashicorp: cfg.Vault.Hashicorp, PLC: cfg.PLC, Auth: cfg.Auth, + Upload: cfg.Upload, } } diff --git a/pkg/fx/rpc.go b/pkg/fx/rpc.go index abf4f36..8e8fac0 100644 --- a/pkg/fx/rpc.go +++ b/pkg/fx/rpc.go @@ -2,6 +2,7 @@ package fx import ( "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" "github.com/fil-forge/libforge/identity" "github.com/fil-forge/ucantone/server" "go.uber.org/fx" @@ -11,6 +12,8 @@ import ( // serves, collected into the "ucanRoutes" group. var RPCModule = fx.Module("rpc", fx.Provide( + auth.NewAuthorizer, + NewUploadClient, NewUCANServer, asUCANRoute(rpc.NewAuthorizeRequestHandler), asUCANRoute(rpc.NewCreateBucketHandler), diff --git a/pkg/fx/rpc_test.go b/pkg/fx/rpc_test.go index 32da8ce..c5348df 100644 --- a/pkg/fx/rpc_test.go +++ b/pkg/fx/rpc_test.go @@ -6,6 +6,14 @@ import ( "github.com/fil-forge/hilt/pkg/config" appfx "github.com/fil-forge/hilt/pkg/fx" "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "github.com/fil-forge/libforge/testutil" "github.com/fil-forge/ucantone/server" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -27,14 +35,21 @@ func TestNewUCANServer(t *testing.T) { id, err := appfx.NewIdentity(config.IdentityConfig{}, zap.NewNop()) require.NoError(t, err) + az := auth.NewAuthorizer(zap.NewNop(), accesskeymemory.New(), tenantmemory.New(), providermemory.New(), bucketmemory.New(), vaultmemory.New()) + upload, err := appfx.NewUploadClient( + id, + config.UploadConfig{ServiceID: testutil.RandomDID(t).String(), ServiceURL: "http://sprue.test"}, + zap.NewNop(), + ) + require.NoError(t, err) srv := appfx.NewUCANServer(appfx.UCANServerParams{ Identity: id, Routes: []server.Route{ - rpc.NewAuthorizeRequestHandler(zap.NewNop()), - rpc.NewCreateBucketHandler(zap.NewNop()), - rpc.NewDeleteBucketHandler(zap.NewNop()), - rpc.NewBucketInfoHandler(zap.NewNop()), - rpc.NewListBucketsHandler(zap.NewNop()), + rpc.NewAuthorizeRequestHandler(zap.NewNop(), az), + rpc.NewCreateBucketHandler(zap.NewNop(), az, bucketmemory.New(), delegationmemory.New(), upload), + rpc.NewDeleteBucketHandler(zap.NewNop(), az, bucketmemory.New(), delegationmemory.New(), upload), + rpc.NewBucketInfoHandler(zap.NewNop(), bucketmemory.New(), accesskeymemory.New(), delegationmemory.New()), + rpc.NewListBucketsHandler(zap.NewNop(), az, bucketmemory.New()), }, }) require.NotNil(t, srv) diff --git a/pkg/fx/upload.go b/pkg/fx/upload.go new file mode 100644 index 0000000..33f9ff9 --- /dev/null +++ b/pkg/fx/upload.go @@ -0,0 +1,66 @@ +package fx + +import ( + "fmt" + "net/url" + "os" + + "github.com/fil-forge/hilt/pkg/client" + "github.com/fil-forge/hilt/pkg/config" + "github.com/fil-forge/libforge/identity" + ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan/container" + "go.uber.org/zap" +) + +// NewUploadClient builds the Sprue upload-service client from configuration. Its +// proof store — the delegations it presents to Sprue — is loaded from +// upload.proofs (see [uploadProofs]). +func NewUploadClient(id identity.Identity, cfg config.UploadConfig, logger *zap.Logger) (*client.UploadClient, error) { + serviceID, err := did.Parse(cfg.ServiceID) + if err != nil { + return nil, fmt.Errorf("parsing upload.service_id %q: %w", cfg.ServiceID, err) + } + serviceURL, err := url.Parse(cfg.ServiceURL) + if err != nil { + return nil, fmt.Errorf("parsing upload.service_url %q: %w", cfg.ServiceURL, err) + } + // The product DID is optional here: it is only consumed when registering + // tenants as customers. Leave it undefined when unset so the client remains + // usable for the bucket-provisioning flows that do not need it. + var product did.DID + if cfg.ProductID != "" { + product, err = did.Parse(cfg.ProductID) + if err != nil { + return nil, fmt.Errorf("parsing upload.product_id %q: %w", cfg.ProductID, err) + } + } + proofs, err := uploadProofs(cfg.Proofs) + if err != nil { + return nil, fmt.Errorf("loading upload.proofs: %w", err) + } + return client.NewUploadClient(serviceID, *serviceURL, id, proofs, + client.WithProduct(product), client.WithLogger(logger)) +} + +// uploadProofs builds the upload client's proof store from cfg.Proofs, which is +// either an inline (codec-prefixed) encoded UCAN container or a path to a file +// containing one. The inline form is tried first so a file whose name happens to +// be a valid container string does not shadow it. Empty yields an empty store. +func uploadProofs(proofs string) (ucanlib.ProofStore, error) { + if proofs == "" { + return ucanlib.NewContainerProofStore(container.New()), nil + } + ct, err := container.Decode([]byte(proofs)) + if err != nil { + data, ferr := os.ReadFile(proofs) + if ferr != nil { + return nil, fmt.Errorf("upload.proofs is neither a valid container (%v) nor a readable file: %w", err, ferr) + } + if ct, err = container.Decode(data); err != nil { + return nil, fmt.Errorf("decoding upload.proofs file %q: %w", proofs, err) + } + } + return ucanlib.NewContainerProofStore(ct), nil +} diff --git a/pkg/fx/upload_test.go b/pkg/fx/upload_test.go new file mode 100644 index 0000000..c2ea411 --- /dev/null +++ b/pkg/fx/upload_test.go @@ -0,0 +1,73 @@ +package fx_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fil-forge/hilt/pkg/config" + appfx "github.com/fil-forge/hilt/pkg/fx" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewUploadClient(t *testing.T) { + id, err := appfx.NewIdentity(config.IdentityConfig{}, zap.NewNop()) + require.NoError(t, err) + + baseCfg := func(proofs string) config.UploadConfig { + return config.UploadConfig{ + ServiceID: testutil.RandomDID(t).String(), + ServiceURL: "http://sprue.test", + Proofs: proofs, + } + } + + // A container holding one root delegation (subject == issuer), and its + // codec-prefixed encoding. + svc := testutil.RandomIssuer(t) + alice := testutil.RandomDID(t) + cmd := command.MustParse("/test/run") + dlg, err := delegation.Delegate(svc, alice, svc.DID(), cmd) + require.NoError(t, err) + encoded, err := container.Encode(container.Base64url, container.New(container.WithDelegations(dlg))) + require.NoError(t, err) + + t.Run("inline encoded container", func(t *testing.T) { + c, err := appfx.NewUploadClient(id, baseCfg(string(encoded)), zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, c) + + // The proof store was built from the config value. + proofs, links, err := c.Proofs.ProofChain(t.Context(), alice, cmd, svc.DID()) + require.NoError(t, err) + require.Len(t, proofs, 1) + require.Len(t, links, 1) + }) + + t.Run("path to a container file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "proofs") + require.NoError(t, os.WriteFile(path, encoded, 0o600)) + + c, err := appfx.NewUploadClient(id, baseCfg(path), zap.NewNop()) + require.NoError(t, err) + proofs, _, err := c.Proofs.ProofChain(t.Context(), alice, cmd, svc.DID()) + require.NoError(t, err) + require.Len(t, proofs, 1) + }) + + t.Run("empty proofs yields an empty store", func(t *testing.T) { + c, err := appfx.NewUploadClient(id, baseCfg(""), zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, c) + }) + + t.Run("invalid proofs (neither container nor file) errors", func(t *testing.T) { + _, err := appfx.NewUploadClient(id, baseCfg("not-a-container-and-not-a-file"), zap.NewNop()) + require.Error(t, err) + }) +} diff --git a/pkg/rpc/authorize.go b/pkg/rpc/authorize.go new file mode 100644 index 0000000..30d86d3 --- /dev/null +++ b/pkg/rpc/authorize.go @@ -0,0 +1,147 @@ +package rpc + +import ( + "context" + "fmt" + "time" + + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/s3perm" + "github.com/fil-forge/hilt/pkg/sigv4" + s3 "github.com/fil-forge/libforge/commands/s3" + s3req "github.com/fil-forge/libforge/commands/s3/request" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/ipfs/go-cid" + "go.uber.org/zap" +) + +// NewAuthorizeRequestHandler handles /s3/request/authorize — authenticate an AWS +// S3 request, derive the verification key the gateway needs, and issue delegations +// for the requested action's Forge commands to the invocation issuer. +func NewAuthorizeRequestHandler( + logger *zap.Logger, + authorizer *auth.Authorizer, +) server.Route { + log := logger.With(zap.Stringer("command", s3req.Authorize.Command)) + return s3req.Authorize.Route(func(req *binding.Request[*s3req.AuthorizeArguments], res *binding.Response[*s3req.AuthorizeOK]) error { + ok, dlgs, err := AuthorizeRequest(req.Context(), log, authorizer, req.Invocation().Issuer(), req.Task().Arguments()) + if err != nil { + log.Error("authorize request failed", zap.Error(err)) + return res.SetFailure(err) + } + // The delegation map in the result carries only CIDs; the blocks ride back + // in the response container via the metadata. + if len(dlgs) > 0 { + if err := res.SetMetadata(container.New(container.WithDelegations(dlgs...))); err != nil { + log.Error("attaching delegations", zap.Error(err)) + return err + } + } + return res.SetSuccess(ok) + }) +} + +// AuthorizeRequest authenticates the S3 request (which resolves and scope-checks +// the addressed bucket and the access key's permission for the action), derives the +// verification key, and mints delegations for the action's Forge commands to the +// invocation issuer (TTL ≤ 24h). It returns the result and the delegation blocks to +// attach to the response. It is factored out of the handler so it can be unit +// tested without constructing a UCAN invocation. +func AuthorizeRequest( + ctx context.Context, + logger *zap.Logger, + authorizer *auth.Authorizer, + issuer did.DID, + args *s3req.AuthorizeArguments, +) (*s3req.AuthorizeOK, []ucan.Delegation, error) { + authz, err := authorizer.Authorize(ctx, issuer, args.Request) + if err != nil { + return nil, nil, err + } + accessKeyID := authz.AccessKey.ID + + // Authorize resolved and scope-checked the addressed bucket; the gateway path + // only handles requests that operate on a bucket. + if authz.Bucket == nil { + return nil, nil, fmt.Errorf("request does not address a bucket") + } + b := authz.Bucket + + // Authorize also verified the access key holds the operation's permission; the + // permission drives which Forge commands to re-delegate. + perm := authz.Operation.Permission() + + // Derive the verification key the gateway uses to validate request signatures. + signer, err := authorizer.AccessKeySigner(ctx, authz.AccessKey.Tenant, accessKeyID) + if err != nil { + return nil, nil, err + } + secret, err := auth.EncodeSecret(signer) + if err != nil { + return nil, nil, err + } + key, err := sigv4.DeriveKey(authz.Signed, secret) + if err != nil { + return nil, nil, fmt.Errorf("deriving signing key: %w", err) + } + kind := s3.KeyKindSigV4 + if authz.Signed.Scheme == sigv4.SchemeV4a { + kind = s3.KeyKindSigV4a + } + + // Issue a delegation to the invocation issuer (the gateway) for each Forge + // command the action maps to, signing as the access key with the bucket as + // subject. We assume the access key already holds these commands (delegated at + // access-key creation); if not, the delegation simply has no proof chain to a + // root and is unusable — harmless. The gateway obtains the chain to the + // access key via `/s3/bucket/info`. + akIssuer := multikey.NewIssuer(accessKeyID, signer) + // Expire when the derived key does: 00:00:00 UTC of the following day (≤24h, + // satisfying the RFC TTL), capped to the access key's own expiry if sooner. + exp := ucan.UnixTimestamp(nextUTCMidnight(time.Now()).Unix()) + if authz.AccessKey.ExpiresAt != nil { + if capExp := authz.AccessKey.ExpiresAt.Unix(); capExp < int64(exp) { + exp = ucan.UnixTimestamp(capExp) + } + } + + proofSet := map[cid.Cid][]cid.Cid{} + var blocks []ucan.Delegation + for _, cmd := range s3perm.CommandsFor(perm) { + reDel, err := delegation.Delegate(akIssuer, issuer, b.ID, cmd, delegation.WithExpiration(exp)) + if err != nil { + return nil, nil, fmt.Errorf("delegating %s: %w", cmd, err) + } + proofSet[reDel.Link()] = []cid.Cid{reDel.Link()} + blocks = append(blocks, reDel) + } + + logger.Debug("authorized request", + zap.Stringer("bucket", b.ID), + zap.String("permission", perm), + zap.Int("delegations", len(proofSet)), + ) + return &s3req.AuthorizeOK{ + Bucket: b.ID, + Permissions: s3.PermissionSet{Entries: map[did.DID][]string{ + accessKeyID: authz.AccessKey.Permissions, + }}, + Keys: s3.KeySet{Entries: map[did.DID][]s3.VerificationKey{ + accessKeyID: {{Kind: kind, Data: key}}, + }}, + Delegations: s3.ProofSet{Entries: proofSet}, + }, blocks, nil +} + +// nextUTCMidnight returns 00:00:00 UTC of the day after t — when a date-scoped +// SigV4 signing key derived for t's date stops being usable. +func nextUTCMidnight(t time.Time) time.Time { + t = t.UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1) +} diff --git a/pkg/rpc/authorize_test.go b/pkg/rpc/authorize_test.go new file mode 100644 index 0000000..3e5e8c5 --- /dev/null +++ b/pkg/rpc/authorize_test.go @@ -0,0 +1,140 @@ +package rpc_test + +import ( + "testing" + "time" + + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/sigv4" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + "github.com/fil-forge/hilt/pkg/store/tenant" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + "github.com/fil-forge/hilt/pkg/vault" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "github.com/fil-forge/libforge/commands/content" + s3 "github.com/fil-forge/libforge/commands/s3" + s3req "github.com/fil-forge/libforge/commands/s3/request" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/ucan" + "github.com/ipfs/go-cid" + "github.com/multiformats/go-multibase" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// signedGetArgs builds AuthorizeArguments whose request is a presigned GET of an +// object in the named bucket (path-style addressing: bucket is the first path +// segment). +func signedGetArgs(t *testing.T, signer ed25519.Signer, bucketName, region string, signedAt time.Time, expires time.Duration) *s3req.AuthorizeArguments { + t.Helper() + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "GET", URL: "https://s3.fil.one/" + bucketName + "/object-key"} + signed, err := sigv4.Presign(req, signer.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, signedAt, expires) + require.NoError(t, err) + return &s3req.AuthorizeArguments{Request: s3.Request{Method: signed.Method, URL: signed.URL}} +} + +func TestAuthorizeRequest(t *testing.T) { + ctx := t.Context() + const ( + region = "us-west-2" + bucketName = "mybucket" + ) + + // The access key signs the request; its private key lives in the vault so the + // handler can issue delegations as the access key. + akSigner, err := ed25519.Generate() + require.NoError(t, err) + akDID := akSigner.KeyDID() + + // providerID is both the tenant's provider and the only legitimate invocation + // issuer. bucketID/tenantID are opaque DIDs — the handler no longer reads any + // stored delegation chain. + bucketID := testutil.RandomDID(t) + tenantID := testutil.RandomDID(t) + providerID := testutil.RandomDID(t) + + // setup wires the stores + vault for a tenant whose provider serves the signing + // region and that owns this access key + bucket, returning the Authorizer built + // from them plus the bucket store. + setup := func(t *testing.T, perms []string, vaultSigner ed25519.Signer) (*auth.Authorizer, *bucketmemory.Store) { + t.Helper() + accessKeys, tenants, buckets := accesskeymemory.New(), tenantmemory.New(), bucketmemory.New() + providers, secrets := providermemory.New(), vaultmemory.New() + + require.NoError(t, providers.Add(ctx, providerID, region)) + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenant.Active)) + require.NoError(t, accessKeys.Add(ctx, akDID, tenantID, "k1", nil, perms, nil)) + require.NoError(t, secrets.Write(ctx, vault.AccessKeyPath(tenantID, akDID), vaultSigner.Bytes())) + require.NoError(t, buckets.Add(ctx, bucketID, tenantID, bucketName)) + + return auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, buckets, secrets), buckets + } + + call := func(t *testing.T, authorizer *auth.Authorizer, buckets *bucketmemory.Store, issuer did.DID, args *s3req.AuthorizeArguments) (*s3req.AuthorizeOK, []ucan.Delegation, error) { + t.Helper() + return rpc.AuthorizeRequest(ctx, zap.NewNop(), authorizer, issuer, args) + } + + t.Run("authorizes a validly-signed request and issues a delegation to the issuer", func(t *testing.T) { + az, buckets := setup(t, []string{"s3:GetObject"}, akSigner) + args := signedGetArgs(t, akSigner, bucketName, region, time.Now(), time.Hour) + + ok, blocks, err := call(t, az, buckets, providerID, args) + require.NoError(t, err) + + require.Equal(t, bucketID, ok.Bucket) + require.Equal(t, []string{"s3:GetObject"}, ok.Permissions.Entries[akDID]) + + // The derived key verifies the request locally (the gateway path). + keys := ok.Keys.Entries[akDID] + require.Len(t, keys, 1) + require.Equal(t, s3.KeyKindSigV4, keys[0].Kind) + sr, err := sigv4.Parse(sigv4.Request{Method: args.Request.Method, URL: args.Request.URL}) + require.NoError(t, err) + require.NoError(t, sigv4.VerifyWithKey(sr, keys[0].Data)) + + // s3:GetObject maps to /content/retrieve: exactly one delegation issued to + // the invocation issuer over the bucket, no proof chain fetched. + require.Len(t, blocks, 1) + reDel := blocks[0] + require.Equal(t, providerID, reDel.Audience()) + require.Equal(t, bucketID, reDel.Subject()) + require.Equal(t, content.Retrieve.Command.String(), reDel.Command().String()) + + exp := reDel.Expiration() + require.NotNil(t, exp) + now := time.Now().Unix() + // Expires at the next UTC midnight: a day boundary within the next 24h. + require.Zero(t, int64(*exp)%86400, "expiry should be a UTC midnight") + require.Greater(t, int64(*exp), now) + require.LessOrEqual(t, int64(*exp), now+86400) + + // The delegations map keys the issued delegation to its own CID (the + // initial-implementation proof chain). + require.Len(t, ok.Delegations.Entries, 1) + chain, found := ok.Delegations.Entries[reDel.Link()] + require.True(t, found) + require.Equal(t, []cid.Cid{reDel.Link()}, chain) + }) + + t.Run("rejects a key lacking the permission for the action", func(t *testing.T) { + az, buckets := setup(t, []string{"s3:PutObject"}, akSigner) + args := signedGetArgs(t, akSigner, bucketName, region, time.Now(), time.Hour) + _, _, err := call(t, az, buckets, providerID, args) + require.Error(t, err) + }) + + t.Run("rejects an unknown bucket", func(t *testing.T) { + az, buckets := setup(t, []string{"s3:GetObject"}, akSigner) + args := signedGetArgs(t, akSigner, "nope", region, time.Now(), time.Hour) + _, _, err := call(t, az, buckets, providerID, args) + require.Error(t, err) + }) +} diff --git a/pkg/rpc/create.go b/pkg/rpc/create.go new file mode 100644 index 0000000..e7e2e8a --- /dev/null +++ b/pkg/rpc/create.go @@ -0,0 +1,219 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + + client "github.com/fil-forge/hilt/pkg/client" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/sigv4" + "github.com/fil-forge/hilt/pkg/store" + "github.com/fil-forge/hilt/pkg/store/bucket" + delegationstore "github.com/fil-forge/hilt/pkg/store/delegation" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + s3req "github.com/fil-forge/libforge/commands/s3/request" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/ipfs/go-cid" + "go.uber.org/zap" +) + +// SpaceProvisioner provisions a bucket's storage space with the upload service +// (Sprue). It is satisfied by [*client.UploadClient]; the interface lets the +// handler logic be unit tested without a live Sprue. +type SpaceProvisioner interface { + ProvisionSpace(ctx context.Context, account ucan.Issuer, space did.DID) (string, error) +} + +// NewCreateBucketHandler handles /s3/bucket/create — authenticate an AWS S3 +// CreateBucket request, create the bucket (and its bucket→tenant root +// delegation), provision its space with Sprue, and return the bucket DID with +// the delegation chains that now grant the access key access to it. +func NewCreateBucketHandler( + logger *zap.Logger, + authorizer *auth.Authorizer, + buckets bucket.Store, + delegations delegationstore.Store, + upload *client.UploadClient, +) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.Create.Command)) + return s3bkt.Create.Route(func(req *binding.Request[*s3bkt.CreateArguments], res *binding.Response[*s3req.AuthorizeOK]) error { + ok, dlgs, err := CreateBucket(req.Context(), log, authorizer, buckets, delegations, upload, req.Invocation().Issuer(), req.Task().Arguments()) + if err != nil { + log.Error("create bucket failed", zap.Error(err)) + return res.SetFailure(err) + } + // The delegation map in the result carries only CIDs; the blocks ride back + // in the response container via the metadata. + if len(dlgs) > 0 { + if err := res.SetMetadata(container.New(container.WithDelegations(dlgs...))); err != nil { + log.Error("attaching delegations", zap.Error(err)) + return err + } + } + return res.SetSuccess(ok) + }) +} + +// CreateBucket authenticates the request, checks the s3:CreateBucket permission, +// creates the bucket (an ephemeral bucket key signs a bucket→tenant "top" root +// delegation and is then discarded), provisions the bucket's space with Sprue as +// the tenant, and returns the AuthorizeOK: the new bucket DID, the access key's +// permissions and derived verification key, and the proof chains for the access +// key's powerline delegations (which now reach the new bucket). It is factored +// out of the handler so it can be unit tested without a UCAN invocation. +func CreateBucket( + ctx context.Context, + logger *zap.Logger, + authorizer *auth.Authorizer, + buckets bucket.Store, + delegations delegationstore.Store, + provisioner SpaceProvisioner, + issuer did.DID, + args *s3bkt.CreateArguments, +) (*s3req.AuthorizeOK, []ucan.Delegation, error) { + authz, err := authorizer.Authorize(ctx, issuer, args.Request) + if err != nil { + return nil, nil, err + } + accessKeyID := authz.AccessKey.ID + + if authz.Operation != auth.OpCreateBucket { + return nil, nil, fmt.Errorf("request is not a CreateBucket operation: %s", authz.Operation) + } + + _, err = buckets.GetByName(ctx, authz.BucketName) + if err == nil { + return nil, nil, fmt.Errorf("bucket %q already exists", authz.BucketName) + } else if !errors.Is(err, store.ErrRecordNotFound) { + return nil, nil, fmt.Errorf("looking up bucket: %w", err) + } + + // Generate an ephemeral bucket key; its DID is the bucket DID. The key signs + // the root delegation below and is then discarded (the space is managed by + // Sprue). + bucketSigner, err := ed25519.Generate() + if err != nil { + return nil, nil, fmt.Errorf("generating bucket key: %w", err) + } + bucketID := bucketSigner.KeyDID() + log := logger.With(zap.Stringer("bucket", bucketID), zap.String("name", authz.BucketName)) + + if err := buckets.Add(ctx, bucketID, authz.Tenant.ID, authz.BucketName); err != nil { + return nil, nil, fmt.Errorf("storing bucket: %w", err) + } + // Best-effort rollback of the bucket record on a later failure. The root + // delegation (if already stored) becomes unreachable — its bucket record is + // gone — so it is inert; the delegation store has no delete-by-CID. + // + // Cleanup runs on a context detached from the request (values retained, but + // cancellation/deadline dropped) so a client disconnect — which cancels ctx — + // cannot abort the rollback partway and leave an orphaned bucket record. + rollback := func() { + cleanupCtx := context.WithoutCancel(ctx) + if err := buckets.Delete(cleanupCtx, bucketID); err != nil { + log.Error("rollback: deleting bucket", zap.Error(err)) + } + } + + // Root delegation: the bucket delegates top authority over itself to the + // tenant (iss == sub == bucket, aud == tenant). + root, err := delegation.Delegate(multikey.NewIssuer(bucketID, bucketSigner), authz.Tenant.ID, bucketID, command.Top()) + if err != nil { + rollback() + return nil, nil, fmt.Errorf("issuing root delegation: %w", err) + } + if err := delegations.PutBatch(ctx, []ucan.Delegation{root}); err != nil { + rollback() + return nil, nil, fmt.Errorf("storing root delegation: %w", err) + } + + // Provision the bucket's space with Sprue, acting as the tenant. + account, err := authorizer.TenantIssuer(ctx, authz.Tenant.ID) + if err != nil { + rollback() + return nil, nil, err + } + subscription, err := provisioner.ProvisionSpace(ctx, account, bucketID) + if err != nil { + rollback() + return nil, nil, fmt.Errorf("provisioning bucket space: %w", err) + } + log.Debug("provisioned bucket space", zap.String("subscription", subscription)) + + // Derive the verification key the gateway uses to validate the caller's + // request signatures for this bucket. + signer, err := authorizer.AccessKeySigner(ctx, authz.AccessKey.Tenant, accessKeyID) + if err != nil { + return nil, nil, err + } + secret, err := auth.EncodeSecret(signer) + if err != nil { + return nil, nil, err + } + key, err := sigv4.DeriveKey(authz.Signed, secret) + if err != nil { + return nil, nil, fmt.Errorf("deriving signing key: %w", err) + } + kind := s3.KeyKindSigV4 + if authz.Signed.Scheme == sigv4.SchemeV4a { + kind = s3.KeyKindSigV4a + } + + // Return the proof chains for the access key's powerline (undefined-subject) + // delegations, which now reach the new bucket via the root delegation above. + stored, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[ucan.Delegation], error) { + var o []store.PaginationOption + if opts.Cursor != nil { + o = append(o, store.WithCursor(*opts.Cursor)) + } + return delegations.ListByAudience(ctx, accessKeyID, o...) + }) + if err != nil { + return nil, nil, fmt.Errorf("listing delegations: %w", err) + } + + proofSet := map[cid.Cid][]cid.Cid{} + var blocks []ucan.Delegation + seen := map[string]bool{} + for _, d := range stored { + if d.Subject().Defined() { + continue // only powerline delegations reach a brand-new bucket + } + proofs, links, err := delegations.ProofChain(ctx, accessKeyID, d.Command(), bucketID) + if err != nil { + return nil, nil, fmt.Errorf("building proof chain for %s: %w", d.Command(), err) + } + if len(proofs) == 0 { + continue + } + proofSet[d.Link()] = links + for _, p := range proofs { + if k := p.Link().String(); !seen[k] { + seen[k] = true + blocks = append(blocks, p) + } + } + } + + log.Debug("created bucket", zap.Int("delegations", len(proofSet))) + return &s3req.AuthorizeOK{ + Bucket: bucketID, + Permissions: s3.PermissionSet{Entries: map[did.DID][]string{ + accessKeyID: authz.AccessKey.Permissions, + }}, + Keys: s3.KeySet{Entries: map[did.DID][]s3.VerificationKey{ + accessKeyID: {{Kind: kind, Data: key}}, + }}, + Delegations: s3.ProofSet{Entries: proofSet}, + }, blocks, nil +} diff --git a/pkg/rpc/create_test.go b/pkg/rpc/create_test.go new file mode 100644 index 0000000..2cd6063 --- /dev/null +++ b/pkg/rpc/create_test.go @@ -0,0 +1,159 @@ +package rpc_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/sigv4" + "github.com/fil-forge/hilt/pkg/store" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + "github.com/fil-forge/hilt/pkg/store/tenant" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + "github.com/fil-forge/hilt/pkg/vault" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "github.com/fil-forge/libforge/commands/content" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/multiformats/go-multibase" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// fakeProvisioner is a stub SpaceProvisioner recording its inputs. +type fakeProvisioner struct { + sub string + err error + called bool + account did.DID + space did.DID +} + +func (f *fakeProvisioner) ProvisionSpace(_ context.Context, account ucan.Issuer, space did.DID) (string, error) { + f.called = true + f.account = account.DID() + f.space = space + return f.sub, f.err +} + +// signedCreateArgs presigns a CreateBucket request (path-style) for the bucket. +func signedCreateArgs(t *testing.T, signer ed25519.Signer, bucketName, region string) *s3bkt.CreateArguments { + t.Helper() + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "PUT", URL: "https://s3.fil.one/" + bucketName} + signed, err := sigv4.Presign(req, signer.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, time.Now(), time.Hour) + require.NoError(t, err) + return &s3bkt.CreateArguments{Request: s3.Request{Method: signed.Method, URL: signed.URL}} +} + +func TestCreateBucket(t *testing.T) { + ctx := t.Context() + const ( + region = "us-west-2" + bucketName = "newbucket" + ) + + // The access key signs the request. + akSigner, err := ed25519.Generate() + require.NoError(t, err) + akDID := akSigner.KeyDID() + + // The tenant signs the Sprue provisioning invocation and the powerline + // delegation; its secp256k1 key lives in the vault. + tenantSigner, err := secp256k1.Generate() + require.NoError(t, err) + tenantID := tenantSigner.KeyDID() + + providerID := testutil.RandomDID(t) + + // setup wires the stores + vault and seeds a powerline (undefined-subject) + // tenant→access-key delegation for /content/retrieve. + setup := func(t *testing.T, perms []string) (*auth.Authorizer, *bucketmemory.Store, *delegationmemory.Store) { + t.Helper() + accessKeys, tenants, buckets := accesskeymemory.New(), tenantmemory.New(), bucketmemory.New() + providers, secrets, delegations := providermemory.New(), vaultmemory.New(), delegationmemory.New() + require.NoError(t, providers.Add(ctx, providerID, region)) + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenant.Active)) + require.NoError(t, accessKeys.Add(ctx, akDID, tenantID, "k1", nil, perms, nil)) + require.NoError(t, secrets.Write(ctx, vault.AccessKeyPath(tenantID, akDID), akSigner.Bytes())) + require.NoError(t, secrets.Write(ctx, vault.TenantKeyPath(tenantID), tenantSigner.Bytes())) + + powerline, err := delegation.Delegate(multikey.NewIssuer(tenantID, tenantSigner), akDID, did.DID{}, content.Retrieve.Command) + require.NoError(t, err) + require.NoError(t, delegations.PutBatch(ctx, []ucan.Delegation{powerline})) + + return auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, buckets, secrets), buckets, delegations + } + + t.Run("creates and provisions the bucket, returning the powerline chain", func(t *testing.T) { + az, buckets, delegations := setup(t, []string{"s3:CreateBucket", "s3:GetObject"}) + prov := &fakeProvisioner{sub: "sub-1"} + + ok, blocks, err := rpc.CreateBucket(ctx, zap.NewNop(), az, buckets, delegations, prov, providerID, signedCreateArgs(t, akSigner, bucketName, region)) + require.NoError(t, err) + + // Bucket persisted under the tenant, with the returned DID. + rec, err := buckets.GetByName(ctx, bucketName) + require.NoError(t, err) + require.Equal(t, rec.ID, ok.Bucket) + require.Equal(t, tenantID, rec.Tenant) + + // Provisioned with Sprue as the tenant, for the new bucket. + require.True(t, prov.called) + require.Equal(t, tenantID, prov.account) + require.Equal(t, ok.Bucket, prov.space) + + // Permissions + a derived verification key for the access key. + require.Equal(t, []string{"s3:CreateBucket", "s3:GetObject"}, ok.Permissions.Entries[akDID]) + keys := ok.Keys.Entries[akDID] + require.Len(t, keys, 1) + require.Equal(t, s3.KeyKindSigV4, keys[0].Kind) + + // The powerline chain (root bucket→tenant + tenant→access-key) is returned. + require.Len(t, ok.Delegations.Entries, 1) + require.Len(t, blocks, 2) + var hasRoot bool + for _, b := range blocks { + if b.Subject() == ok.Bucket && b.Audience() == tenantID { + hasRoot = true + } + } + require.True(t, hasRoot, "expected a bucket→tenant root delegation among the returned blocks") + }) + + t.Run("rejects a key without s3:CreateBucket", func(t *testing.T) { + az, buckets, delegations := setup(t, []string{"s3:GetObject"}) + _, _, err := rpc.CreateBucket(ctx, zap.NewNop(), az, buckets, delegations, &fakeProvisioner{}, providerID, signedCreateArgs(t, akSigner, bucketName, region)) + require.Error(t, err) + }) + + t.Run("rejects a duplicate bucket name", func(t *testing.T) { + az, buckets, delegations := setup(t, []string{"s3:CreateBucket"}) + require.NoError(t, buckets.Add(ctx, testutil.RandomDID(t), tenantID, bucketName)) + _, _, err := rpc.CreateBucket(ctx, zap.NewNop(), az, buckets, delegations, &fakeProvisioner{}, providerID, signedCreateArgs(t, akSigner, bucketName, region)) + require.Error(t, err) + }) + + t.Run("rolls back the bucket when provisioning fails", func(t *testing.T) { + az, buckets, delegations := setup(t, []string{"s3:CreateBucket"}) + prov := &fakeProvisioner{err: errors.New("sprue unavailable")} + _, _, err := rpc.CreateBucket(ctx, zap.NewNop(), az, buckets, delegations, prov, providerID, signedCreateArgs(t, akSigner, bucketName, region)) + require.Error(t, err) + _, err = buckets.GetByName(ctx, bucketName) + require.ErrorIs(t, err, store.ErrRecordNotFound) + }) +} diff --git a/pkg/rpc/delete.go b/pkg/rpc/delete.go new file mode 100644 index 0000000..92d1e0a --- /dev/null +++ b/pkg/rpc/delete.go @@ -0,0 +1,97 @@ +package rpc + +import ( + "context" + "fmt" + + client "github.com/fil-forge/hilt/pkg/client" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/store/bucket" + delegationstore "github.com/fil-forge/hilt/pkg/store/delegation" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/server" + "go.uber.org/zap" +) + +// SpaceChecker reports whether a bucket's storage space is empty. It is +// satisfied by [*client.UploadClient]; the interface lets the handler logic be +// unit tested without a live Sprue. +type SpaceChecker interface { + SpaceEmpty(ctx context.Context, space did.DID, opts ...client.MethodOption) (bool, error) +} + +// NewDeleteBucketHandler handles /s3/bucket/delete — authenticate an AWS S3 +// DeleteBucket request, verify the bucket is empty (via Sprue), then remove its +// delegations and record. +func NewDeleteBucketHandler( + logger *zap.Logger, + authorizer *auth.Authorizer, + buckets bucket.Store, + delegations delegationstore.Store, + upload *client.UploadClient, +) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.Delete.Command)) + return s3bkt.Delete.Route(func(req *binding.Request[*s3bkt.DeleteArguments], res *binding.Response[*s3bkt.DeleteOK]) error { + ok, err := DeleteBucket(req.Context(), log, authorizer, buckets, delegations, upload, req.Invocation().Issuer(), req.Task().Arguments()) + if err != nil { + log.Error("delete bucket failed", zap.Error(err)) + return res.SetFailure(err) + } + return res.SetSuccess(ok) + }) +} + +// DeleteBucket authenticates the request, checks the s3:DeleteBucket permission, +// resolves the bucket, verifies its space is empty via Sprue (acting as the +// tenant), then deletes the bucket's delegations (subject == bucket) and the +// bucket record. It is factored out of the handler so it can be unit tested +// without a UCAN invocation. +func DeleteBucket( + ctx context.Context, + logger *zap.Logger, + authorizer *auth.Authorizer, + buckets bucket.Store, + delegations delegationstore.Store, + checker SpaceChecker, + issuer did.DID, + args *s3bkt.DeleteArguments, +) (*s3bkt.DeleteOK, error) { + authz, err := authorizer.Authorize(ctx, issuer, args.Request) + if err != nil { + return nil, err + } + + if authz.Operation != auth.OpDeleteBucket { + return nil, fmt.Errorf("request is not a DeleteBucket operation: %s", authz.Operation) + } + + // Verify the bucket is empty, listing its blobs via Sprue as the tenant (the + // bucket→tenant root delegation authorizes the /blob/list invocation). + account, err := authorizer.TenantIssuer(ctx, authz.Tenant.ID) + if err != nil { + return nil, err + } + empty, err := checker.SpaceEmpty(ctx, authz.Bucket.ID, client.WithIssuer(account), client.WithProofs(delegations)) + if err != nil { + return nil, fmt.Errorf("checking bucket is empty: %w", err) + } + if !empty { + return nil, fmt.Errorf("bucket %q is not empty", authz.BucketName) + } + + // TODO: revoke the delegations where subject == bucket, via external + // revocation service to inform Ingot that these are no longer valid. + + // Remove the bucket's delegations (subject == bucket), then the record. + if err := delegations.DeleteBySubject(ctx, authz.Bucket.ID); err != nil { + return nil, fmt.Errorf("deleting bucket delegations: %w", err) + } + if err := buckets.Delete(ctx, authz.Bucket.ID); err != nil { + return nil, fmt.Errorf("deleting bucket: %w", err) + } + + logger.Debug("deleted bucket", zap.Stringer("bucket", authz.Bucket.ID), zap.String("name", authz.BucketName)) + return &s3bkt.DeleteOK{}, nil +} diff --git a/pkg/rpc/delete_test.go b/pkg/rpc/delete_test.go new file mode 100644 index 0000000..eaf9b52 --- /dev/null +++ b/pkg/rpc/delete_test.go @@ -0,0 +1,155 @@ +package rpc_test + +import ( + "context" + "errors" + "testing" + "time" + + client "github.com/fil-forge/hilt/pkg/client" + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/sigv4" + "github.com/fil-forge/hilt/pkg/store" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + "github.com/fil-forge/hilt/pkg/store/tenant" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + "github.com/fil-forge/hilt/pkg/vault" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "github.com/fil-forge/libforge/commands/content" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/multiformats/go-multibase" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// fakeSpaceChecker is a stub SpaceChecker recording its inputs. +type fakeSpaceChecker struct { + empty bool + err error + called bool + space did.DID +} + +func (f *fakeSpaceChecker) SpaceEmpty(_ context.Context, space did.DID, _ ...client.MethodOption) (bool, error) { + f.called = true + f.space = space + return f.empty, f.err +} + +// signedDeleteArgs presigns a DeleteBucket request (path-style) for the bucket. +func signedDeleteArgs(t *testing.T, signer ed25519.Signer, bucketName, region string) *s3bkt.DeleteArguments { + t.Helper() + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "DELETE", URL: "https://s3.fil.one/" + bucketName} + signed, err := sigv4.Presign(req, signer.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, time.Now(), time.Hour) + require.NoError(t, err) + return &s3bkt.DeleteArguments{Request: s3.Request{Method: signed.Method, URL: signed.URL}} +} + +func TestDeleteBucket(t *testing.T) { + ctx := t.Context() + const ( + region = "us-west-2" + bucketName = "delbucket" + ) + + akSigner, err := ed25519.Generate() + require.NoError(t, err) + akDID := akSigner.KeyDID() + + tenantSigner, err := secp256k1.Generate() + require.NoError(t, err) + tenantID := tenantSigner.KeyDID() + + providerID := testutil.RandomDID(t) + + // setup wires stores + vault and seeds the bucket, its bucket→tenant root, and + // a bucket-scoped tenant→access-key grant (both subject == bucket). + setup := func(t *testing.T, perms []string) (*auth.Authorizer, *bucketmemory.Store, *delegationmemory.Store, did.DID) { + t.Helper() + accessKeys, tenants, buckets := accesskeymemory.New(), tenantmemory.New(), bucketmemory.New() + providers, secrets, delegations := providermemory.New(), vaultmemory.New(), delegationmemory.New() + require.NoError(t, providers.Add(ctx, providerID, region)) + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenant.Active)) + require.NoError(t, accessKeys.Add(ctx, akDID, tenantID, "k1", nil, perms, nil)) + require.NoError(t, secrets.Write(ctx, vault.AccessKeyPath(tenantID, akDID), akSigner.Bytes())) + require.NoError(t, secrets.Write(ctx, vault.TenantKeyPath(tenantID), tenantSigner.Bytes())) + + bucketSigner, err := ed25519.Generate() + require.NoError(t, err) + bucketID := bucketSigner.KeyDID() + require.NoError(t, buckets.Add(ctx, bucketID, tenantID, bucketName)) + + root, err := delegation.Delegate(multikey.NewIssuer(bucketID, bucketSigner), tenantID, bucketID, command.Top()) + require.NoError(t, err) + grant, err := delegation.Delegate(multikey.NewIssuer(tenantID, tenantSigner), akDID, bucketID, content.Retrieve.Command) + require.NoError(t, err) + require.NoError(t, delegations.PutBatch(ctx, []ucan.Delegation{root, grant})) + + return auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, buckets, secrets), buckets, delegations, bucketID + } + + t.Run("deletes an empty bucket and its delegations", func(t *testing.T) { + az, buckets, delegations, bucketID := setup(t, []string{"s3:DeleteBucket"}) + checker := &fakeSpaceChecker{empty: true} + + _, err := rpc.DeleteBucket(ctx, zap.NewNop(), az, buckets, delegations, checker, providerID, signedDeleteArgs(t, akSigner, bucketName, region)) + require.NoError(t, err) + + require.True(t, checker.called) + require.Equal(t, bucketID, checker.space) + + // Bucket record is gone. + _, err = buckets.GetByName(ctx, bucketName) + require.ErrorIs(t, err, store.ErrRecordNotFound) + + // The subject == bucket delegations (root to the tenant, grant to the access + // key) are gone. + rootPage, err := delegations.ListByAudience(ctx, tenantID) + require.NoError(t, err) + require.Empty(t, rootPage.Results) + grantPage, err := delegations.ListByAudience(ctx, akDID) + require.NoError(t, err) + require.Empty(t, grantPage.Results) + }) + + t.Run("rejects a key without s3:DeleteBucket", func(t *testing.T) { + az, buckets, delegations, _ := setup(t, []string{"s3:GetObject"}) + _, err := rpc.DeleteBucket(ctx, zap.NewNop(), az, buckets, delegations, &fakeSpaceChecker{empty: true}, providerID, signedDeleteArgs(t, akSigner, bucketName, region)) + require.Error(t, err) + }) + + t.Run("rejects an unknown bucket", func(t *testing.T) { + az, buckets, delegations, _ := setup(t, []string{"s3:DeleteBucket"}) + _, err := rpc.DeleteBucket(ctx, zap.NewNop(), az, buckets, delegations, &fakeSpaceChecker{empty: true}, providerID, signedDeleteArgs(t, akSigner, "nope", region)) + require.Error(t, err) + }) + + t.Run("rejects a non-empty bucket and keeps it", func(t *testing.T) { + az, buckets, delegations, _ := setup(t, []string{"s3:DeleteBucket"}) + _, err := rpc.DeleteBucket(ctx, zap.NewNop(), az, buckets, delegations, &fakeSpaceChecker{empty: false}, providerID, signedDeleteArgs(t, akSigner, bucketName, region)) + require.Error(t, err) + _, err = buckets.GetByName(ctx, bucketName) + require.NoError(t, err) // not deleted + }) + + t.Run("propagates a SpaceEmpty error", func(t *testing.T) { + az, buckets, delegations, _ := setup(t, []string{"s3:DeleteBucket"}) + _, err := rpc.DeleteBucket(ctx, zap.NewNop(), az, buckets, delegations, &fakeSpaceChecker{err: errors.New("sprue unavailable")}, providerID, signedDeleteArgs(t, akSigner, bucketName, region)) + require.Error(t, err) + }) +} diff --git a/pkg/rpc/info.go b/pkg/rpc/info.go new file mode 100644 index 0000000..9ffe009 --- /dev/null +++ b/pkg/rpc/info.go @@ -0,0 +1,128 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + + "github.com/fil-forge/hilt/pkg/store" + "github.com/fil-forge/hilt/pkg/store/accesskey" + "github.com/fil-forge/hilt/pkg/store/bucket" + delegationstore "github.com/fil-forge/hilt/pkg/store/delegation" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/container" + "github.com/ipfs/go-cid" + "go.uber.org/zap" +) + +// NewBucketInfoHandler handles /s3/bucket/info — look up a bucket by name and +// return its DID, the access key's S3 permissions, and the delegation proof +// chain(s) from the bucket to that access key. It is a lookup: it carries no +// signed S3 request, so it neither authenticates a signature nor checks the +// invocation issuer. +func NewBucketInfoHandler( + logger *zap.Logger, + buckets bucket.Store, + accessKeys accesskey.Store, + delegations delegationstore.Store, +) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.Info.Command)) + return s3bkt.Info.Route(func(req *binding.Request[*s3bkt.InfoArguments], res *binding.Response[*s3bkt.InfoOK]) error { + ok, dlgs, err := BucketInfo(req.Context(), log, buckets, accessKeys, delegations, req.Task().Arguments()) + if err != nil { + log.Error("bucket info failed", zap.Error(err)) + return res.SetFailure(err) + } + // The delegation map in the result carries only CIDs; the blocks ride back + // in the response container via the metadata. + if len(dlgs) > 0 { + if err := res.SetMetadata(container.New(container.WithDelegations(dlgs...))); err != nil { + log.Error("attaching delegations", zap.Error(err)) + return err + } + } + return res.SetSuccess(ok) + }) +} + +// BucketInfo resolves the named bucket and returns its DID, the access key's +// permissions, and the proof chains for the access key's delegations that reach +// the bucket. It is factored out of the handler so it can be unit tested without +// a UCAN invocation. +func BucketInfo( + ctx context.Context, + logger *zap.Logger, + buckets bucket.Store, + accessKeys accesskey.Store, + delegations delegationstore.Store, + args *s3bkt.InfoArguments, +) (*s3bkt.InfoOK, []ucan.Delegation, error) { + b, err := buckets.GetByName(ctx, args.Name) + if errors.Is(err, store.ErrRecordNotFound) { + return nil, nil, fmt.Errorf("unknown bucket %q", args.Name) + } else if err != nil { + return nil, nil, fmt.Errorf("looking up bucket: %w", err) + } + + akRec, err := accessKeys.Get(ctx, args.AccessKey) + if errors.Is(err, store.ErrRecordNotFound) { + return nil, nil, fmt.Errorf("unknown access key %q", args.AccessKey) + } else if err != nil { + return nil, nil, fmt.Errorf("looking up access key: %w", err) + } + + // Build the proof chains from the bucket to the access key: for each grant to + // the access key that reaches this bucket (scoped to it or powerline), resolve + // its chain up to the bucket→tenant root. + stored, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[ucan.Delegation], error) { + var o []store.PaginationOption + if opts.Cursor != nil { + o = append(o, store.WithCursor(*opts.Cursor)) + } + return delegations.ListByAudience(ctx, args.AccessKey, o...) + }) + if err != nil { + return nil, nil, fmt.Errorf("listing delegations: %w", err) + } + + proofSet := map[cid.Cid][]cid.Cid{} + var blocks []ucan.Delegation + seen := map[string]bool{} + for _, d := range stored { + if d.Subject().Defined() && d.Subject() != b.ID { + continue // grant scoped to a different bucket + } + proofs, links, err := delegations.ProofChain(ctx, args.AccessKey, d.Command(), b.ID) + if err != nil { + return nil, nil, fmt.Errorf("building proof chain for %s: %w", d.Command(), err) + } + if len(proofs) == 0 { + continue + } + proofSet[d.Link()] = links + for _, p := range proofs { + if k := p.Link().String(); !seen[k] { + seen[k] = true + blocks = append(blocks, p) + } + } + } + + logger.Debug("bucket info", + zap.Stringer("bucket", b.ID), + zap.String("name", args.Name), + zap.Int("delegations", len(proofSet)), + ) + return &s3bkt.InfoOK{ + ID: b.ID, + Permissions: s3.PermissionSet{Entries: map[did.DID][]string{ + args.AccessKey: akRec.Permissions, + }}, + Delegations: s3.ProofSet{Entries: proofSet}, + }, blocks, nil +} diff --git a/pkg/rpc/info_test.go b/pkg/rpc/info_test.go new file mode 100644 index 0000000..aa13985 --- /dev/null +++ b/pkg/rpc/info_test.go @@ -0,0 +1,90 @@ +package rpc_test + +import ( + "testing" + + "github.com/fil-forge/hilt/pkg/rpc" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" + "github.com/fil-forge/libforge/commands/content" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestBucketInfo(t *testing.T) { + ctx := t.Context() + const bucketName = "infobucket" + + akSigner, err := ed25519.Generate() + require.NoError(t, err) + akDID := akSigner.KeyDID() + + tenantSigner, err := secp256k1.Generate() + require.NoError(t, err) + tenantID := tenantSigner.KeyDID() + + bucketSigner, err := ed25519.Generate() + require.NoError(t, err) + bucketID := bucketSigner.KeyDID() + + // setup seeds a bucket, an access key, a bucket→tenant root, and a + // tenant→access-key grant with the given subject (did.DID{} = powerline). + setup := func(t *testing.T, grantSubject did.DID) (*bucketmemory.Store, *accesskeymemory.Store, *delegationmemory.Store) { + t.Helper() + accessKeys, buckets, delegations := accesskeymemory.New(), bucketmemory.New(), delegationmemory.New() + require.NoError(t, accessKeys.Add(ctx, akDID, tenantID, "k1", nil, []string{"s3:GetObject"}, nil)) + require.NoError(t, buckets.Add(ctx, bucketID, tenantID, bucketName)) + + root, err := delegation.Delegate(multikey.NewIssuer(bucketID, bucketSigner), tenantID, bucketID, command.Top()) + require.NoError(t, err) + grant, err := delegation.Delegate(multikey.NewIssuer(tenantID, tenantSigner), akDID, grantSubject, content.Retrieve.Command) + require.NoError(t, err) + require.NoError(t, delegations.PutBatch(ctx, []ucan.Delegation{root, grant})) + + return buckets, accessKeys, delegations + } + + t.Run("returns the bucket, permissions, and delegation chain", func(t *testing.T) { + buckets, accessKeys, delegations := setup(t, did.DID{}) // powerline grant reaches the bucket + ok, blocks, err := rpc.BucketInfo(ctx, zap.NewNop(), buckets, accessKeys, delegations, &s3bkt.InfoArguments{Name: bucketName, AccessKey: akDID}) + require.NoError(t, err) + + require.Equal(t, bucketID, ok.ID) + require.Equal(t, []string{"s3:GetObject"}, ok.Permissions.Entries[akDID]) + require.Len(t, ok.Delegations.Entries, 1) + require.Len(t, blocks, 2) // bucket→tenant root + tenant→access-key grant + }) + + t.Run("rejects an unknown bucket", func(t *testing.T) { + buckets, accessKeys, delegations := setup(t, did.DID{}) + _, _, err := rpc.BucketInfo(ctx, zap.NewNop(), buckets, accessKeys, delegations, &s3bkt.InfoArguments{Name: "nope", AccessKey: akDID}) + require.Error(t, err) + }) + + t.Run("rejects an unknown access key", func(t *testing.T) { + buckets, accessKeys, delegations := setup(t, did.DID{}) + _, _, err := rpc.BucketInfo(ctx, zap.NewNop(), buckets, accessKeys, delegations, &s3bkt.InfoArguments{Name: bucketName, AccessKey: testutil.RandomDID(t)}) + require.Error(t, err) + }) + + t.Run("returns empty delegations when no grant reaches the bucket", func(t *testing.T) { + buckets, accessKeys, delegations := setup(t, testutil.RandomDID(t)) // grant scoped to a different bucket + ok, blocks, err := rpc.BucketInfo(ctx, zap.NewNop(), buckets, accessKeys, delegations, &s3bkt.InfoArguments{Name: bucketName, AccessKey: akDID}) + require.NoError(t, err) + + require.Equal(t, bucketID, ok.ID) + require.Equal(t, []string{"s3:GetObject"}, ok.Permissions.Entries[akDID]) + require.Empty(t, ok.Delegations.Entries) + require.Empty(t, blocks) + }) +} diff --git a/pkg/rpc/list.go b/pkg/rpc/list.go new file mode 100644 index 0000000..5602820 --- /dev/null +++ b/pkg/rpc/list.go @@ -0,0 +1,86 @@ +package rpc + +import ( + "context" + "fmt" + "time" + + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/store" + "github.com/fil-forge/hilt/pkg/store/bucket" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/server" + "go.uber.org/zap" +) + +// NewListBucketsHandler handles /s3/bucket/list — list the tenant's buckets. The +// caller is identified and authenticated by the access key in the request's +// SigV4/SigV4a signature. +func NewListBucketsHandler( + logger *zap.Logger, + authorizer *auth.Authorizer, + buckets bucket.Store, +) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.List.Command)) + return s3bkt.List.Route(func(req *binding.Request[*s3bkt.ListArguments], res *binding.Response[*s3bkt.ListOK]) error { + ok, err := ListBuckets(req.Context(), log, authorizer, buckets, req.Invocation().Issuer(), req.Task().Arguments()) + if err != nil { + log.Error("list buckets failed", zap.Error(err)) + return res.SetFailure(err) + } + return res.SetSuccess(ok) + }) +} + +// ListBuckets authorizes the request (see [auth.Authorizer.Authorize], which also +// verifies the access key holds the operation's permission), confirms the request +// is a ListBuckets operation, and returns the tenant's buckets. It is factored out +// of the handler so it can be unit tested without constructing a UCAN invocation. +func ListBuckets( + ctx context.Context, + logger *zap.Logger, + authorizer *auth.Authorizer, + buckets bucket.Store, + issuer did.DID, + args *s3bkt.ListArguments, +) (*s3bkt.ListOK, error) { + authz, err := authorizer.Authorize(ctx, issuer, args.Request) + if err != nil { + return nil, err + } + // Bind the verified signature to this handler's operation: reject a + // (validly-signed) request for any other S3 operation. + if authz.Operation != auth.OpListBuckets { + return nil, fmt.Errorf("request is not a ListBuckets operation: %s", authz.Operation) + } + + recs, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[bucket.Record], error) { + var listOpts []bucket.ListOption + if opts.Cursor != nil { + listOpts = append(listOpts, bucket.WithCursor(*opts.Cursor)) + } + return buckets.ListByTenant(ctx, authz.Tenant.ID, listOpts...) + }) + if err != nil { + return nil, fmt.Errorf("listing buckets: %w", err) + } + + out := &s3bkt.ListOK{ + Buckets: make([]s3bkt.Bucket, 0, len(recs)), + Owner: s3bkt.Owner{ + DisplayName: authz.Tenant.Name, + ID: authz.Tenant.ID.String(), + }, + } + for _, b := range recs { + out.Buckets = append(out.Buckets, s3bkt.Bucket{ + ARN: "arn:aws:s3:::" + b.Name, + Region: authz.Region, + CreationDate: b.CreatedAt.UTC().Format(time.RFC3339), + Name: b.Name, + }) + } + return out, nil +} diff --git a/pkg/rpc/list_test.go b/pkg/rpc/list_test.go new file mode 100644 index 0000000..e6fb1c1 --- /dev/null +++ b/pkg/rpc/list_test.go @@ -0,0 +1,113 @@ +package rpc_test + +import ( + "testing" + "time" + + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/sigv4" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + "github.com/fil-forge/hilt/pkg/store/tenant" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + "github.com/fil-forge/hilt/pkg/vault" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + s3 "github.com/fil-forge/libforge/commands/s3" + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/multiformats/go-multibase" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// signedListArgs builds ListArguments whose request is presigned for the given +// access key signer and region. +func signedListArgs(t *testing.T, signer ed25519.Signer, region string, signedAt time.Time, expires time.Duration) *s3bkt.ListArguments { + t.Helper() + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "GET", URL: "https://" + region + ".s3.fil.one/?x-id=ListBuckets"} + signed, err := sigv4.Presign(req, signer.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, signedAt, expires) + require.NoError(t, err) + return &s3bkt.ListArguments{Request: s3.Request{ + Method: signed.Method, + URL: signed.URL, + }} +} + +func TestListBuckets(t *testing.T) { + ctx := t.Context() + const region = "us-west-2" + + signer, err := ed25519.Generate() + require.NoError(t, err) + akDID := signer.KeyDID() + + // providerID is both the tenant's provider and the only legitimate + // invocation issuer. + providerID := testutil.RandomDID(t) + + // setup wires up the stores + vault for a tenant whose provider serves the + // signing region and that owns this access key, and returns the Authorizer + // built from them plus the bucket store subtests populate. Authentication and + // authorization edge cases are covered by the auth service's own test suite; + // these tests focus on the list-specific behavior. + setup := func(t *testing.T, perms []string) (*auth.Authorizer, *bucketmemory.Store, did.DID) { + t.Helper() + accessKeys, tenants, buckets := accesskeymemory.New(), tenantmemory.New(), bucketmemory.New() + providers, secrets := providermemory.New(), vaultmemory.New() + require.NoError(t, providers.Add(ctx, providerID, region)) + tenantID := testutil.RandomDID(t) + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenant.Active)) + require.NoError(t, accessKeys.Add(ctx, akDID, tenantID, "k1", nil, perms, nil)) + require.NoError(t, secrets.Write(ctx, vault.AccessKeyPath(tenantID, akDID), signer.Bytes())) + az := auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, buckets, secrets) + return az, buckets, tenantID + } + + t.Run("lists the tenant's buckets for a validly-signed request", func(t *testing.T) { + az, buckets, tenantID := setup(t, []string{"s3:ListAllMyBuckets"}) + require.NoError(t, buckets.Add(ctx, testutil.RandomDID(t), tenantID, "alpha")) + require.NoError(t, buckets.Add(ctx, testutil.RandomDID(t), tenantID, "bravo")) + + ok, err := rpc.ListBuckets(ctx, zap.NewNop(), az, buckets, providerID, signedListArgs(t, signer, region, time.Now(), time.Hour)) + require.NoError(t, err) + require.Equal(t, "Acme", ok.Owner.DisplayName) + require.Equal(t, tenantID.String(), ok.Owner.ID) + require.Len(t, ok.Buckets, 2) + + byName := map[string]s3bkt.Bucket{} + for _, b := range ok.Buckets { + byName[b.Name] = b + } + require.Equal(t, "arn:aws:s3:::alpha", byName["alpha"].ARN) + require.Equal(t, region, byName["alpha"].Region) + require.NotEmpty(t, byName["alpha"].CreationDate) + require.Contains(t, byName, "bravo") + }) + + t.Run("rejects a key without the list permission", func(t *testing.T) { + az, buckets, _ := setup(t, []string{"s3:GetObject"}) + _, err := rpc.ListBuckets(ctx, zap.NewNop(), az, buckets, providerID, signedListArgs(t, signer, region, time.Now(), time.Hour)) + require.Error(t, err) + }) + + t.Run("rejects a validly-signed request for a different operation", func(t *testing.T) { + // The key holds s3:GetObject, so a GetObject request passes Authorize — but + // the list handler must reject it because it is not a ListBuckets operation. + az, buckets, tenantID := setup(t, []string{"s3:GetObject"}) + require.NoError(t, buckets.Add(ctx, testutil.RandomDID(t), tenantID, "bucket-a")) + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "GET", URL: "https://" + region + ".s3.fil.one/bucket-a/object-key"} + signed, err := sigv4.Presign(req, akDID.Identifier(), secret, region, sigv4.SchemeV4, time.Now(), time.Hour) + require.NoError(t, err) + args := &s3bkt.ListArguments{Request: s3.Request{Method: signed.Method, URL: signed.URL}} + _, err = rpc.ListBuckets(ctx, zap.NewNop(), az, buckets, providerID, args) + require.Error(t, err) + }) +} diff --git a/pkg/rpc/rpc.go b/pkg/rpc/rpc.go index 11e8742..e2ef5ee 100644 --- a/pkg/rpc/rpc.go +++ b/pkg/rpc/rpc.go @@ -1,68 +1,8 @@ // Package rpc implements the Hilt UCAN RPC API — the S3 commands Ingot invokes -// on Hilt (see the Forge S3 tenant-management RFC). Handlers are exposed as -// [server.Route] values, collected via fx and registered on the UCAN server. -// -// The handlers are currently stubs that report "not implemented"; the bodies -// will be filled in a later pass. +// on Hilt (see the Forge S3 tenant-management RFC). Each command is exposed as a +// [github.com/fil-forge/ucantone/server.Route] via its New*Handler constructor, +// collected via fx and registered on the UCAN server: /s3/request/authorize +// (authorize.go), /s3/bucket/{create,delete,info,list} (create.go, delete.go, +// info.go, list.go). Authentication and authorization shared by the +// signature-bearing commands live in the auth service (service/auth). package rpc - -import ( - "errors" - - s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" - s3req "github.com/fil-forge/libforge/commands/s3/request" - "github.com/fil-forge/ucantone/binding" - "github.com/fil-forge/ucantone/server" - "go.uber.org/zap" -) - -var errNotImplemented = errors.New("not implemented") - -// NewAuthorizeRequestHandler handles /s3/request/authorize — authorize an AWS S3 -// API request and return the derived signing key and delegations. -func NewAuthorizeRequestHandler(logger *zap.Logger) server.Route { - log := logger.With(zap.Stringer("command", s3req.Authorize.Command)) - return s3req.Authorize.Route(func(req *binding.Request[*s3req.AuthorizeArguments], res *binding.Response[*s3req.AuthorizeOK]) error { - log.Debug("not implemented") - return errNotImplemented - }) -} - -// NewCreateBucketHandler handles /s3/bucket/create — create a bucket and -// provision it with Sprue. -func NewCreateBucketHandler(logger *zap.Logger) server.Route { - log := logger.With(zap.Stringer("command", s3bkt.Create.Command)) - return s3bkt.Create.Route(func(req *binding.Request[*s3bkt.CreateArguments], res *binding.Response[*s3req.AuthorizeOK]) error { - log.Debug("not implemented") - return errNotImplemented - }) -} - -// NewDeleteBucketHandler handles /s3/bucket/delete — delete an empty bucket and -// revoke the delegations that grant access to it. -func NewDeleteBucketHandler(logger *zap.Logger) server.Route { - log := logger.With(zap.Stringer("command", s3bkt.Delete.Command)) - return s3bkt.Delete.Route(func(req *binding.Request[*s3bkt.DeleteArguments], res *binding.Response[*s3bkt.DeleteOK]) error { - log.Debug("not implemented") - return errNotImplemented - }) -} - -// NewBucketInfoHandler handles /s3/bucket/info — return a bucket DID and the -// delegation chain to the given access key. -func NewBucketInfoHandler(logger *zap.Logger) server.Route { - log := logger.With(zap.Stringer("command", s3bkt.Info.Command)) - return s3bkt.Info.Route(func(req *binding.Request[*s3bkt.InfoArguments], res *binding.Response[*s3bkt.InfoOK]) error { - log.Debug("not implemented") - return errNotImplemented - }) -} - -// NewListBucketsHandler handles /s3/bucket/list — list the tenant's buckets. -func NewListBucketsHandler(logger *zap.Logger) server.Route { - log := logger.With(zap.Stringer("command", s3bkt.List.Command)) - return s3bkt.List.Route(func(req *binding.Request[*s3bkt.ListArguments], res *binding.Response[*s3bkt.ListOK]) error { - log.Debug("not implemented") - return errNotImplemented - }) -} diff --git a/pkg/rpc/rpc_test.go b/pkg/rpc/rpc_test.go index 7663c67..5f649e9 100644 --- a/pkg/rpc/rpc_test.go +++ b/pkg/rpc/rpc_test.go @@ -1,31 +1,59 @@ package rpc_test import ( + "net/url" "testing" + "github.com/fil-forge/hilt/pkg/client" "github.com/fil-forge/hilt/pkg/rpc" - "github.com/fil-forge/ucantone/server" + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "github.com/fil-forge/libforge/testutil" "github.com/stretchr/testify/require" "go.uber.org/zap" ) +// TestHandlerCommands checks each handler constructor wires up the right command +// and a non-nil handler. func TestHandlerCommands(t *testing.T) { - cases := []struct { - name string - route func(*zap.Logger) server.Route - command string - }{ - {"authorize", rpc.NewAuthorizeRequestHandler, "/s3/request/authorize"}, - {"create", rpc.NewCreateBucketHandler, "/s3/bucket/create"}, - {"delete", rpc.NewDeleteBucketHandler, "/s3/bucket/delete"}, - {"info", rpc.NewBucketInfoHandler, "/s3/bucket/info"}, - {"list", rpc.NewListBucketsHandler, "/s3/bucket/list"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - route := tc.route(zap.NewNop()) - require.Equal(t, tc.command, route.Command.String()) - require.NotNil(t, route.Handler) - }) - } + az := auth.NewAuthorizer(zap.NewNop(), accesskeymemory.New(), tenantmemory.New(), providermemory.New(), bucketmemory.New(), vaultmemory.New()) + + t.Run("list", func(t *testing.T) { + route := rpc.NewListBucketsHandler(zap.NewNop(), az, bucketmemory.New()) + require.Equal(t, "/s3/bucket/list", route.Command.String()) + require.NotNil(t, route.Handler) + }) + + t.Run("authorize", func(t *testing.T) { + route := rpc.NewAuthorizeRequestHandler(zap.NewNop(), az) + require.Equal(t, "/s3/request/authorize", route.Command.String()) + require.NotNil(t, route.Handler) + }) + + t.Run("create", func(t *testing.T) { + up, err := client.NewUploadClient(testutil.RandomDID(t), url.URL{Scheme: "http", Host: "sprue.test"}, testutil.RandomIssuer(t), delegationmemory.New()) + require.NoError(t, err) + route := rpc.NewCreateBucketHandler(zap.NewNop(), az, bucketmemory.New(), delegationmemory.New(), up) + require.Equal(t, "/s3/bucket/create", route.Command.String()) + require.NotNil(t, route.Handler) + }) + + t.Run("delete", func(t *testing.T) { + up, err := client.NewUploadClient(testutil.RandomDID(t), url.URL{Scheme: "http", Host: "sprue.test"}, testutil.RandomIssuer(t), delegationmemory.New()) + require.NoError(t, err) + route := rpc.NewDeleteBucketHandler(zap.NewNop(), az, bucketmemory.New(), delegationmemory.New(), up) + require.Equal(t, "/s3/bucket/delete", route.Command.String()) + require.NotNil(t, route.Handler) + }) + + t.Run("info", func(t *testing.T) { + route := rpc.NewBucketInfoHandler(zap.NewNop(), bucketmemory.New(), accesskeymemory.New(), delegationmemory.New()) + require.Equal(t, "/s3/bucket/info", route.Command.String()) + require.NotNil(t, route.Handler) + }) } diff --git a/pkg/rpc/service/auth/auth.go b/pkg/rpc/service/auth/auth.go new file mode 100644 index 0000000..d4a41aa --- /dev/null +++ b/pkg/rpc/service/auth/auth.go @@ -0,0 +1,274 @@ +// Package auth provides the request authorization service for the Hilt UCAN RPC +// handlers: it authenticates SigV4/SigV4a signatures, resolves the access key +// and tenant, and enforces the provider/region constraints shared by every S3 +// command. +package auth + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/fil-forge/hilt/pkg/sigv4" + "github.com/fil-forge/hilt/pkg/store" + "github.com/fil-forge/hilt/pkg/store/accesskey" + "github.com/fil-forge/hilt/pkg/store/bucket" + "github.com/fil-forge/hilt/pkg/store/provider" + "github.com/fil-forge/hilt/pkg/store/tenant" + "github.com/fil-forge/hilt/pkg/vault" + s3 "github.com/fil-forge/libforge/commands/s3" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/multiformats/go-multibase" + "go.uber.org/zap" +) + +// AuthorizedRequest is the authenticated, authorized context of an S3 RPC +// request: the verified caller's access key and tenant, and the region the +// request is scoped to (served by the tenant's provider). Command-specific +// permission checks use AccessKey.Permissions. +type AuthorizedRequest struct { + AccessKey accesskey.Record + Tenant tenant.Record + Region string + // Operation is the S3 operation the (signature-verified) request performs. The + // access key is confirmed to hold its permission; handlers check it matches the + // operation they serve. + Operation Operation + // BucketName is the bucket name from the request. + BucketName string + // Bucket is the resolved bucket the request addresses, when the operation acts + // on an existing bucket. It is confirmed to belong to the tenant and to be + // within the access key's bucket scope. Nil for ListBuckets and CreateBucket. + Bucket *bucket.Record + // Signed is the parsed, verified request signature. Handlers use it to derive + // the verification key and to inspect the requested action. + Signed *sigv4.SignedRequest +} + +// Authorizer authenticates and authorizes S3 RPC requests. It is the shared +// authorization service injected into the S3 command handlers. +type Authorizer struct { + logger *zap.Logger + accessKeys accesskey.Store + tenants tenant.Store + providers provider.Store + buckets bucket.Store + secrets vault.Vault +} + +// NewAuthorizer constructs the shared authorization service. +func NewAuthorizer( + logger *zap.Logger, + accessKeys accesskey.Store, + tenants tenant.Store, + providers provider.Store, + buckets bucket.Store, + secrets vault.Vault, +) *Authorizer { + return &Authorizer{ + logger: logger, + accessKeys: accessKeys, + tenants: tenants, + providers: providers, + buckets: buckets, + secrets: secrets, + } +} + +// Authorize authenticates and authorizes an S3 RPC request. It verifies the +// SigV4/SigV4a signature and time bounds, resolves the access key and its +// tenant, confirms the invocation issuer is the tenant's provider, and +// validates the request region against that provider. +// +// Finally, the requested S3 operation is checked against the access key's +// permissions. Note that the caller must still check the operation matches the +// handler's operation, since Authorize is operation-agnostic. +func (a *Authorizer) Authorize(ctx context.Context, issuer did.DID, req s3.Request) (*AuthorizedRequest, error) { + sr, err := sigv4.Parse(sigv4.Request{ + Method: req.Method, + Headers: req.Headers, + URL: req.URL, + }) + if err != nil { + a.logger.Debug("rejecting unparseable request signature", zap.Error(err)) + return nil, ErrMalformedSignature + } + log := a.logger.With(zap.String("access_key", sr.AccessKeyID), zap.Strings("regions", sr.Regions)) + log.Debug("authorizing request") + + accessKeyID, err := did.Parse(did.KeyPrefix + sr.AccessKeyID) + if err != nil { + log.Debug("rejecting invalid access key id", zap.Error(err)) + return nil, ErrInvalidAccessKeyID + } + + akRec, err := a.accessKeys.Get(ctx, accessKeyID) + if errors.Is(err, store.ErrRecordNotFound) { + log.Debug("rejecting unknown access key") + return nil, ErrUnknownAccessKey + } else if err != nil { + log.Error("looking up access key", zap.Error(err)) + return nil, fmt.Errorf("looking up access key: %w", err) + } + log = log.With(zap.Stringer("tenant", akRec.Tenant)) + + // Reject expired access keys before touching the vault. ValidateTimeBounds + // (below) bounds the signature's freshness, not the credential's lifetime. + if akRec.ExpiresAt != nil && time.Now().After(*akRec.ExpiresAt) { + log.Debug("rejecting expired access key", zap.Timep("expires_at", akRec.ExpiresAt)) + return nil, ErrAccessKeyExpired + } + + // Authenticate: verify the request signature using the access key's secret. + signer, err := a.AccessKeySigner(ctx, akRec.Tenant, accessKeyID) + if err != nil { + log.Error("loading access key", zap.Error(err)) + return nil, err + } + secret, err := EncodeSecret(signer) + if err != nil { + return nil, err + } + if err := sigv4.Verify(sr, secret); err != nil { + log.Debug("rejecting invalid request signature", zap.Error(err)) + return nil, ErrSignatureMismatch + } + if err := sigv4.ValidateTimeBounds(sr, time.Now()); err != nil { + log.Debug("rejecting request outside its validity window", zap.Error(err)) + return nil, ErrSignatureExpired + } + + tenantRec, err := a.tenants.Get(ctx, akRec.Tenant) + if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return nil, fmt.Errorf("looking up tenant: %w", err) + } + log = log.With(zap.Stringer("provider", tenantRec.Provider)) + + // Disabled is the hard lock-out state (lifecycle Active → Disabled → delete). + // WriteLocked still authenticates here so reads (like ListBuckets) work; write + // handlers gate WriteLocked themselves, since Authorize is operation-agnostic. + if tenantRec.Status == tenant.Disabled { + log.Debug("rejecting disabled tenant") + return nil, ErrTenantDisabled + } + + // Only the tenant's provider may invoke on its behalf. + if issuer != tenantRec.Provider { + log.Debug("rejecting invocation not from the tenant's provider", zap.Stringer("issuer", issuer)) + return nil, ErrIssuerForbidden + } + + // The request must be scoped to a region served by the tenant's provider. + region, err := validateRegion(ctx, a.providers, sr.Regions, tenantRec.Provider) + if err != nil { + log.Debug("rejecting request region", zap.Error(err)) + return nil, err + } + log = log.With(zap.String("region", region)) + + // Determine the S3 operation the (verified) request performs and confirm the + // access key is permitted to perform it. The operation is returned so the + // handler can check it matches the operation it serves. + op, bucketName, _, err := classifyRequest(req) + if err != nil { + log.Debug("rejecting unsupported operation", zap.Error(err)) + return nil, ErrUnsupportedOperation + } + if !slices.Contains(akRec.Permissions, op.Permission()) { + log.Debug("rejecting operation the access key lacks permission for", zap.Stringer("operation", op)) + return nil, ErrOperationNotPermitted + } + + // For operations on an existing bucket, resolve it (within the tenant) and + // confirm it is within the access key's bucket scope (empty scope = all buckets). + var resolved *bucket.Record + if op.addressesExistingBucket() { + b, err := a.buckets.GetByName(ctx, bucketName) + if errors.Is(err, store.ErrRecordNotFound) || (err == nil && b.Tenant != tenantRec.ID) { + log.Debug("rejecting unknown bucket", zap.String("bucket", bucketName)) + return nil, ErrUnknownBucket + } else if err != nil { + log.Error("looking up bucket", zap.Error(err)) + return nil, fmt.Errorf("looking up bucket: %w", err) + } + if len(akRec.Buckets) > 0 && !slices.Contains(akRec.Buckets, b.ID) { + log.Debug("rejecting bucket the access key is not scoped to", zap.String("bucket", bucketName)) + return nil, ErrBucketNotPermitted + } + resolved = &b + } + + log.Debug("request authorized", zap.Stringer("operation", op)) + return &AuthorizedRequest{ + AccessKey: akRec, + Tenant: tenantRec, + Region: region, + Operation: op, + BucketName: bucketName, + Bucket: resolved, + Signed: sr, + }, nil +} + +// TenantIssuer loads the tenant's secp256k1 signing key from the vault and +// returns an issuer that signs as the tenant — used to act on the tenant's +// behalf (e.g. provisioning a bucket's space with Sprue). +func (a *Authorizer) TenantIssuer(ctx context.Context, tenantID did.DID) (ucan.Issuer, error) { + keyBytes, err := a.secrets.Read(ctx, vault.TenantKeyPath(tenantID)) + if err != nil { + return nil, fmt.Errorf("reading tenant key: %w", err) + } + signer, err := secp256k1.Decode(keyBytes) + if err != nil { + return nil, fmt.Errorf("decoding tenant key: %w", err) + } + return multikey.NewIssuer(tenantID, signer), nil +} + +// AccessKeySigner reads the access key's ed25519 private key from the vault. +func (a *Authorizer) AccessKeySigner(ctx context.Context, tenantID, accessKeyID did.DID) (multikey.Signer, error) { + keyBytes, err := a.secrets.Read(ctx, vault.AccessKeyPath(tenantID, accessKeyID)) + if err != nil { + return nil, fmt.Errorf("reading access key secret: %w", err) + } + signer, err := ed25519.Decode(keyBytes) + if err != nil { + return nil, fmt.Errorf("decoding access key: %w", err) + } + return signer, nil +} + +// EncodeSecret returns the multibase base64url secretAccessKey string the +// client signs with, for the given access key private key. +func EncodeSecret(signer multikey.Signer) (string, error) { + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + if err != nil { + return "", fmt.Errorf("encoding access key secret: %w", err) + } + return secret, nil +} + +// 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 { + prov, err := providers.GetByRegion(ctx, r) + if errors.Is(err, store.ErrRecordNotFound) { + continue // no provider serves this region + } + if err != nil { + return "", fmt.Errorf("looking up provider for region %q: %w", r, err) + } + if prov.ID == tenantProvider { + return r, nil + } + } + return "", ErrRegionNotServed +} diff --git a/pkg/rpc/service/auth/auth_test.go b/pkg/rpc/service/auth/auth_test.go new file mode 100644 index 0000000..f35645d --- /dev/null +++ b/pkg/rpc/service/auth/auth_test.go @@ -0,0 +1,245 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/fil-forge/hilt/pkg/rpc/service/auth" + "github.com/fil-forge/hilt/pkg/sigv4" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" + "github.com/fil-forge/hilt/pkg/store/tenant" + tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" + "github.com/fil-forge/hilt/pkg/vault" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + s3 "github.com/fil-forge/libforge/commands/s3" + "github.com/fil-forge/libforge/testutil" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/multiformats/go-multibase" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// signedRequest builds an S3 request presigned by the given access key signer for +// the given region. +func signedRequest(t *testing.T, signer multikey.Signer, region string, signedAt time.Time, expires time.Duration) s3.Request { + t.Helper() + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "GET", URL: "https://s3.fil.one/bucket/object-key"} + signed, err := sigv4.Presign(req, signer.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, signedAt, expires) + require.NoError(t, err) + return s3.Request{Method: signed.Method, URL: signed.URL} +} + +type setupConfig struct { + accessKeyExpires *time.Time + accessKeyBuckets []did.DID + tenantStatus tenant.Status +} + +// signedObjectRequest presigns a GET of an object in the named bucket. +func signedObjectRequest(t *testing.T, signer multikey.Signer, bucketName, region string) s3.Request { + t.Helper() + secret, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "GET", URL: "https://s3.fil.one/" + bucketName + "/object-key"} + signed, err := sigv4.Presign(req, signer.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, time.Now(), time.Hour) + require.NoError(t, err) + return s3.Request{Method: signed.Method, URL: signed.URL} +} + +func TestAuthorize(t *testing.T) { + ctx := t.Context() + const region = "us-west-2" + + accessKey, err := ed25519.GenerateIssuer() + require.NoError(t, err) + + // providerID is both the tenant's provider and the only legitimate invocation + // issuer. + providerID := testutil.RandomDID(t) + + // setup wires the stores + vault for a tenant whose provider serves the signing + // region and that owns this access key, returning the Authorizer built from + // them (plus the provider handle and tenant DID subtests still use). + setup := func(t *testing.T, accessKey multikey.Issuer, setupConfig *setupConfig) (*auth.Authorizer, *providermemory.Store, did.DID) { + t.Helper() + accessKeys, tenants := accesskeymemory.New(), tenantmemory.New() + providers, buckets, secrets := providermemory.New(), bucketmemory.New(), vaultmemory.New() + require.NoError(t, providers.Add(ctx, providerID, region)) + tenantID := testutil.RandomDID(t) + tenantStatus := tenant.Active + if setupConfig != nil && setupConfig.tenantStatus != "" { + tenantStatus = setupConfig.tenantStatus + } + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenantStatus)) + // The bucket the happy-path request addresses (GET /bucket/object-key). + require.NoError(t, buckets.Add(ctx, testutil.RandomDID(t), tenantID, "bucket")) + var accessKeyExpires *time.Time + var accessKeyBuckets []did.DID + if setupConfig != nil { + accessKeyExpires = setupConfig.accessKeyExpires + accessKeyBuckets = setupConfig.accessKeyBuckets + } + require.NoError(t, accessKeys.Add(ctx, accessKey.DID(), tenantID, "k1", accessKeyBuckets, []string{"s3:GetObject"}, accessKeyExpires)) + require.NoError(t, secrets.Write(ctx, vault.AccessKeyPath(tenantID, accessKey.DID()), accessKey.Bytes())) + return auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, buckets, secrets), providers, tenantID + } + + t.Run("authorizes a validly-signed request", func(t *testing.T) { + az, _, tenantID := setup(t, accessKey, nil) + authz, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.NoError(t, err) + require.Equal(t, accessKey.DID(), authz.AccessKey.ID) + require.Equal(t, tenantID, authz.Tenant.ID) + require.Equal(t, region, authz.Region) + require.Equal(t, auth.OpGetObject, authz.Operation) // GET /bucket/object-key + require.NotNil(t, authz.Bucket) + require.Equal(t, "bucket", authz.Bucket.Name) + require.NotNil(t, authz.Signed) + }) + + t.Run("rejects a bucket the access key is not scoped to", func(t *testing.T) { + // The key is scoped to some other bucket, so it may not use "bucket". + az, _, _ := setup(t, accessKey, &setupConfig{accessKeyBuckets: []did.DID{testutil.RandomDID(t)}}) + _, err := az.Authorize(ctx, providerID, signedObjectRequest(t, accessKey, "bucket", region)) + require.ErrorIs(t, err, auth.ErrBucketNotPermitted) + }) + + t.Run("rejects an unknown bucket", func(t *testing.T) { + // The key may use any bucket (nil scope), but "nope" does not exist. + az, _, _ := setup(t, accessKey, nil) + _, err := az.Authorize(ctx, providerID, signedObjectRequest(t, accessKey, "nope", region)) + require.ErrorIs(t, err, auth.ErrUnknownBucket) + }) + + t.Run("rejects an operation the access key lacks permission for", func(t *testing.T) { + // The key holds only s3:GetObject, but a ListBuckets-shaped request (GET + // with no bucket in the path) requires s3:ListAllMyBuckets. + az, _, _ := setup(t, accessKey, nil) + secret, err := multibase.Encode(multibase.Base64url, accessKey.Bytes()) + require.NoError(t, err) + req := sigv4.Request{Method: "GET", URL: "https://s3.fil.one/"} + signed, err := sigv4.Presign(req, accessKey.KeyDID().Identifier(), secret, region, sigv4.SchemeV4, time.Now(), time.Hour) + require.NoError(t, err) + _, err = az.Authorize(ctx, providerID, s3.Request{Method: signed.Method, URL: signed.URL}) + require.ErrorIs(t, err, auth.ErrOperationNotPermitted) + }) + + t.Run("rejects an invalid signature", func(t *testing.T) { + // The access key record exists, but the vault holds a different secret than + // the one that signed the request, so the recomputed signature won't match. + other, err := ed25519.GenerateIssuer() + require.NoError(t, err) + accessKeys, tenants := accesskeymemory.New(), tenantmemory.New() + providers, secrets := providermemory.New(), vaultmemory.New() + require.NoError(t, providers.Add(ctx, providerID, region)) + tenantID := testutil.RandomDID(t) + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenant.Active)) + require.NoError(t, accessKeys.Add(ctx, accessKey.DID(), tenantID, "k1", nil, []string{"s3:GetObject"}, nil)) + require.NoError(t, secrets.Write(ctx, vault.AccessKeyPath(tenantID, accessKey.DID()), other.Bytes())) + az := auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, bucketmemory.New(), secrets) + + _, err = az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrSignatureMismatch) + }) + + t.Run("rejects an unsigned request", func(t *testing.T) { + az, _, _ := setup(t, accessKey, nil) + _, err := az.Authorize(ctx, providerID, s3.Request{Method: "GET", URL: "https://s3.fil.one/bucket/object-key"}) + require.ErrorIs(t, err, auth.ErrMalformedSignature) + }) + + t.Run("rejects an unknown access key", func(t *testing.T) { + az := auth.NewAuthorizer(zap.NewNop(), accesskeymemory.New(), tenantmemory.New(), providermemory.New(), bucketmemory.New(), vaultmemory.New()) + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrUnknownAccessKey) + }) + + t.Run("rejects when the access key secret is missing from the vault", func(t *testing.T) { + // The access key record exists but its private key was never written to the + // vault — a store/vault inconsistency the signer load must reject. + accessKeys, tenants := accesskeymemory.New(), tenantmemory.New() + providers, secrets := providermemory.New(), vaultmemory.New() + require.NoError(t, providers.Add(ctx, providerID, region)) + tenantID := testutil.RandomDID(t) + require.NoError(t, tenants.Add(ctx, tenantID, "tenant-1", providerID, "Acme", tenant.Active)) + require.NoError(t, accessKeys.Add(ctx, accessKey.DID(), tenantID, "k1", nil, []string{"s3:GetObject"}, nil)) + az := auth.NewAuthorizer(zap.NewNop(), accessKeys, tenants, providers, bucketmemory.New(), secrets) + + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.Error(t, err) + }) + + t.Run("rejects a region the tenant's provider does not serve", func(t *testing.T) { + az, providers, _ := setup(t, accessKey, nil) + // A provider exists in eu-west-1, but it isn't the tenant's provider. + require.NoError(t, providers.Add(ctx, testutil.RandomDID(t), "eu-west-1")) + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, "eu-west-1", time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrRegionNotServed) + }) + + t.Run("rejects a region no provider serves", func(t *testing.T) { + az, _, _ := setup(t, accessKey, nil) + // No provider is registered for eu-west-1, so validateRegion skips it. + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, "eu-west-1", time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrRegionNotServed) + }) + + t.Run("rejects an expired presigned URL", func(t *testing.T) { + az, _, _ := setup(t, accessKey, nil) + // Validly signed, but two hours ago with only a one-hour window. + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now().Add(-2*time.Hour), time.Hour)) + require.ErrorIs(t, err, auth.ErrSignatureExpired) + }) + + t.Run("rejects an invocation not from the tenant's provider", func(t *testing.T) { + az, _, _ := setup(t, accessKey, nil) + _, err := az.Authorize(ctx, testutil.RandomDID(t), signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrIssuerForbidden) + }) + + t.Run("rejects an expired access key", func(t *testing.T) { + // A freshly-signed request from the tenant's provider must still be rejected + // when the access key itself has expired (so expiry is the only variable). + past := time.Now().Add(-time.Hour) + az, _, _ := setup(t, accessKey, &setupConfig{accessKeyExpires: &past}) + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrAccessKeyExpired) + }) + + t.Run("rejects a disabled tenant", func(t *testing.T) { + // A freshly-signed request from the tenant's provider must be rejected when + // the tenant is disabled (so disabled status is the only variable). + az, _, _ := setup(t, accessKey, &setupConfig{tenantStatus: tenant.Disabled}) + _, err := az.Authorize(ctx, providerID, signedRequest(t, accessKey, region, time.Now(), time.Hour)) + require.ErrorIs(t, err, auth.ErrTenantDisabled) + }) +} + +func TestTenantIssuer(t *testing.T) { + ctx := t.Context() + buckets, secrets := bucketmemory.New(), vaultmemory.New() + az := auth.NewAuthorizer(zap.NewNop(), accesskeymemory.New(), tenantmemory.New(), providermemory.New(), buckets, secrets) + + tenantSigner, err := secp256k1.Generate() + require.NoError(t, err) + tenantID := tenantSigner.KeyDID() + + t.Run("returns an issuer for a tenant with a vaulted key", func(t *testing.T) { + require.NoError(t, secrets.Write(ctx, vault.TenantKeyPath(tenantID), tenantSigner.Bytes())) + iss, err := az.TenantIssuer(ctx, tenantID) + require.NoError(t, err) + require.Equal(t, tenantID, iss.DID()) + }) + + t.Run("errors when the tenant key is missing", func(t *testing.T) { + _, err := az.TenantIssuer(ctx, testutil.RandomDID(t)) + require.Error(t, err) + }) +} diff --git a/pkg/rpc/service/auth/errors.go b/pkg/rpc/service/auth/errors.go new file mode 100644 index 0000000..6c7572b --- /dev/null +++ b/pkg/rpc/service/auth/errors.go @@ -0,0 +1,68 @@ +package auth + +import "github.com/fil-forge/ucantone/errors" + +// Error names for the named rejection errors, exported so callers (e.g. Ingot, +// mapping to canonical S3 error responses) can match on the stable Name() of a +// serialized failure. +const ( + MalformedSignatureErrorName = "MalformedSignature" + InvalidAccessKeyIDErrorName = "InvalidAccessKeyID" + UnknownAccessKeyErrorName = "UnknownAccessKey" + SignatureMismatchErrorName = "SignatureMismatch" + SignatureExpiredErrorName = "SignatureExpired" + AccessKeyExpiredErrorName = "AccessKeyExpired" + TenantDisabledErrorName = "TenantDisabled" + IssuerForbiddenErrorName = "IssuerForbidden" + RegionNotServedErrorName = "RegionNotServed" + UnsupportedOperationErrorName = "UnsupportedOperation" + OperationNotPermittedErrorName = "OperationNotPermitted" + UnknownBucketErrorName = "UnknownBucket" + BucketNotPermittedErrorName = "BucketNotPermitted" +) + +// Named rejection errors returned by [Authorizer.Authorize]. Each is a sentinel +// carrying a stable Name(), wrapped with per-request context at the return site, +// so callers can branch on the reason with errors.Is. Unexpected/internal +// failures (store or vault errors) are intentionally not named — they are +// 500-class, not authorization rejections. +var ( + // ErrMalformedSignature is returned when the request carries no parseable + // signature — absent entirely, or present but unparseable (unsupported + // algorithm, malformed credential, incomplete parameters). It is distinct + // from [ErrSignatureMismatch], which is a cryptographic verification failure. + ErrMalformedSignature = errors.New(MalformedSignatureErrorName, "request signature is missing or malformed") + // ErrInvalidAccessKeyID is returned when the credential's access key id is + // not a valid did:key. + ErrInvalidAccessKeyID = errors.New(InvalidAccessKeyIDErrorName, "invalid access key id") + // ErrUnknownAccessKey is returned when the access key is not found. + ErrUnknownAccessKey = errors.New(UnknownAccessKeyErrorName, "unknown access key") + // ErrSignatureMismatch is returned when the request signature does not verify + // against the access key's secret. + ErrSignatureMismatch = errors.New(SignatureMismatchErrorName, "request signature does not match") + // ErrSignatureExpired is returned when the request is outside its signature + // validity window (presigned expiry or clock skew). + ErrSignatureExpired = errors.New(SignatureExpiredErrorName, "request signature is no longer valid") + // ErrAccessKeyExpired is returned when the access key has passed its expiry. + ErrAccessKeyExpired = errors.New(AccessKeyExpiredErrorName, "access key has expired") + // ErrTenantDisabled is returned when the tenant is disabled. + ErrTenantDisabled = errors.New(TenantDisabledErrorName, "tenant is disabled") + // ErrIssuerForbidden is returned when the invocation issuer is not allowed to + // act on the tenant's behalf (it is not the tenant's provider). + ErrIssuerForbidden = errors.New(IssuerForbiddenErrorName, "issuer is not allowed to act for this tenant") + // ErrRegionNotServed is returned when none of the request's regions are served + // by the tenant's provider. + ErrRegionNotServed = errors.New(RegionNotServedErrorName, "request region is not served by the tenant's provider") + // ErrUnsupportedOperation is returned when the request's method and path map to + // no supported S3 operation. + ErrUnsupportedOperation = errors.New(UnsupportedOperationErrorName, "unsupported S3 operation") + // ErrOperationNotPermitted is returned when the access key does not hold the + // permission required for the requested operation. + ErrOperationNotPermitted = errors.New(OperationNotPermittedErrorName, "access key is not permitted to perform this operation") + // ErrUnknownBucket is returned when the request's bucket does not exist or + // belongs to another tenant. + ErrUnknownBucket = errors.New(UnknownBucketErrorName, "unknown bucket") + // ErrBucketNotPermitted is returned when the access key's bucket scope does not + // include the request's bucket. + ErrBucketNotPermitted = errors.New(BucketNotPermittedErrorName, "access key is not permitted to use this bucket") +) diff --git a/pkg/rpc/service/auth/operation.go b/pkg/rpc/service/auth/operation.go new file mode 100644 index 0000000..7bb0f5a --- /dev/null +++ b/pkg/rpc/service/auth/operation.go @@ -0,0 +1,107 @@ +package auth + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + s3 "github.com/fil-forge/libforge/commands/s3" +) + +// Operation is the S3 operation a request performs, derived from its HTTP method +// and path. [Authorizer.Authorize] classifies it, checks the access key is +// permitted to perform it, and returns it on [AuthorizedRequest.Operation] so a +// handler can confirm the request matches the operation it serves. +type Operation string + +const ( + OpListBuckets Operation = "ListBuckets" // GET, no bucket in path + OpListBucket Operation = "ListBucket" // GET, bucket, no key (list objects) + OpGetObject Operation = "GetObject" // GET, bucket + key + OpPutObject Operation = "PutObject" // PUT/POST, bucket + key + OpCreateBucket Operation = "CreateBucket" // PUT/POST, bucket, no key + OpDeleteObject Operation = "DeleteObject" // DELETE, bucket + key + OpDeleteBucket Operation = "DeleteBucket" // DELETE, bucket, no key +) + +// operationPermission maps each operation to the S3 permission an access key +// must hold to perform it. +var operationPermission = map[Operation]string{ + OpListBuckets: "s3:ListAllMyBuckets", + OpListBucket: "s3:ListBucket", + OpGetObject: "s3:GetObject", + OpPutObject: "s3:PutObject", + OpCreateBucket: "s3:CreateBucket", + OpDeleteObject: "s3:DeleteObject", + OpDeleteBucket: "s3:DeleteBucket", +} + +// Permission returns the S3 permission an access key must hold to perform the +// operation. Callers that map permissions to Forge commands (see the +// `/s3/request/authorize` handler) use it to avoid re-deriving the permission. +func (o Operation) Permission() string { return operationPermission[o] } + +func (o Operation) String() string { return string(o) } + +// addressesExistingBucket reports whether the operation acts on a bucket that must +// already exist, so it can be resolved and scope-checked. ListBuckets addresses no +// bucket; CreateBucket's bucket does not exist yet. +func (o Operation) addressesExistingBucket() bool { + switch o { + case OpListBucket, OpGetObject, OpPutObject, OpDeleteObject, OpDeleteBucket: + return true + default: + return false + } +} + +// OperationFor classifies the S3 operation addressed by a request. See +// [classifyRequest] for the method/path rules. +func OperationFor(req s3.Request) (Operation, error) { + op, _, _, err := classifyRequest(req) + return op, err +} + +// classifyRequest determines the S3 operation and the addressed bucket/object key +// from a request's HTTP method and path-style URL (https:////). +// The path is part of the SigV4-signed canonical request, so once the signature is +// verified the classification is bound to what the caller signed. It returns an +// error for method/path combinations that map to no supported operation. +func classifyRequest(req s3.Request) (op Operation, bucket, key string, err error) { + u, err := url.Parse(req.URL) + if err != nil { + return "", "", "", fmt.Errorf("parsing request URL: %w", err) + } + bucket, key, _ = strings.Cut(strings.TrimPrefix(u.EscapedPath(), "/"), "/") + + switch strings.ToUpper(req.Method) { + case http.MethodGet, http.MethodHead: + switch { + case bucket == "": + return OpListBuckets, bucket, key, nil + case key == "": + return OpListBucket, bucket, key, nil + default: + return OpGetObject, bucket, key, nil + } + case http.MethodPut, http.MethodPost: + if bucket == "" { + return "", "", "", fmt.Errorf("%s request has no bucket in its path", req.Method) + } + if key == "" { + return OpCreateBucket, bucket, key, nil + } + return OpPutObject, bucket, key, nil + case http.MethodDelete: + if bucket == "" { + return "", "", "", fmt.Errorf("%s request has no bucket in its path", req.Method) + } + if key == "" { + return OpDeleteBucket, bucket, key, nil + } + return OpDeleteObject, bucket, key, nil + default: + return "", "", "", fmt.Errorf("unsupported S3 method %q", req.Method) + } +} diff --git a/pkg/api/permissions.go b/pkg/s3perm/s3perm.go similarity index 56% rename from pkg/api/permissions.go rename to pkg/s3perm/s3perm.go index a695a9d..ecf217c 100644 --- a/pkg/api/permissions.go +++ b/pkg/s3perm/s3perm.go @@ -1,4 +1,8 @@ -package api +// Package s3perm maps S3 permission strings (e.g. "s3:GetObject") to the Forge +// network commands that must be delegated for them. It is shared by the Tenant +// REST API (which delegates commands to an access key at creation) and the UCAN +// RPC API (which re-delegates them to the invocation issuer). +package s3perm import ( "github.com/fil-forge/libforge/commands/blob" @@ -16,12 +20,12 @@ var ( cmdsRemove = []ucan.Command{blob.Remove.Command, upload.Remove.Command} ) -// s3PermissionCommands maps each supported S3 permission to the Forge commands -// that must be delegated from the tenant to the access key for it. Permissions -// with no Forge equivalent (bucket-level actions) map to nil — they are valid -// and stored on the access key, but issue no delegation and are enforced -// directly by Ingot/Hilt (see the RFC). -var s3PermissionCommands = map[string][]ucan.Command{ +// permissionCommands maps each supported S3 permission to the Forge commands +// that must be delegated for it. Permissions with no Forge equivalent +// (bucket-level actions) map to nil — they are valid and stored on the access +// key, but issue no delegation and are enforced directly by Ingot/Hilt (see the +// RFC). +var permissionCommands = map[string][]ucan.Command{ "s3:GetObject": cmdsRetrieve, "s3:GetObjectVersion": cmdsRetrieve, "s3:GetObjectRetention": cmdsRetrieve, @@ -38,19 +42,19 @@ var s3PermissionCommands = map[string][]ucan.Command{ "s3:DeleteBucket": nil, } -// validS3Permission reports whether p is a recognized S3 permission. -func validS3Permission(p string) bool { - _, ok := s3PermissionCommands[p] +// Valid reports whether p is a recognized S3 permission. +func Valid(p string) bool { + _, ok := permissionCommands[p] return ok } -// commandsForPermissions returns the deduplicated set of Forge commands to -// delegate for the given S3 permissions, preserving first-seen order. -func commandsForPermissions(permissions []string) []ucan.Command { +// CommandsFor returns the deduplicated set of Forge commands to delegate for the +// given S3 permissions, preserving first-seen order. +func CommandsFor(permissions ...string) []ucan.Command { seen := map[string]bool{} var cmds []ucan.Command for _, p := range permissions { - for _, c := range s3PermissionCommands[p] { + for _, c := range permissionCommands[p] { if k := c.String(); !seen[k] { seen[k] = true cmds = append(cmds, c) diff --git a/pkg/sigv4/aws_suite_test.go b/pkg/sigv4/aws_suite_test.go new file mode 100644 index 0000000..0e5aac1 --- /dev/null +++ b/pkg/sigv4/aws_suite_test.go @@ -0,0 +1,150 @@ +package sigv4 + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// awsSuiteSecret is the example secret access key AWS documents for its SigV4 +// test suite (see testdata/aws-sig-v4-test-suite/README.md). All vectors are +// signed with it. +const awsSuiteSecret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" + +const awsSuiteRoot = "testdata/aws-sig-v4-test-suite" + +// awsSuiteIncompatible lists vectors that a faithful S3-style verifier cannot +// reproduce, and are therefore asserted as expected-failures: +// +// - Path-normalization vectors: the generic AWS suite normalizes the request +// path (// -> /, /example/.. -> /, /./ -> /). S3 does NOT normalize, and +// neither does this verifier (it signs the escaped path verbatim), so the +// canonical URI — and thus the signature — differs. +// - Multiline (folded) header vector: AWS joins the folded continuation lines +// with commas (value1,value2,value3), but HTTP stacks — and therefore Ingot — +// unfold obsolete line folding with spaces, so an S3 verifier fed a realistic +// request cannot reproduce the comma-joined canonical form. +// +// Duplicate header keys are NOT incompatible: parseSreq combines them into one +// comma-separated value in appearance order, matching AWS's canonical-header rule. +var awsSuiteIncompatible = map[string]string{ + "get-slash": "S3 does not normalize request paths (// stays //)", + "get-slashes": "S3 does not normalize request paths", + "get-slash-dot-slash": "S3 does not normalize request paths (/./ stays)", + "get-slash-pointless-dot": "S3 does not normalize request paths", + "get-relative": "S3 does not normalize request paths (/example/.. stays)", + "get-relative-relative": "S3 does not normalize request paths", + "get-header-value-multiline": "folded header values are unfolded with spaces, not AWS's commas", +} + +// TestAWSSigV4Suite runs Parse + Verify over every AWS SigV4 test-suite vector. +// The S3-compatible vectors must verify; the ones exercising behaviour S3 +// deliberately diverges from (see [awsSuiteIncompatible]) must fail. +func TestAWSSigV4Suite(t *testing.T) { + var vectors []string + err := filepath.WalkDir(awsSuiteRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".sreq") { + vectors = append(vectors, path) + } + return nil + }) + require.NoError(t, err) + require.NotEmpty(t, vectors, "no .sreq vectors found under %s", awsSuiteRoot) + + for _, path := range vectors { + name := strings.TrimSuffix(filepath.Base(path), ".sreq") + rel, _ := filepath.Rel(awsSuiteRoot, path) + t.Run(rel, func(t *testing.T) { + req := parseSreq(t, path) + + sr, err := Parse(req) + if err == nil { + err = Verify(sr, awsSuiteSecret) + } + + if reason, incompatible := awsSuiteIncompatible[name]; incompatible { + require.Error(t, err, "expected verification to fail: %s", reason) + } else { + require.NoError(t, err, "vector should verify against AWS's signature") + } + }) + } +} + +// parseSreq reads a signed-request vector file into a [Request]. It performs the +// minimal raw-HTTP parsing the vectors need (net/http.ReadRequest mangles "//" +// request targets and cannot yield a map[string]string). The empty-payload / +// body hash is injected as X-Amz-Content-Sha256, which the vectors omit but Parse +// requires — it is exactly the payload hash AWS signed with. +func parseSreq(t *testing.T, path string) Request { + t.Helper() + raw, err := os.ReadFile(path) + require.NoError(t, err) + + // Split headers from body on the first blank line (CRLF or LF). + head, body, ok := bytes.Cut(raw, []byte("\r\n\r\n")) + if !ok { + head, body, _ = bytes.Cut(raw, []byte("\n\n")) + } + + lines := strings.Split(string(head), "\n") + require.NotEmpty(t, lines) + + // Request line: METHOD HTTP/1.1 + reqLine := strings.TrimRight(lines[0], "\r") + method, rest, ok := strings.Cut(reqLine, " ") + require.True(t, ok, "malformed request line %q", reqLine) + target := rest + if i := strings.LastIndex(rest, " HTTP/"); i >= 0 { + target = rest[:i] + } + + headers := map[string]string{} + var lastKey string + for _, line := range lines[1:] { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + // Continuation of the previous header (obsolete line folding). + if lastKey != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + headers[lastKey] += " " + strings.TrimSpace(line) + continue + } + key, value, ok := strings.Cut(line, ":") + require.True(t, ok, "malformed header line %q", line) + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + // Combine duplicate header keys into one comma-separated value in the + // order they appear — matching AWS's canonical-header rule and how Ingot + // maps an incoming request into a Hilt invocation. + if existing, exists := headers[key]; exists { + headers[key] = existing + "," + value + } else { + headers[key] = value + } + lastKey = key + } + + host := headers["Host"] + require.NotEmpty(t, host, "vector %s is missing a Host header", path) + + // The vectors omit X-Amz-Content-Sha256; inject the payload hash AWS signed + // with (empty-string hash when there is no body). + if _, ok := headers[amzContentSHA]; !ok { + headers[amzContentSHA] = hashSHA256(body) + } + + return Request{ + Method: method, + Headers: headers, + URL: "https://" + host + target, + } +} diff --git a/pkg/sigv4/canonical.go b/pkg/sigv4/canonical.go new file mode 100644 index 0000000..b749ab7 --- /dev/null +++ b/pkg/sigv4/canonical.go @@ -0,0 +1,101 @@ +package sigv4 + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" +) + +// canonicalRequest builds the AWS canonical request string per the SigV4 spec. +func (s *SignedRequest) canonicalRequest() string { + var b strings.Builder + b.WriteString(s.method) + b.WriteByte('\n') + b.WriteString(s.canonicalURI) + b.WriteByte('\n') + b.WriteString(s.canonicalQueryString()) + b.WriteByte('\n') + b.WriteString(s.canonicalHeaders()) + b.WriteByte('\n') + b.WriteString(strings.Join(s.signedHeaders, ";")) + b.WriteByte('\n') + b.WriteString(s.payloadHash) + return b.String() +} + +// stringToSign builds the AWS string-to-sign. +func (s *SignedRequest) stringToSign() string { + return string(s.Scheme) + "\n" + + s.amzDate + "\n" + + s.scope + "\n" + + hashSHA256([]byte(s.canonicalRequest())) +} + +// canonicalQueryString encodes and sorts the signed query parameters. +func (s *SignedRequest) canonicalQueryString() string { + pairs := make([]string, 0, len(s.query)) + for key, values := range s.query { + ek := awsURIEncode(key, true) + for _, v := range values { + pairs = append(pairs, ek+"="+awsURIEncode(v, true)) + } + } + sort.Strings(pairs) + return strings.Join(pairs, "&") +} + +// canonicalHeaders builds the canonical header block for the signed headers. +func (s *SignedRequest) canonicalHeaders() string { + var b strings.Builder + for _, name := range s.signedHeaders { + var value string + if name == "host" { + value = s.host + } else { + value = s.headers.Get(name) + } + b.WriteString(name) + b.WriteByte(':') + b.WriteString(trimHeaderValue(value)) + b.WriteByte('\n') + } + return b.String() +} + +// trimHeaderValue trims surrounding whitespace and collapses internal runs of +// whitespace to a single space, per the SigV4 canonical header rules. +func trimHeaderValue(v string) string { + return strings.Join(strings.Fields(v), " ") +} + +// awsURIEncode percent-encodes per the AWS SigV4 rules: unreserved characters +// are left as-is, everything else is %XX (uppercase hex). When encodeSlash is +// false, '/' is left literal (used for the canonical URI path). +func awsURIEncode(s string, encodeSlash bool) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~': + b.WriteByte(c) + case c == '/' && !encodeSlash: + b.WriteByte(c) + default: + b.WriteByte('%') + b.WriteByte(upperHex[c>>4]) + b.WriteByte(upperHex[c&0x0f]) + } + } + return b.String() +} + +const upperHex = "0123456789ABCDEF" + +// hashSHA256 returns the lowercase hex SHA-256 of b. +func hashSHA256(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/pkg/sigv4/ecdsa.go b/pkg/sigv4/ecdsa.go new file mode 100644 index 0000000..58bd25e --- /dev/null +++ b/pkg/sigv4/ecdsa.go @@ -0,0 +1,145 @@ +package sigv4 + +import ( + "bytes" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math/big" +) + +// signV4a computes a hex DER AWS4-ECDSA-P256-SHA256 signature for the request. +func (s *SignedRequest) signV4a(secretAccessKey string) (string, error) { + priv, err := deriveKeyV4a(s.AccessKeyID, secretAccessKey) + if err != nil { + return "", err + } + digest := sha256.Sum256([]byte(s.stringToSign())) + der, err := ecdsa.SignASN1(rand.Reader, priv, digest[:]) + if err != nil { + return "", fmt.Errorf("signing: %w", err) + } + return hex.EncodeToString(der), nil +} + +// verifyV4a verifies an AWS4-ECDSA-P256-SHA256 signature for req: it derives the +// access key's P-256 key and ECDSA-verifies the DER signature over the +// string-to-sign. +func verifyV4a(req *SignedRequest, secretAccessKey string) error { + priv, err := deriveKeyV4a(req.AccessKeyID, secretAccessKey) + if err != nil { + return err + } + return verifyV4aWithPublicKey(req, &priv.PublicKey) +} + +// verifyV4aWithKey verifies the signature using a compressed SEC1 P-256 public +// key (the SigV4a derived key, as produced by DeriveKey). +func verifyV4aWithKey(req *SignedRequest, key []byte) error { + pub, err := parseCompressedP256(key) + if err != nil { + return err + } + return verifyV4aWithPublicKey(req, pub) +} + +// verifyV4aWithPublicKey ECDSA-verifies the request's DER signature over the +// string-to-sign against pub. +func verifyV4aWithPublicKey(req *SignedRequest, pub *ecdsa.PublicKey) error { + der, err := hex.DecodeString(req.signature) + if err != nil { + return fmt.Errorf("decoding signature: %w", err) + } + digest := sha256.Sum256([]byte(req.stringToSign())) + if !ecdsa.VerifyASN1(pub, digest[:], der) { + return errors.New("signature mismatch") + } + return nil +} + +// parseCompressedP256 decodes a 33-byte compressed SEC1 point into an ECDSA P-256 +// public key. The standard library has no non-deprecated compressed-point parser +// (crypto/ecdh rejects compressed points and ecdsa.ParseUncompressedPublicKey +// takes only uncompressed), so use elliptic.UnmarshalCompressed — the documented +// inverse of the compression DeriveKey applies — then re-encode as an +// uncompressed point for ecdsa.ParseUncompressedPublicKey. +func parseCompressedP256(key []byte) (*ecdsa.PublicKey, error) { + x, y := elliptic.UnmarshalCompressed(elliptic.P256(), key) + if x == nil { + return nil, errors.New("sigv4a: invalid compressed public key") + } + uncompressed := make([]byte, 65) + uncompressed[0] = 0x04 + x.FillBytes(uncompressed[1:33]) + y.FillBytes(uncompressed[33:65]) + return ecdsa.ParseUncompressedPublicKey(elliptic.P256(), uncompressed) +} + +// derivedPublicKeyV4a derives the SigV4a P-256 key and returns its public key in +// compressed SEC1 form (33 bytes) — the s3.VerificationKey data for the "sigv4a" +// kind (see the RFC appendix, which encodes the same compressed bytes). +func derivedPublicKeyV4a(accessKeyID, secretAccessKey string) ([]byte, error) { + priv, err := deriveKeyV4a(accessKeyID, secretAccessKey) + if err != nil { + return nil, err + } + // PublicKey.Bytes is the uncompressed SEC1 encoding (0x04 || X || Y); compress + // it to 0x02|0x03(parity of Y) || X, avoiding the deprecated coordinate APIs. + uncompressed, err := priv.PublicKey.Bytes() + if err != nil { + return nil, fmt.Errorf("sigv4a: marshaling public key: %w", err) + } + compressed := make([]byte, 33) + compressed[0] = 0x02 | (uncompressed[64] & 1) + copy(compressed[1:], uncompressed[1:33]) + return compressed, nil +} + +// deriveKeyV4a derives the SigV4a ECDSA P-256 private key from an access key id +// and secret using AWS's NIST SP 800-108 counter-mode KDF (HMAC-SHA256 keyed by +// "AWS4A" + secret). It mirrors aws-sdk-go-v2's internal/v4a derivation so the +// key matches what an AWS client uses. +func deriveKeyV4a(accessKeyID, secretAccessKey string) (*ecdsa.PrivateKey, error) { + const label = string(SchemeV4a) // "AWS4-ECDSA-P256-SHA256" + nMinusTwo := new(big.Int).Sub(elliptic.P256().Params().N, big.NewInt(2)) + + var d *big.Int + for counter := 1; counter <= 0xFE; counter++ { + // fixed input: 0x00000001 || label || 0x00 || accessKeyID || counter || 0x00000100 + var input bytes.Buffer + input.Write([]byte{0x00, 0x00, 0x00, 0x01}) + input.WriteString(label) + input.WriteByte(0x00) + input.WriteString(accessKeyID) + input.WriteByte(byte(counter)) + input.Write([]byte{0x00, 0x00, 0x01, 0x00}) + + candidate := hmacSHA256([]byte("AWS4A"+secretAccessKey), input.Bytes()) + + c := new(big.Int).SetBytes(candidate) + if c.Cmp(nMinusTwo) <= 0 { + d = c.Add(c, big.NewInt(1)) // d in [1, N-1] + break + } + } + if d == nil { + return nil, errors.New("sigv4a: exhausted key-derivation counter") + } + + // Derive the public point via crypto/ecdh (validates the scalar range) and + // bridge to an *ecdsa.PrivateKey without the deprecated raw-coordinate APIs. + ecdhKey, err := ecdh.P256().NewPrivateKey(d.FillBytes(make([]byte, 32))) + if err != nil { + return nil, fmt.Errorf("sigv4a: invalid derived scalar: %w", err) + } + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), ecdhKey.PublicKey().Bytes()) + if err != nil { + return nil, fmt.Errorf("sigv4a: parsing derived public key: %w", err) + } + return &ecdsa.PrivateKey{PublicKey: *pub, D: d}, nil +} diff --git a/pkg/sigv4/hmac.go b/pkg/sigv4/hmac.go new file mode 100644 index 0000000..98f4c8d --- /dev/null +++ b/pkg/sigv4/hmac.go @@ -0,0 +1,50 @@ +package sigv4 + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// verifyV4 recomputes the AWS4-HMAC-SHA256 signature for req and compares it. +func verifyV4(req *SignedRequest, secretAccessKey string) error { + key := deriveSigningKeyV4(secretAccessKey, req.scopeDate(), req.scopeRegion(), req.scopeService()) + return verifyV4WithKey(req, key) +} + +// verifyV4WithKey verifies the HMAC signature using a precomputed signing key +// (the SigV4 derived key, as produced by DeriveKey). +func verifyV4WithKey(req *SignedRequest, key []byte) error { + if !hmac.Equal([]byte(req.signV4WithKey(key)), []byte(req.signature)) { + return fmt.Errorf("signature mismatch") + } + return nil +} + +// signV4 computes the hex AWS4-HMAC-SHA256 signature for the request. +func (s *SignedRequest) signV4(secretAccessKey string) string { + key := deriveSigningKeyV4(secretAccessKey, s.scopeDate(), s.scopeRegion(), s.scopeService()) + return s.signV4WithKey(key) +} + +// signV4WithKey computes the hex signature from a precomputed signing key. +func (s *SignedRequest) signV4WithKey(key []byte) string { + return hex.EncodeToString(hmacSHA256(key, []byte(s.stringToSign()))) +} + +// deriveSigningKeyV4 derives the SigV4 signing key: +// HMAC chain over date, region, service, then the "aws4_request" terminator, +// seeded with "AWS4" + secret. +func deriveSigningKeyV4(secret, date, region, service string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secret), []byte(date)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(service)) + return hmacSHA256(kService, []byte(terminator)) +} + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} diff --git a/pkg/sigv4/host.go b/pkg/sigv4/host.go new file mode 100644 index 0000000..14bb4c7 --- /dev/null +++ b/pkg/sigv4/host.go @@ -0,0 +1,93 @@ +package sigv4 + +import ( + "net" + "net/http" + "net/url" + "strings" +) + +// extractHost reconstructs the host the client signed into the canonical Host +// header. When the request arrives via a proxy/load balancer the client-signed +// host is carried in X-Forwarded-Host (with X-Forwarded-Port / X-Forwarded-Proto) +// while the Host header holds the proxy's address, so we prefer the forwarded +// values. The scheme's default port (80 for http, 443 for https) is stripped to +// match the AWS SDK's SanitizeHostForHeader — the value AWS clients actually sign. +// +// It is a port of SeaweedFS's extractHostHeader (minus the externalHost override), +// with scheme precedence adjusted for our request model: X-Forwarded-Proto beats +// the request URL scheme (we have no *tls.ConnectionState). +func extractHost(headers http.Header, u *url.URL) string { + fwdHost := headers.Get("X-Forwarded-Host") + fwdPort := firstHop(headers.Get("X-Forwarded-Port")) + fwdProto := firstHop(headers.Get("X-Forwarded-Proto")) + + scheme := "http" + if u.Scheme != "" { + scheme = u.Scheme + } + if fwdProto != "" { + scheme = fwdProto + } + + rHost := headers.Get("Host") + if rHost == "" { + rHost = u.Host + } + + var host, port string + if fwdHost != "" { + host = firstHop(fwdHost) + if h, p, err := net.SplitHostPort(host); err == nil { + // The forwarded host carries its own port — it wins over X-Forwarded-Port. + host, port = h, p + } else if rh, rp, err := net.SplitHostPort(rHost); err == nil && rh == host { + // No port on the forwarded host, but the Host header names the same + // hostname with a port — trust that port over a (possibly misreported) + // X-Forwarded-Port. + port = rp + } else if fwdPort != "" { + port = fwdPort + } + } else { + host = rHost + if h, p, err := net.SplitHostPort(host); err == nil { + host, port = h, p + } else if fwdPort != "" { + port = fwdPort + } + } + + if port != "" && !isDefaultPort(scheme, port) { + // Strip any existing brackets first: JoinHostPort re-adds them for IPv6, so + // this avoids double-bracketing like [[::1]]:8080. + host = strings.Trim(host, "[]") + return net.JoinHostPort(host, port) + } + // No port, or a default port that was stripped. For IPv6 (contains ':') drop the + // brackets to match the AWS SDK's bracket-less bare-host form. + if strings.Contains(host, ":") { + return strings.Trim(host, "[]") + } + return host +} + +// firstHop returns the first comma-separated value, trimmed. X-Forwarded-* headers +// accumulate a value per proxy hop; the first is the original (client-facing) one. +func firstHop(v string) string { + first, _, _ := strings.Cut(v, ",") + return strings.TrimSpace(first) +} + +// isDefaultPort reports whether port is the default for the scheme (80 for http, +// 443 for https), which the AWS SDK strips from the signed Host header. +func isDefaultPort(scheme, port string) bool { + switch port { + case "80": + return strings.EqualFold(scheme, "http") + case "443": + return strings.EqualFold(scheme, "https") + default: + return false + } +} diff --git a/pkg/sigv4/sign.go b/pkg/sigv4/sign.go new file mode 100644 index 0000000..e33c366 --- /dev/null +++ b/pkg/sigv4/sign.go @@ -0,0 +1,83 @@ +package sigv4 + +import ( + "fmt" + "net/url" + "strconv" + "time" +) + +const ( + amzDateFormat = "20060102T150405Z" + dateFormat = "20060102" + service = "s3" +) + +// Presign returns a copy of req signed as a presigned URL (auth in the query +// string) for the given scheme, valid for expires from signedAt. It mirrors +// Verify's canonicalization and is primarily used by tests and any client-side +// signing; Hilt itself only verifies. host is the only signed header. +func Presign(req Request, accessKeyID, secretAccessKey, region string, scheme Scheme, signedAt time.Time, expires time.Duration) (Request, error) { + u, err := url.Parse(req.URL) + if err != nil { + return Request{}, fmt.Errorf("parsing request URL: %w", err) + } + + date := signedAt.UTC().Format(amzDateFormat) + stamp := signedAt.UTC().Format(dateFormat) + + scope := stamp + "/" + region + "/" + service + "/" + terminator + if scheme == SchemeV4a { + scope = stamp + "/" + service + "/" + terminator + } + + q := u.Query() + q.Set(amzAlgorithm, string(scheme)) + q.Set(amzCredential, accessKeyID+"/"+scope) + q.Set(amzDate, date) + q.Set(amzExpires, strconv.Itoa(int(expires.Seconds()))) + q.Set(amzSignedHdrs, "host") + if scheme == SchemeV4a { + q.Set(amzRegionSet, region) + } + + canonicalURI := u.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + + sr := &SignedRequest{ + Scheme: scheme, + AccessKeyID: accessKeyID, + Regions: []string{region}, + method: req.Method, + canonicalURI: canonicalURI, + query: q, // X-Amz-Signature not yet set + headers: toHeader(req.Headers), + host: u.Host, + signedHeaders: []string{"host"}, + payloadHash: unsignedPayload, + amzDate: date, + scope: scope, + } + + var signature string + switch scheme { + case SchemeV4: + signature = sr.signV4(secretAccessKey) + case SchemeV4a: + signature, err = sr.signV4a(secretAccessKey) + if err != nil { + return Request{}, err + } + default: + return Request{}, fmt.Errorf("unsupported signature algorithm %q", scheme) + } + + q.Set(amzSignature, signature) + u.RawQuery = q.Encode() + + signed := req + signed.URL = u.String() + return signed, nil +} diff --git a/pkg/sigv4/sigv4.go b/pkg/sigv4/sigv4.go new file mode 100644 index 0000000..a6bb445 --- /dev/null +++ b/pkg/sigv4/sigv4.go @@ -0,0 +1,374 @@ +// Package sigv4 verifies AWS S3 request signatures for the two schemes the +// Forge S3 gateway uses: AWS4-HMAC-SHA256 (SigV4) and AWS4-ECDSA-P256-SHA256 +// (SigV4a). It is built on the Go standard library only. +// +// Access keys are ed25519 keys; the client's secretAccessKey is the multibase +// base64url encoding of the multiformat-tagged private key. SigV4 feeds that +// string into the standard HMAC signing-key chain; SigV4a derives an ECDSA +// P-256 key from the access key id + secret using AWS's deterministic KDF. +package sigv4 + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +// Scheme identifies an AWS signature algorithm. +type Scheme string + +const ( + SchemeV4 Scheme = "AWS4-HMAC-SHA256" + SchemeV4a Scheme = "AWS4-ECDSA-P256-SHA256" +) + +const ( + amzAlgorithm = "X-Amz-Algorithm" + amzCredential = "X-Amz-Credential" + amzSignedHdrs = "X-Amz-SignedHeaders" + amzSignature = "X-Amz-Signature" + amzDate = "X-Amz-Date" + amzContentSHA = "X-Amz-Content-Sha256" + amzRegionSet = "X-Amz-Region-Set" + amzExpires = "X-Amz-Expires" + terminator = "aws4_request" + unsignedPayload = "UNSIGNED-PAYLOAD" +) + +// Request is the subset of an HTTP request that sigv4 needs to verify (or +// produce) a signature. Callers adapt their own request representation to it. +type Request struct { + Method string + Headers map[string]string + URL string +} + +// toHeader builds a canonicalized http.Header from a plain header map, so +// internal lookups get case-insensitive .Get semantics. +func toHeader(m map[string]string) http.Header { + h := make(http.Header, len(m)) + for k, v := range m { + h.Set(k, v) + } + return h +} + +// SignedRequest is the parsed authentication state of an S3 request: the public +// identity fields plus the components needed to recompute the signature. +type SignedRequest struct { + Scheme Scheme + AccessKeyID string // bare did:key identifier + Regions []string // credential-scope region (V4) or X-Amz-Region-Set (V4a) + + method string + canonicalURI string + query url.Values // signed query params (X-Amz-Signature removed) + headers http.Header + host string + signedHeaders []string // lowercased, sorted + payloadHash string + amzDate string + scope string // "/[/]/aws4_request" + signature string // the signature carried on the request + presigned bool // auth came from the query string (presigned URL) + expires int // X-Amz-Expires seconds (presigned only) +} + +// Parse extracts the signature fields from an S3 request — from the +// Authorization header or, for presigned URLs, the X-Amz-* query parameters. It +// does not verify the signature; call [Verify] for that. +func Parse(req Request) (*SignedRequest, error) { + u, err := url.Parse(req.URL) + if err != nil { + return nil, fmt.Errorf("parsing request URL: %w", err) + } + query := u.Query() + headers := toHeader(req.Headers) + + var ( + algorithm string + credential string + signedHeaders string + signature string + date string + regionSet string + payloadHash string + presigned bool + expires int + ) + + if query.Get(amzAlgorithm) != "" { + // Presigned URL: auth fields live in the query string. + presigned = true + algorithm = query.Get(amzAlgorithm) + credential = query.Get(amzCredential) + signedHeaders = query.Get(amzSignedHdrs) + signature = query.Get(amzSignature) + date = query.Get(amzDate) + regionSet = query.Get(amzRegionSet) + expires, _ = strconv.Atoi(query.Get(amzExpires)) + payloadHash = query.Get(amzContentSHA) + if payloadHash == "" { + payloadHash = unsignedPayload + } + } else if auth := headers.Get("Authorization"); strings.HasPrefix(auth, "AWS4-") { + algorithm, credential, signedHeaders, signature = parseAuthorization(auth) + date = headers.Get(amzDate) + regionSet = headers.Get(amzRegionSet) + payloadHash = headers.Get(amzContentSHA) + if payloadHash == "" { + return nil, fmt.Errorf("missing %s header", amzContentSHA) + } + } else { + return nil, errors.New("request is not signed") + } + + scheme := Scheme(algorithm) + if scheme != SchemeV4 && scheme != SchemeV4a { + return nil, fmt.Errorf("unsupported signature algorithm %q", algorithm) + } + if credential == "" || signedHeaders == "" || signature == "" || date == "" { + return nil, errors.New("incomplete signature parameters") + } + + signedHeaderList := splitSignedHeaders(signedHeaders) + // Header-authenticated requests must cover Host with the signature, and SigV4a + // must additionally cover X-Amz-Region-Set (the authorization region), so an + // on-path attacker cannot rewrite them without invalidating the signature. + // Presigned URLs carry host and the region-set as signed query params, so this + // applies to header auth only. + if !presigned { + if !slices.Contains(signedHeaderList, "host") { + return nil, errors.New("host must be a signed header") + } + if scheme == SchemeV4a && !slices.Contains(signedHeaderList, strings.ToLower(amzRegionSet)) { + return nil, errors.New("x-amz-region-set must be a signed header for SigV4a") + } + } + + // Credential = "/". V4 scope carries the region; V4a does + // not (region comes from X-Amz-Region-Set). + credParts := strings.Split(credential, "/") + wantParts := 5 + if scheme == SchemeV4a { + wantParts = 4 + } + if len(credParts) != wantParts || credParts[len(credParts)-1] != terminator { + return nil, fmt.Errorf("malformed credential %q", credential) + } + accessKeyID := credParts[0] + scope := strings.Join(credParts[1:], "/") + + // SigV4 carries a single credential-scope region; SigV4a carries a + // (comma-separated) X-Amz-Region-Set. + var regions []string + if scheme == SchemeV4 { + regions = []string{credParts[2]} + } else { + regions = splitRegionSet(regionSet) + } + + canonicalURI := u.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + + signed := query + signed.Del(amzSignature) + + host := extractHost(headers, u) + + return &SignedRequest{ + Scheme: scheme, + AccessKeyID: accessKeyID, + Regions: regions, + method: req.Method, + canonicalURI: canonicalURI, + query: signed, + headers: headers, + host: host, + signedHeaders: signedHeaderList, + payloadHash: payloadHash, + amzDate: date, + scope: scope, + signature: signature, + presigned: presigned, + expires: expires, + }, nil +} + +// Verify recomputes the request signature from secretAccessKey (the client's +// multibase base64url secret) and compares it to the one on the request. It +// returns nil when the signature is valid. +func Verify(req *SignedRequest, secretAccessKey string) error { + switch req.Scheme { + case SchemeV4: + return verifyV4(req, secretAccessKey) + case SchemeV4a: + return verifyV4a(req, secretAccessKey) + default: + return fmt.Errorf("unsupported signature algorithm %q", req.Scheme) + } +} + +// VerifyWithKey verifies the request signature using a derived key previously +// produced by [DeriveKey]. +// +// For SigV4 key is the 32-byte HMAC signing key; for SigV4a it is the 33-byte +// compressed SEC1 P-256 public key. It returns nil when the signature is valid. +// It checks only the signature; time bounds, region, and permissions are the +// caller's responsibility (as with [Verify]). +func VerifyWithKey(req *SignedRequest, key []byte) error { + switch req.Scheme { + case SchemeV4: + return verifyV4WithKey(req, key) + case SchemeV4a: + return verifyV4aWithKey(req, key) + default: + return fmt.Errorf("unsupported signature algorithm %q", req.Scheme) + } +} + +// DeriveKey returns the derived signing key for the request, that can be used +// to verify subsequent requests with the same signature scheme. See +// [VerifyWithKey]. +// +// For SigV4 it returns the 32-byte HMAC signing key derived for the request's +// date/region/service scope (symmetric — used to recompute and compare the +// HMAC). For SigV4a it returns the 33-byte compressed SEC1 P-256 public key. +func DeriveKey(req *SignedRequest, secretAccessKey string) ([]byte, error) { + switch req.Scheme { + case SchemeV4: + return deriveSigningKeyV4(secretAccessKey, req.scopeDate(), req.scopeRegion(), req.scopeService()), nil + case SchemeV4a: + return derivedPublicKeyV4a(req.AccessKeyID, secretAccessKey) + default: + return nil, fmt.Errorf("unsupported signature algorithm %q", req.Scheme) + } +} + +const ( + // maxPresignExpiry is AWS's upper bound on a presigned URL's validity window. + maxPresignExpiry = 7 * 24 * 60 * 60 // 7 days, in seconds + // maxClockSkew is the tolerance applied to a header-authenticated request's + // X-Amz-Date (AWS rejects beyond this as RequestTimeTooSkewed). + maxClockSkew = 15 * time.Minute +) + +// ValidateTimeBounds checks that the request is still valid at now, bounding +// signature replay. For presigned requests it enforces the +// [signedAt, signedAt + X-Amz-Expires] window (and a 7-day cap on X-Amz-Expires). +// For header-authenticated requests (no X-Amz-Expires) it enforces an X-Amz-Date +// clock-skew window of ±maxClockSkew. +func ValidateTimeBounds(req *SignedRequest, now time.Time) error { + signedAt, err := time.Parse(amzDateFormat, req.amzDate) + if err != nil { + return fmt.Errorf("parsing X-Amz-Date: %w", err) + } + + if !req.presigned { + if now.Sub(signedAt).Abs() > maxClockSkew { + return fmt.Errorf("request time %s is outside the allowed clock skew", signedAt.Format(time.RFC3339)) + } + return nil + } + + if req.expires <= 0 || req.expires > maxPresignExpiry { + return fmt.Errorf("invalid X-Amz-Expires %d", req.expires) + } + expiresAt := signedAt.Add(time.Duration(req.expires) * time.Second) + if now.Before(signedAt) { + return fmt.Errorf("presigned URL not yet valid (signed %s)", signedAt.Format(time.RFC3339)) + } + if now.After(expiresAt) { + return fmt.Errorf("presigned URL expired at %s", expiresAt.Format(time.RFC3339)) + } + return nil +} + +// scopeService returns the service element of the credential scope (e.g. "s3"). +func (s *SignedRequest) scopeService() string { + parts := strings.Split(s.scope, "/") + // scope is "/[/]/aws4_request"; service is second-last. + if len(parts) < 2 { + return "" + } + return parts[len(parts)-2] +} + +// scopeDate returns the yyyymmdd date element of the credential scope. +func (s *SignedRequest) scopeDate() string { + parts := strings.Split(s.scope, "/") + if len(parts) == 0 { + return "" + } + return parts[0] +} + +// scopeRegion returns the region element of a SigV4 credential scope +// ("///aws4_request"); it is the source of truth for the +// SigV4 signing-key derivation. SigV4a scopes carry no region. +func (s *SignedRequest) scopeRegion() string { + parts := strings.Split(s.scope, "/") + if len(parts) != 4 { + return "" + } + return parts[1] +} + +// splitRegionSet splits a SigV4a X-Amz-Region-Set into its (trimmed) regions. +func splitRegionSet(s string) []string { + raw := strings.Split(strings.ToLower(s), ",") + var regions []string + for _, r := range raw { + r = strings.TrimSpace(r) + if r != "" { + regions = append(regions, r) + } + } + return regions +} + +func splitSignedHeaders(s string) []string { + raw := strings.Split(strings.ToLower(s), ";") + var hdrs []string + for _, h := range raw { + h = strings.TrimSpace(h) + if h != "" { + hdrs = append(hdrs, h) + } + } + sort.Strings(hdrs) + return hdrs +} + +// parseAuthorization splits a SigV4/SigV4a Authorization header value: +// " Credential=, SignedHeaders=, Signature=". +func parseAuthorization(auth string) (algorithm, credential, signedHeaders, signature string) { + algorithm, rest, ok := strings.Cut(auth, " ") + if !ok { + return "", "", "", "" + } + for part := range strings.SplitSeq(rest, ",") { + part = strings.TrimSpace(part) + k, v, ok := strings.Cut(part, "=") + if !ok { + continue + } + switch k { + case "Credential": + credential = v + case "SignedHeaders": + signedHeaders = v + case "Signature": + signature = v + } + } + return algorithm, credential, signedHeaders, signature +} diff --git a/pkg/sigv4/sigv4_test.go b/pkg/sigv4/sigv4_test.go new file mode 100644 index 0000000..67c4d37 --- /dev/null +++ b/pkg/sigv4/sigv4_test.go @@ -0,0 +1,418 @@ +package sigv4 + +import ( + "encoding/hex" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestSigV4KnownAnswer checks the canonical-request + string-to-sign + HMAC +// chain against AWS's documented worked example, anchoring SigV4 correctness: +// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html +func TestSigV4KnownAnswer(t *testing.T) { + sr := &SignedRequest{ + Scheme: SchemeV4, + Regions: []string{"us-east-1"}, + method: "GET", + canonicalURI: "/", + query: mustQuery(t, "Action=ListUsers&Version=2010-05-08"), + headers: http.Header{ + "Content-Type": {"application/x-www-form-urlencoded; charset=utf-8"}, + "X-Amz-Date": {"20150830T123600Z"}, + }, + host: "iam.amazonaws.com", + signedHeaders: []string{"content-type", "host", "x-amz-date"}, + payloadHash: hashSHA256(nil), + amzDate: "20150830T123600Z", + scope: "20150830/us-east-1/iam/aws4_request", + } + + const ( + secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" + want = "5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7" + ) + require.Equal(t, want, sr.signV4(secret)) +} + +func mustQuery(t *testing.T, raw string) url.Values { + t.Helper() + v, err := url.ParseQuery(raw) + require.NoError(t, err) + return v +} + +func TestRoundTrip(t *testing.T) { + const ( + akid = "z6MkExampleAccessKeyIdentifier000000000000000" + secret = "uExampleSecretAccessKeyMaterial00000000000000" + region = "us-east-1" + ) + at := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + + for _, scheme := range []Scheme{SchemeV4, SchemeV4a} { + t.Run(string(scheme), func(t *testing.T) { + req := Request{Method: "GET", URL: "https://bucket.s3.fil.one/path?x-id=ListBuckets"} + + signed, err := Presign(req, akid, secret, region, scheme, at, time.Hour) + require.NoError(t, err) + + sr, err := Parse(signed) + require.NoError(t, err) + require.Equal(t, scheme, sr.Scheme) + require.Equal(t, akid, sr.AccessKeyID) + require.Equal(t, []string{region}, sr.Regions) + + require.NoError(t, Verify(sr, secret), "valid signature should verify") + require.Error(t, Verify(sr, "uWrongSecret0000000000000000000000000000000"), "wrong secret should fail") + }) + } +} + +func TestVerifyRejectsTamperedSignature(t *testing.T) { + const ( + akid = "z6MkExampleAccessKeyIdentifier000000000000000" + secret = "uExampleSecretAccessKeyMaterial00000000000000" + ) + signed, err := Presign( + Request{Method: "GET", URL: "https://bucket.s3.fil.one/"}, + akid, secret, "us-east-1", SchemeV4, time.Unix(0, 0).UTC(), time.Hour, + ) + require.NoError(t, err) + + sr, err := Parse(signed) + require.NoError(t, err) + sr.signature = "deadbeef" // tamper + require.Error(t, Verify(sr, secret)) +} + +func TestDeriveKeyV4aDeterministic(t *testing.T) { + const ( + akid = "z6MkExampleAccessKeyIdentifier000000000000000" + secret = "uExampleSecretAccessKeyMaterial00000000000000" + ) + k1, err := deriveKeyV4a(akid, secret) + require.NoError(t, err) + k2, err := deriveKeyV4a(akid, secret) + require.NoError(t, err) + other, err := deriveKeyV4a(akid, "uDifferentSecret00000000000000000000000000000") + require.NoError(t, err) + + b1, err := k1.Bytes() + require.NoError(t, err) + b2, err := k2.Bytes() + require.NoError(t, err) + bOther, err := other.Bytes() + require.NoError(t, err) + + require.Equal(t, b1, b2, "derivation must be deterministic") + require.NotEqual(t, b1, bOther, "different secret yields a different key") +} + +func TestDeriveKey(t *testing.T) { + const ( + akid = "z6MkExampleAccessKeyIdentifier000000000000000" + secret = "uExampleSecretAccessKeyMaterial00000000000000" + region = "us-east-1" + ) + at := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + + presign := func(t *testing.T, scheme Scheme) *SignedRequest { + t.Helper() + signed, err := Presign( + Request{Method: "GET", URL: "https://bucket.s3.fil.one/path?x-id=ListBuckets"}, + akid, secret, region, scheme, at, time.Hour, + ) + require.NoError(t, err) + sr, err := Parse(signed) + require.NoError(t, err) + return sr + } + + t.Run("sigv4 returns the HMAC signing key", func(t *testing.T) { + sr := presign(t, SchemeV4) + key, err := DeriveKey(sr, secret) + require.NoError(t, err) + require.Len(t, key, 32, "SigV4 signing key is HMAC-SHA256 sized") + // The derived key, applied as the gateway would, must reproduce the + // request's signature. + got := hex.EncodeToString(hmacSHA256(key, []byte(sr.stringToSign()))) + require.Equal(t, sr.signature, got) + }) + + t.Run("sigv4a returns the compressed public key", func(t *testing.T) { + sr := presign(t, SchemeV4a) + key, err := DeriveKey(sr, secret) + require.NoError(t, err) + require.Len(t, key, 33, "compressed SEC1 P-256 point") + require.True(t, key[0] == 0x02 || key[0] == 0x03, "compressed-point prefix") + + // Must be the access key's verifying public key: compare against the + // canonical uncompressed encoding (0x04 || X || Y). + priv, err := deriveKeyV4a(akid, secret) + require.NoError(t, err) + uncompressed, err := priv.PublicKey.Bytes() + require.NoError(t, err) + require.Equal(t, uncompressed[1:33], key[1:], "X coordinate") + require.Equal(t, byte(0x02|(uncompressed[64]&1)), key[0], "Y-parity prefix") + }) + + t.Run("unsupported scheme errors", func(t *testing.T) { + _, err := DeriveKey(&SignedRequest{Scheme: "bogus"}, secret) + require.Error(t, err) + }) +} + +func TestVerifyWithKey(t *testing.T) { + const ( + akid = "z6MkExampleAccessKeyIdentifier000000000000000" + secret = "uExampleSecretAccessKeyMaterial00000000000000" + altSecret = "uDifferentSecretAccessKeyMaterial0000000000000" + region = "us-east-1" + ) + at := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + + signedFor := func(t *testing.T, scheme Scheme, signSecret string) *SignedRequest { + t.Helper() + signed, err := Presign( + Request{Method: "GET", URL: "https://bucket.s3.fil.one/path?x-id=ListBuckets"}, + akid, signSecret, region, scheme, at, time.Hour, + ) + require.NoError(t, err) + sr, err := Parse(signed) + require.NoError(t, err) + return sr + } + + for _, scheme := range []Scheme{SchemeV4, SchemeV4a} { + t.Run(string(scheme), func(t *testing.T) { + // The Hilt→gateway round-trip: derive the key, then verify with it. + sr := signedFor(t, scheme, secret) + key, err := DeriveKey(sr, secret) + require.NoError(t, err) + require.NoError(t, VerifyWithKey(sr, key), "derived key should verify the request") + + // A key derived for a different secret must not verify. + wrong, err := DeriveKey(signedFor(t, scheme, altSecret), altSecret) + require.NoError(t, err) + require.Error(t, VerifyWithKey(sr, wrong), "mismatched key should fail") + }) + } + + t.Run("malformed sigv4a key", func(t *testing.T) { + sr := signedFor(t, SchemeV4a, secret) + require.Error(t, VerifyWithKey(sr, []byte{0x02, 0x00})) + }) + + t.Run("unsupported scheme errors", func(t *testing.T) { + require.Error(t, VerifyWithKey(&SignedRequest{Scheme: "bogus"}, nil)) + }) +} + +func TestParseHeaderAuth(t *testing.T) { + req := Request{ + Method: "GET", + URL: "https://bucket.s3.fil.one/", + Headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=z6MkAbc/20260616/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123", + "X-Amz-Date": "20260616T091923Z", + "X-Amz-Content-Sha256": unsignedPayload, + }, + } + sr, err := Parse(req) + require.NoError(t, err) + require.Equal(t, SchemeV4, sr.Scheme) + require.Equal(t, "z6MkAbc", sr.AccessKeyID) + require.Equal(t, []string{"us-west-2"}, sr.Regions) +} + +func TestParseRegionsV4a(t *testing.T) { + // SigV4a credential scope omits the region; regions come from X-Amz-Region-Set. + u := "https://bucket.s3.fil.one/?X-Amz-Algorithm=AWS4-ECDSA-P256-SHA256" + + "&X-Amz-Credential=z6MkAbc%2F20260616%2Fs3%2Faws4_request" + + "&X-Amz-Region-Set=us-east-1%2Cus-west-2" + + "&X-Amz-SignedHeaders=host&X-Amz-Signature=abc&X-Amz-Date=20260616T091923Z" + sr, err := Parse(Request{Method: "GET", URL: u}) + require.NoError(t, err) + require.Equal(t, SchemeV4a, sr.Scheme) + require.Equal(t, []string{"us-east-1", "us-west-2"}, sr.Regions) +} + +func TestParseErrors(t *testing.T) { + t.Run("unsigned", func(t *testing.T) { + _, err := Parse(Request{Method: "GET", URL: "https://bucket.s3.fil.one/"}) + require.Error(t, err) + }) + t.Run("malformed credential", func(t *testing.T) { + _, err := Parse(Request{ + Method: "GET", + URL: "https://bucket.s3.fil.one/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=z6MkAbc%2Fonly&X-Amz-SignedHeaders=host&X-Amz-Signature=abc&X-Amz-Date=20260616T091923Z", + }) + require.Error(t, err) + }) + t.Run("header auth missing payload hash", func(t *testing.T) { + // X-Amz-Content-Sha256 is part of the signed canonical request; refuse to + // invent an (empty-payload) hash for it rather than verify against the + // value the client actually signed. + _, err := Parse(Request{ + Method: "GET", + URL: "https://bucket.s3.fil.one/", + Headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=z6MkAbc/20260616/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123", + "X-Amz-Date": "20260616T091923Z", + }, + }) + require.Error(t, err) + }) +} + +// TestParseRequiresSignedHeaders covers the header-authenticated hardening: Host +// must be a signed header, and SigV4a must additionally sign X-Amz-Region-Set, so +// the host and authorization region are covered by the signature. (These are +// parse-time structural checks; no valid signature is needed.) +func TestParseRequiresSignedHeaders(t *testing.T) { + headerAuth := func(scheme Scheme, credential, signedHeaders string, extra map[string]string) Request { + headers := map[string]string{ + "Authorization": string(scheme) + " Credential=" + credential + ", SignedHeaders=" + signedHeaders + ", Signature=abc123", + "X-Amz-Date": "20260616T091923Z", + "X-Amz-Content-Sha256": unsignedPayload, + } + for k, v := range extra { + headers[k] = v + } + return Request{Method: "GET", URL: "https://bucket.s3.fil.one/", Headers: headers} + } + + t.Run("SigV4 rejects when host is not signed", func(t *testing.T) { + _, err := Parse(headerAuth(SchemeV4, "z6MkAbc/20260616/us-west-2/s3/aws4_request", "x-amz-date", nil)) + require.Error(t, err) + }) + + t.Run("SigV4a rejects when region-set is not signed", func(t *testing.T) { + req := headerAuth(SchemeV4a, "z6MkAbc/20260616/s3/aws4_request", "host;x-amz-date", + map[string]string{"X-Amz-Region-Set": "us-west-2"}) + _, err := Parse(req) + require.Error(t, err) + }) + + t.Run("SigV4a accepts when host and region-set are signed", func(t *testing.T) { + req := headerAuth(SchemeV4a, "z6MkAbc/20260616/s3/aws4_request", "host;x-amz-date;x-amz-region-set", + map[string]string{"X-Amz-Region-Set": "us-west-2"}) + sr, err := Parse(req) + require.NoError(t, err) + require.Equal(t, SchemeV4a, sr.Scheme) + require.Equal(t, []string{"us-west-2"}, sr.Regions) + }) +} + +func TestValidateTimeBounds(t *testing.T) { + const ( + akid = "z6MkExampleAccessKeyIdentifier000000000000000" + secret = "uExampleSecretAccessKeyMaterial00000000000000" + ) + signedAt := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + + presign := func(t *testing.T, at time.Time, expires time.Duration) *SignedRequest { + t.Helper() + signed, err := Presign(Request{Method: "GET", URL: "https://bucket.s3.fil.one/"}, akid, secret, "us-east-1", SchemeV4, at, expires) + require.NoError(t, err) + sr, err := Parse(signed) + require.NoError(t, err) + return sr + } + + t.Run("presigned within window", func(t *testing.T) { + sr := presign(t, signedAt, time.Hour) + require.NoError(t, ValidateTimeBounds(sr, signedAt.Add(30*time.Minute))) + }) + + t.Run("presigned expired", func(t *testing.T) { + sr := presign(t, signedAt, time.Hour) + require.Error(t, ValidateTimeBounds(sr, signedAt.Add(2*time.Hour))) + }) + + t.Run("presigned not yet valid", func(t *testing.T) { + sr := presign(t, signedAt, time.Hour) + require.Error(t, ValidateTimeBounds(sr, signedAt.Add(-time.Minute))) + }) + + t.Run("presigned expires too large", func(t *testing.T) { + sr := presign(t, signedAt, 8*24*time.Hour) // > 7 days + require.Error(t, ValidateTimeBounds(sr, signedAt.Add(time.Hour))) + }) + + // Header auth carries no X-Amz-Expires; it's bound by the clock-skew window. + t.Run("header auth within skew", func(t *testing.T) { + sr := &SignedRequest{amzDate: signedAt.Format(amzDateFormat)} + require.NoError(t, ValidateTimeBounds(sr, signedAt.Add(5*time.Minute))) + }) + + t.Run("header auth too skewed", func(t *testing.T) { + sr := &SignedRequest{amzDate: signedAt.Format(amzDateFormat)} + require.Error(t, ValidateTimeBounds(sr, signedAt.Add(time.Hour))) + }) +} + +// TestExtractHost ports the forwarded-header cases from SeaweedFS's +// TestExtractHostHeader (the externalHost override cases are out of scope). It +// verifies the signed Host is reconstructed from X-Forwarded-Host/Port/Proto with +// AWS-style default-port stripping and IPv6 bracket handling. +func TestExtractHost(t *testing.T) { + tests := []struct { + name string + host string // Host header (r.Host) + forwardedHost string + forwardedPort string + forwardedProto string + want string + }{ + {name: "basic host without forwarding", host: "example.com", want: "example.com"}, + {name: "host with port without forwarding", host: "example.com:8080", want: "example.com:8080"}, + {name: "X-Forwarded-Host without port", host: "backend:8333", forwardedHost: "example.com", want: "example.com"}, + {name: "XFH with XFP (HTTP non-standard)", host: "backend:8333", forwardedHost: "example.com", forwardedPort: "8080", forwardedProto: "http", want: "example.com:8080"}, + {name: "XFH with XFP (HTTPS non-standard)", host: "backend:8333", forwardedHost: "example.com", forwardedPort: "8443", forwardedProto: "https", want: "example.com:8443"}, + {name: "XFH with XFP (HTTP standard port 80)", host: "backend:8333", forwardedHost: "example.com", forwardedPort: "80", forwardedProto: "http", want: "example.com"}, + {name: "XFH with XFP (HTTPS standard port 443)", host: "backend:8333", forwardedHost: "example.com", forwardedPort: "443", forwardedProto: "https", want: "example.com"}, + {name: "XFH with port already included", host: "backend:8333", forwardedHost: "127.0.0.1:8433", forwardedPort: "8433", forwardedProto: "https", want: "127.0.0.1:8433"}, + {name: "XFH with standard port already included (HTTPS 443)", host: "backend:8333", forwardedHost: "example.com:443", forwardedPort: "443", forwardedProto: "https", want: "example.com"}, + {name: "XFH with port, no XFP", host: "backend:8333", forwardedHost: "example.com:9000", forwardedProto: "http", want: "example.com:9000"}, + {name: "IPv6 brackets and port in XFH", host: "backend:8333", forwardedHost: "[::1]:8080", forwardedPort: "8080", forwardedProto: "http", want: "[::1]:8080"}, + {name: "IPv6 no brackets, add brackets with port", host: "backend:8333", forwardedHost: "::1", forwardedPort: "8080", forwardedProto: "http", want: "[::1]:8080"}, + {name: "IPv6 no brackets, standard port stripped", host: "backend:8333", forwardedHost: "::1", forwardedPort: "80", forwardedProto: "http", want: "::1"}, + {name: "IPv6 no brackets, standard HTTPS port stripped", host: "backend:8333", forwardedHost: "2001:db8::1", forwardedPort: "443", forwardedProto: "https", want: "2001:db8::1"}, + {name: "IPv6 brackets, no port, add port", host: "backend:8333", forwardedHost: "[2001:db8::1]", forwardedPort: "8080", forwardedProto: "http", want: "[2001:db8::1]:8080"}, + {name: "IPv6 full brackets, default port stripped", host: "backend:8333", forwardedHost: "[2001:db8:85a3::8a2e:370:7334]:443", forwardedPort: "443", forwardedProto: "https", want: "2001:db8:85a3::8a2e:370:7334"}, + {name: "IPv4-mapped IPv6 no brackets, add brackets with port", host: "backend:8333", forwardedHost: "::ffff:127.0.0.1", forwardedPort: "8080", forwardedProto: "http", want: "[::ffff:127.0.0.1]:8080"}, + {name: "simple port 442", host: "bucket.domain.com:442", want: "bucket.domain.com:442"}, + {name: "port 442 with XFH", host: "backend:8333", forwardedHost: "bucket.domain.com:442", want: "bucket.domain.com:442"}, + {name: "port 442 with XFP", host: "backend:8333", forwardedHost: "bucket.domain.com", forwardedPort: "442", want: "bucket.domain.com:442"}, + {name: "HTTPS with port 442 (not stripped)", host: "bucket.domain.com:442", forwardedProto: "https", want: "bucket.domain.com:442"}, + {name: "XFH multiple hosts (including port)", forwardedHost: "bucket.domain.com:442, internal.proxy", want: "bucket.domain.com:442"}, + {name: "IPv6 with port", host: "[2001:db8::1]:442", want: "[2001:db8::1]:442"}, + {name: "XFH port 442 but XFP 80 (prefer 442)", forwardedHost: "bucket.domain.com:442", forwardedPort: "80", forwardedProto: "http", want: "bucket.domain.com:442"}, + {name: "XFP misreports 443 but Host has 30007", host: "storage-stgops.mt.mtnet:30007", forwardedHost: "storage-stgops.mt.mtnet", forwardedPort: "443", forwardedProto: "https", want: "storage-stgops.mt.mtnet:30007"}, + {name: "XFH already has correct port, ignore misaligned XFP", host: "backend:8333", forwardedHost: "storage-stgops.mt.mtnet:30007", forwardedPort: "443", forwardedProto: "https", want: "storage-stgops.mt.mtnet:30007"}, + {name: "XFH no port, match Host hostname and take its port", host: "example.com:8080", forwardedHost: "example.com", forwardedPort: "80", forwardedProto: "http", want: "example.com:8080"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := http.Header{} + headers.Set("Host", tt.host) + if tt.forwardedHost != "" { + headers.Set("X-Forwarded-Host", tt.forwardedHost) + } + if tt.forwardedPort != "" { + headers.Set("X-Forwarded-Port", tt.forwardedPort) + } + if tt.forwardedProto != "" { + headers.Set("X-Forwarded-Proto", tt.forwardedProto) + } + u := &url.URL{Scheme: "http", Host: tt.host} + require.Equal(t, tt.want, extractHost(headers, u)) + }) + } +} diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/LICENSE b/pkg/sigv4/testdata/aws-sig-v4-test-suite/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/NOTICE b/pkg/sigv4/testdata/aws-sig-v4-test-suite/NOTICE new file mode 100644 index 0000000..9db16d7 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/NOTICE @@ -0,0 +1,2 @@ +AWS Signature Version 4 Test Suite +Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/README.md b/pkg/sigv4/testdata/aws-sig-v4-test-suite/README.md new file mode 100644 index 0000000..3ae5860 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/README.md @@ -0,0 +1,112 @@ +# Signature Version 4 Test Suite + +To assist you in the development of an AWS client that supports Signature Version 4, you can use the files in the test suite to ensure your code is performing each step of the signing process correctly. + +Each test group contains five files that you can use to validate each of the tasks described in Signature Version 4 Signing Process. The following list describes the contents of each file. + +* `file-name.req` — the web request to be signed. +* `file-name.creq` — the resulting canonical request. +* `file-name.sts` — the resulting string to sign. +* `file-name.authz` — the Authorization header. +* `file-name.sreq` — the signed request. + +## Credential Scope and Secret Key + +The examples in the test suite use the following credential scope: + +``` +AKIDEXAMPLE/20150830/us-east-1/service/aws4_request +``` + +The example secret key used for signing is: + +``` +wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY +``` + +## Example — A Simple GET Request with Parameters + +The following example shows the web request to be signed from the `get-vanilla-query-order-key-case.req` file. This is the original request. + +``` +GET /?Param2=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +``` + +### Task 1: Create a Canonical Request + +In the steps outlined in Task 1: Create a Canonical Request for Signature Version 4, change the request in the get-vanilla-query-order-key-case.req file. + +``` +GET /?Param2=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +``` + +This creates the canonical request in the `get-vanilla-query-order-key-case.creq` file. + +``` +GET +/ +Param1=value1&Param2=value2 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +#### Notes + +* The parameters are sorted alphabetically (by character code). +* The header names are lowercase. +* There is a line break between the x-amz-date header and the signed headers. +* The hash of the payload is the hash of the empty string. + +### Task 2: Create a String to Sign + +The hash of the canonical request returns the following value: + +``` +816cd5b414d056048ba4f7c5386d6e0533120fb1fcfa93762cf0fc39e2cf19e0 +``` + +In the steps outlined in Task 2: Create a String to Sign for Signature Version 4, add the algorithm, request date, credential scope, and the canonical request hash to create the string to sign. + +The result is the `get-vanilla-query-order-key-case.sts` file. + +``` +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +816cd5b414d056048ba4f7c5386d6e0533120fb1fcfa93762cf0fc39e2cf19e0 +``` + +Notes + +* The date on the second line matches the x-amz-date header, as well as the first element in the credential scope. +* The last line is the hex-encoded value for the hash of the canonical request. + +### Task 3: Calculate the Signature + +In the steps outlined in Task 3: Calculate the Signature for AWS Signature Version 4, create a signature with your signing key and the string to sign from the `get-vanilla-query-order-key-case.sts` file. + +The result generates the contents in the `get-vanilla-query-order-key-case.authz` file. + +``` +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500 +``` + +### Task 4: Add the Signing Information to the Request + +In the steps outlined in Task 4: Add the Signature to the HTTP Request, add the signing information generated in task 3 to the original request. For example, take the contents in the `get-vanilla-query-order-key-case.authz`, add it to the Authorization header, and then add the result to the `get-vanilla-query-order-key-case.req`. + +This creates the signed request in the get-vanilla-query-order-key-case.sreq file. + +``` +GET /?Param2=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service +``` diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.authz new file mode 100644 index 0000000..ade3ec7 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=c9d5ea9f3f72853aea855b47ea873832890dbdd183b4468f858259531a5138ea \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.creq new file mode 100644 index 0000000..fa8f49a --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.creq @@ -0,0 +1,9 @@ +GET +/ + +host:example.amazonaws.com +my-header1:value2,value2,value1 +x-amz-date:20150830T123600Z + +host;my-header1;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.req new file mode 100644 index 0000000..08a0364 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.req @@ -0,0 +1,6 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value2 +My-Header1:value2 +My-Header1:value1 +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.sreq new file mode 100644 index 0000000..f0166e1 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.sreq @@ -0,0 +1,7 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value2 +My-Header1:value2 +My-Header1:value1 +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=c9d5ea9f3f72853aea855b47ea873832890dbdd183b4468f858259531a5138ea \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.sts new file mode 100644 index 0000000..48a135e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-key-duplicate/get-header-key-duplicate.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +dc7f04a3abfde8d472b0ab1a418b741b7c67174dad1551b4117b15527fbe966c \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.authz new file mode 100644 index 0000000..e2717bf --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=ba17b383a53190154eb5fa66a1b836cc297cc0a3d70a5d00705980573d8ff790 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.creq new file mode 100644 index 0000000..721a39f --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.creq @@ -0,0 +1,9 @@ +GET +/ + +host:example.amazonaws.com +my-header1:value1,value2,value3 +x-amz-date:20150830T123600Z + +host;my-header1;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.req new file mode 100644 index 0000000..7caa6ac --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.req @@ -0,0 +1,6 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value1 + value2 + value3 +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.sreq new file mode 100644 index 0000000..56955d9 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.sreq @@ -0,0 +1,7 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value1 + value2 + value3 +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=ba17b383a53190154eb5fa66a1b836cc297cc0a3d70a5d00705980573d8ff790 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.sts new file mode 100644 index 0000000..0a3350a --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-multiline/get-header-value-multiline.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +b7b6cbfd8a0430b78891e986784da2630c8a135a8595cec25b26ea94f926ee55 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.authz new file mode 100644 index 0000000..c0409ab --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=08c7e5a9acfcfeb3ab6b2185e75ce8b1deb5e634ec47601a50643f830c755c01 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.creq new file mode 100644 index 0000000..e336bc9 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.creq @@ -0,0 +1,9 @@ +GET +/ + +host:example.amazonaws.com +my-header1:value4,value1,value3,value2 +x-amz-date:20150830T123600Z + +host;my-header1;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.req new file mode 100644 index 0000000..f7bd9e6 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.req @@ -0,0 +1,7 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value4 +My-Header1:value1 +My-Header1:value3 +My-Header1:value2 +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.sreq new file mode 100644 index 0000000..79e16a9 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.sreq @@ -0,0 +1,8 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value4 +My-Header1:value1 +My-Header1:value3 +My-Header1:value2 +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=08c7e5a9acfcfeb3ab6b2185e75ce8b1deb5e634ec47601a50643f830c755c01 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.sts new file mode 100644 index 0000000..711a8d4 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-order/get-header-value-order.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +31ce73cd3f3d9f66977ad3dd957dc47af14df92fcd8509f59b349e9137c58b86 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.authz new file mode 100644 index 0000000..4874ac0 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;my-header2;x-amz-date, Signature=acc3ed3afb60bb290fc8d2dd0098b9911fcaa05412b367055dee359757a9c736 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.creq new file mode 100644 index 0000000..a59087c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.creq @@ -0,0 +1,10 @@ +GET +/ + +host:example.amazonaws.com +my-header1:value1 +my-header2:"a b c" +x-amz-date:20150830T123600Z + +host;my-header1;my-header2;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.req new file mode 100644 index 0000000..901f36c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.req @@ -0,0 +1,5 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1: value1 +My-Header2: "a b c" +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.sreq new file mode 100644 index 0000000..98224c9 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.sreq @@ -0,0 +1,6 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +My-Header1: value1 +My-Header2: "a b c" +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;my-header2;x-amz-date, Signature=acc3ed3afb60bb290fc8d2dd0098b9911fcaa05412b367055dee359757a9c736 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.sts new file mode 100644 index 0000000..a0b15cc --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-header-value-trim/get-header-value-trim.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +a726db9b0df21c14f559d0a978e563112acb1b9e05476f0a6a1c7d68f28605c7 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.authz new file mode 100644 index 0000000..2943ec8 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=07ef7494c76fa4850883e2b006601f940f8a34d404d0cfa977f52a65bbf5f24f \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.creq new file mode 100644 index 0000000..8af54df --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.creq @@ -0,0 +1,8 @@ +GET +/-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.req new file mode 100644 index 0000000..da760cd --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.req @@ -0,0 +1,3 @@ +GET /-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.sreq new file mode 100644 index 0000000..8001b3d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.sreq @@ -0,0 +1,4 @@ +GET /-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=07ef7494c76fa4850883e2b006601f940f8a34d404d0cfa977f52a65bbf5f24f \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.sts new file mode 100644 index 0000000..e9dc541 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-unreserved/get-unreserved.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +6a968768eefaa713e2a6b16b589a8ea192661f098f37349f4e2c0082757446f9 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.authz new file mode 100644 index 0000000..738b3fb --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=8318018e0b0f223aa2bbf98705b62bb787dc9c0e678f255a891fd03141be5d85 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.creq new file mode 100644 index 0000000..5d4b9f6 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.creq @@ -0,0 +1,8 @@ +GET +/%E1%88%B4 + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.req new file mode 100644 index 0000000..da4808d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.req @@ -0,0 +1,3 @@ +GET /ሴ HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.sreq new file mode 100644 index 0000000..94eadb6 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.sreq @@ -0,0 +1,4 @@ +GET /ሴ HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=8318018e0b0f223aa2bbf98705b62bb787dc9c0e678f255a891fd03141be5d85 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.sts new file mode 100644 index 0000000..5edc8f4 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-utf8/get-utf8.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +2a0a97d02205e45ce2e994789806b19270cfbbb0921b278ccf58f5249ac42102 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.authz new file mode 100644 index 0000000..65b5c7c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=a67d582fa61cc504c4bae71f336f98b97f1ea3c7a6bfe1b6e45aec72011b9aeb \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.creq new file mode 100644 index 0000000..c6cdced --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.creq @@ -0,0 +1,8 @@ +GET +/ +Param1=value1 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.req new file mode 100644 index 0000000..970d0a0 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.req @@ -0,0 +1,3 @@ +GET /?Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.sreq new file mode 100644 index 0000000..f081591 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.sreq @@ -0,0 +1,4 @@ +GET /?Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=a67d582fa61cc504c4bae71f336f98b97f1ea3c7a6bfe1b6e45aec72011b9aeb \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.sts new file mode 100644 index 0000000..c4ed216 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-empty-query-key/get-vanilla-empty-query-key.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +1e24db194ed7d0eec2de28d7369675a243488e08526e8c1c73571282f7c517ab \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.authz new file mode 100644 index 0000000..c781fe6 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.creq new file mode 100644 index 0000000..8ae02cd --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.creq @@ -0,0 +1,8 @@ +GET +/ +Param1=value1&Param2=value2 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.req new file mode 100644 index 0000000..8a56f15 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.req @@ -0,0 +1,3 @@ +GET /?Param2=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.sreq new file mode 100644 index 0000000..aa3162d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.sreq @@ -0,0 +1,4 @@ +GET /?Param2=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.sts new file mode 100644 index 0000000..f773de5 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key-case/get-vanilla-query-order-key-case.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +816cd5b414d056048ba4f7c5386d6e0533120fb1fcfa93762cf0fc39e2cf19e0 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.authz new file mode 100644 index 0000000..812cd3f --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=eedbc4e291e521cf13422ffca22be7d2eb8146eecf653089df300a15b2382bd1 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.creq new file mode 100644 index 0000000..36c3cdf --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.creq @@ -0,0 +1,8 @@ +GET +/ +Param1=Value1&Param1=value2 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.req new file mode 100644 index 0000000..375a496 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.req @@ -0,0 +1,3 @@ +GET /?Param1=value2&Param1=Value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.sreq new file mode 100644 index 0000000..bc8e652 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.sreq @@ -0,0 +1,4 @@ +GET /?Param1=value2&Param1=Value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=eedbc4e291e521cf13422ffca22be7d2eb8146eecf653089df300a15b2382bd1 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.sts new file mode 100644 index 0000000..fd43a41 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-key/get-vanilla-query-order-key.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +704b4cef673542d84cdff252633f065e8daeba5f168b77116f8b1bcaf3d38f89 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.authz new file mode 100644 index 0000000..b8ad91f --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5772eed61e12b33fae39ee5e7012498b51d56abc0abb7c60486157bd471c4694 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.creq new file mode 100644 index 0000000..26898eb --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.creq @@ -0,0 +1,8 @@ +GET +/ +Param1=value1&Param1=value2 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.req new file mode 100644 index 0000000..9255bee --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.req @@ -0,0 +1,3 @@ +GET /?Param1=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.sreq new file mode 100644 index 0000000..4793e21 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.sreq @@ -0,0 +1,4 @@ +GET /?Param1=value2&Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5772eed61e12b33fae39ee5e7012498b51d56abc0abb7c60486157bd471c4694 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.sts new file mode 100644 index 0000000..90e66b8 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-order-value/get-vanilla-query-order-value.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +c968629d70850097a2d8781c9bf7edcb988b04cac14cca9be4acc3595f884606 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.authz new file mode 100644 index 0000000..a44ca5b --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=9c3e54bfcdf0b19771a7f523ee5669cdf59bc7cc0884027167c21bb143a40197 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.creq new file mode 100644 index 0000000..5249be3 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.creq @@ -0,0 +1,8 @@ +GET +/ +-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz=-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.req new file mode 100644 index 0000000..d2833b3 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.req @@ -0,0 +1,3 @@ +GET /?-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz=-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.sreq new file mode 100644 index 0000000..ba1ef40 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.sreq @@ -0,0 +1,4 @@ +GET /?-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz=-._~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=9c3e54bfcdf0b19771a7f523ee5669cdf59bc7cc0884027167c21bb143a40197 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.sts new file mode 100644 index 0000000..24a97d2 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query-unreserved/get-vanilla-query-unreserved.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +c30d4703d9f799439be92736156d47ccfb2d879ddf56f5befa6d1d6aab979177 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.authz new file mode 100644 index 0000000..551c027 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.creq new file mode 100644 index 0000000..ed91561 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.creq @@ -0,0 +1,8 @@ +GET +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.req new file mode 100644 index 0000000..0f7a9bf --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.req @@ -0,0 +1,3 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.sreq new file mode 100644 index 0000000..d739b01 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.sreq @@ -0,0 +1,4 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.sts new file mode 100644 index 0000000..b187649 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-query/get-vanilla-query.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.authz new file mode 100644 index 0000000..e016c3d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=2cdec8eed098649ff3a119c94853b13c643bcf08f8b0a1d91e12c9027818dd04 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.creq new file mode 100644 index 0000000..a835c9e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.creq @@ -0,0 +1,8 @@ +GET +/ +%E1%88%B4=bar +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.req new file mode 100644 index 0000000..cc2757e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.req @@ -0,0 +1,3 @@ +GET /?ሴ=bar HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.sreq new file mode 100644 index 0000000..7baf4c8 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.sreq @@ -0,0 +1,4 @@ +GET /?ሴ=bar HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=2cdec8eed098649ff3a119c94853b13c643bcf08f8b0a1d91e12c9027818dd04 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.sts new file mode 100644 index 0000000..51ee71b --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla-utf8-query/get-vanilla-utf8-query.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +eb30c5bed55734080471a834cc727ae56beb50e5f39d1bff6d0d38cb192a7073 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.authz new file mode 100644 index 0000000..551c027 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.creq new file mode 100644 index 0000000..ed91561 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.creq @@ -0,0 +1,8 @@ +GET +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.req new file mode 100644 index 0000000..0f7a9bf --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.req @@ -0,0 +1,3 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.sreq new file mode 100644 index 0000000..d739b01 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.sreq @@ -0,0 +1,4 @@ +GET / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.sts new file mode 100644 index 0000000..b187649 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/get-vanilla/get-vanilla.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.authz new file mode 100644 index 0000000..551c027 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.creq new file mode 100644 index 0000000..ed91561 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.creq @@ -0,0 +1,8 @@ +GET +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.req new file mode 100644 index 0000000..cfd4e8b --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.req @@ -0,0 +1,3 @@ +GET /example1/example2/../.. HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.sreq new file mode 100644 index 0000000..cbdebe2 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.sreq @@ -0,0 +1,4 @@ +GET /example1/example2/../.. HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.sts new file mode 100644 index 0000000..b187649 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative-relative/get-relative-relative.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.authz new file mode 100644 index 0000000..551c027 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.creq new file mode 100644 index 0000000..ed91561 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.creq @@ -0,0 +1,8 @@ +GET +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.req new file mode 100644 index 0000000..9d6d7ca --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.req @@ -0,0 +1,3 @@ +GET /example/.. HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.sreq new file mode 100644 index 0000000..4f59e7d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.sreq @@ -0,0 +1,4 @@ +GET /example/.. HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.sts new file mode 100644 index 0000000..b187649 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-relative/get-relative.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.authz new file mode 100644 index 0000000..551c027 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.creq new file mode 100644 index 0000000..ed91561 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.creq @@ -0,0 +1,8 @@ +GET +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.req new file mode 100644 index 0000000..f3537b7 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.req @@ -0,0 +1,3 @@ +GET /./ HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.sreq new file mode 100644 index 0000000..23a2b41 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.sreq @@ -0,0 +1,4 @@ +GET /./ HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.sts new file mode 100644 index 0000000..b187649 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-dot-slash/get-slash-dot-slash.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.authz new file mode 100644 index 0000000..b76ca1e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=ef75d96142cf21edca26f06005da7988e4f8dc83a165a80865db7089db637ec5 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.creq new file mode 100644 index 0000000..915c57f --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.creq @@ -0,0 +1,8 @@ +GET +/example + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.req new file mode 100644 index 0000000..3c91071 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.req @@ -0,0 +1,3 @@ +GET /./example HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.sreq new file mode 100644 index 0000000..8096609 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.sreq @@ -0,0 +1,4 @@ +GET /./example HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=ef75d96142cf21edca26f06005da7988e4f8dc83a165a80865db7089db637ec5 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.sts new file mode 100644 index 0000000..7429923 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash-pointless-dot/get-slash-pointless-dot.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +214d50c111a8edc4819da6a636336472c916b5240f51e9a51b5c3305180cf702 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.authz new file mode 100644 index 0000000..551c027 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.creq new file mode 100644 index 0000000..ed91561 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.creq @@ -0,0 +1,8 @@ +GET +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.req new file mode 100644 index 0000000..ede8e3c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.req @@ -0,0 +1,3 @@ +GET // HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.sreq new file mode 100644 index 0000000..cde31b4 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.sreq @@ -0,0 +1,4 @@ +GET // HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.sts new file mode 100644 index 0000000..b187649 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slash/get-slash.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +bb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.authz new file mode 100644 index 0000000..307c105 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=9a624bd73a37c9a373b5312afbebe7a714a789de108f0bdfe846570885f57e84 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.creq new file mode 100644 index 0000000..2bdaf74 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.creq @@ -0,0 +1,8 @@ +GET +/example/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.req new file mode 100644 index 0000000..a4307ce --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.req @@ -0,0 +1,3 @@ +GET //example// HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.sreq new file mode 100644 index 0000000..c84a80d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.sreq @@ -0,0 +1,4 @@ +GET //example// HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=9a624bd73a37c9a373b5312afbebe7a714a789de108f0bdfe846570885f57e84 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.sts new file mode 100644 index 0000000..95d1fc2 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-slashes/get-slashes.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +cb96b4ac96d501f7c5c15bc6d67b3035061cfced4af6585ad927f7e6c985c015 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.authz new file mode 100644 index 0000000..832d8a5 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=652487583200325589f1fba4c7e578f72c47cb61beeca81406b39ddec1366741 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.creq new file mode 100644 index 0000000..124a709 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.creq @@ -0,0 +1,8 @@ +GET +/example%20space/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.req new file mode 100644 index 0000000..b7d5e8b --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.req @@ -0,0 +1,3 @@ +GET /example space/ HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.sreq new file mode 100644 index 0000000..eefa20c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.sreq @@ -0,0 +1,4 @@ +GET /example space/ HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=652487583200325589f1fba4c7e578f72c47cb61beeca81406b39ddec1366741 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.sts new file mode 100644 index 0000000..a633f0c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/get-space/get-space.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +63ee75631ed7234ae61b5f736dfc7754cdccfedbff4b5128a915706ee9390d86 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/normalize-path.txt b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/normalize-path.txt new file mode 100644 index 0000000..c2fcb27 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/normalize-path/normalize-path.txt @@ -0,0 +1,3 @@ +A note about signing requests to Amazon S3: + +In exception to this, you do not normalize URI paths for requests to Amazon S3. For example, if you have a bucket with an object named my-object//example//photo.user, use that path. Normalizing the path to my-object/example/photo.user will cause the request to fail. For more information, see Task 1: Create a Canonical Request in the Amazon Simple Storage Service API Reference: http://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#canonical-request \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.authz new file mode 100644 index 0000000..89e572e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.creq new file mode 100644 index 0000000..5c3a943 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.creq @@ -0,0 +1,8 @@ +POST +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.req new file mode 100644 index 0000000..3dc4179 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.req @@ -0,0 +1,3 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.sreq new file mode 100644 index 0000000..a5ada0d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.sreq @@ -0,0 +1,4 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.sts new file mode 100644 index 0000000..a636703 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-case/post-header-key-case.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +553f88c9e4d10fc9e109e2aeb65f030801b70c2f6468faca261d401ae622fc87 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.authz new file mode 100644 index 0000000..a62589f --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=c5410059b04c1ee005303aed430f6e6645f61f4dc9e1461ec8f8916fdf18852c \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.creq new file mode 100644 index 0000000..ebe943e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.creq @@ -0,0 +1,9 @@ +POST +/ + +host:example.amazonaws.com +my-header1:value1 +x-amz-date:20150830T123600Z + +host;my-header1;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.req new file mode 100644 index 0000000..0253f19 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.req @@ -0,0 +1,4 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value1 +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.sreq new file mode 100644 index 0000000..b4b78a1 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.sreq @@ -0,0 +1,5 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:value1 +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=c5410059b04c1ee005303aed430f6e6645f61f4dc9e1461ec8f8916fdf18852c \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.sts new file mode 100644 index 0000000..eb66362 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-key-sort/post-header-key-sort.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +9368318c2967cf6de74404b30c65a91e8f6253e0a8659d6d5319f1a812f87d65 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.authz new file mode 100644 index 0000000..d9e52a3 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=cdbc9802e29d2942e5e10b5bccfdd67c5f22c7c4e8ae67b53629efa58b974b7d \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.creq new file mode 100644 index 0000000..af824c8 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.creq @@ -0,0 +1,9 @@ +POST +/ + +host:example.amazonaws.com +my-header1:VALUE1 +x-amz-date:20150830T123600Z + +host;my-header1;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.req new file mode 100644 index 0000000..3f9987a --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.req @@ -0,0 +1,4 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:VALUE1 +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.sreq new file mode 100644 index 0000000..99c3210 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.sreq @@ -0,0 +1,5 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +My-Header1:VALUE1 +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;my-header1;x-amz-date, Signature=cdbc9802e29d2942e5e10b5bccfdd67c5f22c7c4e8ae67b53629efa58b974b7d \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.sts new file mode 100644 index 0000000..40062c7 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-header-value-case/post-header-value-case.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +d51ced243e649e3de6ef63afbbdcbca03131a21a7103a1583706a64618606a93 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.authz new file mode 100644 index 0000000..89e572e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.creq new file mode 100644 index 0000000..5c3a943 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.creq @@ -0,0 +1,8 @@ +POST +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.req new file mode 100644 index 0000000..3dc4179 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.req @@ -0,0 +1,3 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.sreq new file mode 100644 index 0000000..291ed07 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.sreq @@ -0,0 +1,5 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +X-Amz-Security-Token:AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA== +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.sts new file mode 100644 index 0000000..a636703 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-after/post-sts-header-after.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +553f88c9e4d10fc9e109e2aeb65f030801b70c2f6468faca261d401ae622fc87 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.authz new file mode 100644 index 0000000..64aa046 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=85d96828115b5dc0cfc3bd16ad9e210dd772bbebba041836c64533a82be05ead \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.creq new file mode 100644 index 0000000..1d5a462 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.creq @@ -0,0 +1,9 @@ +POST +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z +x-amz-security-token:AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA== + +host;x-amz-date;x-amz-security-token +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.req new file mode 100644 index 0000000..9d91775 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.req @@ -0,0 +1,4 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +X-Amz-Security-Token:AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA== \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.sreq new file mode 100644 index 0000000..37b2f04 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.sreq @@ -0,0 +1,5 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +X-Amz-Security-Token:AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA== +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=85d96828115b5dc0cfc3bd16ad9e210dd772bbebba041836c64533a82be05ead \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.sts new file mode 100644 index 0000000..bc39ccf --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/post-sts-header-before/post-sts-header-before.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +c237e1b440d4c63c32ca95b5b99481081cb7b13c7e40434868e71567c1a882f6 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/readme.txt b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/readme.txt new file mode 100644 index 0000000..cc34282 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-sts-token/readme.txt @@ -0,0 +1,15 @@ +A note about using temporary security credentials: + +You can use temporary security credentials provided by the AWS Security Token Service (AWS STS) to sign a request. The process is the same as using long-term credentials but requires an additional HTTP header or query string parameter for the security token. The name of the header or query string parameter is X-Amz-Security-Token, and the value is the session token (the string that you received from AWS STS when you obtained temporary security credentials). + +When you add X-Amz-Security-Token, some services require that you include this parameter in the canonical (signed) request. For other services, you add this parameter at the end, after you calculate the signature. For details see the API reference documentation for that service. + +The test suite has 2 examples: + +post-sts-header-before - The X-Amz-Security-Token header is part of the canonical request. + +post-sts-header-after - The X-Amz-Security-Token header is added to the request after you calculate the signature. + +The test suite uses this example value for X-Amz-Security-Token: + +AQoDYXdzEPT//////////wEXAMPLEtc764bNrC9SAPBSM22wDOk4x4HIZ8j4FZTwdQWLWsKWHGBuFqwAeMicRXmxfpSPfIeoIYRqTflfKD8YUuwthAx7mSEI/qkPpKPi/kMcGdQrmGdeehM4IC1NtBmUpp2wUE8phUZampKsburEDy0KPkyQDYwT7WZ0wq5VSXDvp75YU9HFvlRd8Tx6q6fE8YQcHNVXAkiY9q6d+xo0rKwT38xVqr7ZD0u0iPPkUL64lIZbqBAz+scqKmlzm8FDrypNC9Yjc8fPOLn9FX9KSYvKTr4rvx3iSIlTJabIQwj2ICCR/oLxBA== \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.authz new file mode 100644 index 0000000..44280cd --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=28038455d6de14eafc1f9222cf5aa6f1a96197d7deb8263271d420d138af7f11 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.creq new file mode 100644 index 0000000..f5058d4 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.creq @@ -0,0 +1,8 @@ +POST +/ +Param1=value1 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.req new file mode 100644 index 0000000..9157bc7 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.req @@ -0,0 +1,3 @@ +POST /?Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.sreq new file mode 100644 index 0000000..82af150 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.sreq @@ -0,0 +1,4 @@ +POST /?Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=28038455d6de14eafc1f9222cf5aa6f1a96197d7deb8263271d420d138af7f11 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.sts new file mode 100644 index 0000000..ca7cc66 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-empty-query-value/post-vanilla-empty-query-value.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +9d659678c1756bb3113e2ce898845a0a79dbbc57b740555917687f1b3340fbbd \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.authz new file mode 100644 index 0000000..44280cd --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=28038455d6de14eafc1f9222cf5aa6f1a96197d7deb8263271d420d138af7f11 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.creq new file mode 100644 index 0000000..f5058d4 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.creq @@ -0,0 +1,8 @@ +POST +/ +Param1=value1 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.req new file mode 100644 index 0000000..9157bc7 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.req @@ -0,0 +1,3 @@ +POST /?Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.sreq new file mode 100644 index 0000000..82af150 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.sreq @@ -0,0 +1,4 @@ +POST /?Param1=value1 HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=28038455d6de14eafc1f9222cf5aa6f1a96197d7deb8263271d420d138af7f11 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.sts new file mode 100644 index 0000000..ca7cc66 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla-query/post-vanilla-query.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +9d659678c1756bb3113e2ce898845a0a79dbbc57b740555917687f1b3340fbbd \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.authz new file mode 100644 index 0000000..89e572e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.creq new file mode 100644 index 0000000..5c3a943 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.creq @@ -0,0 +1,8 @@ +POST +/ + +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +host;x-amz-date +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.req new file mode 100644 index 0000000..3dc4179 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.req @@ -0,0 +1,3 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.sreq new file mode 100644 index 0000000..a5ada0d --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.sreq @@ -0,0 +1,4 @@ +POST / HTTP/1.1 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.sts new file mode 100644 index 0000000..a636703 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-vanilla/post-vanilla.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +553f88c9e4d10fc9e109e2aeb65f030801b70c2f6468faca261d401ae622fc87 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.authz new file mode 100644 index 0000000..531b89b --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=1a72ec8f64bd914b0e42e42607c7fbce7fb2c7465f63e3092b3b0d39fa77a6fe \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.creq new file mode 100644 index 0000000..8ec0d6c --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.creq @@ -0,0 +1,9 @@ +POST +/ + +content-type:application/x-www-form-urlencoded; charset=utf8 +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +content-type;host;x-amz-date +9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.req new file mode 100644 index 0000000..5ce537e --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.req @@ -0,0 +1,6 @@ +POST / HTTP/1.1 +Content-Type:application/x-www-form-urlencoded; charset=utf8 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z + +Param1=value1 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.sreq new file mode 100644 index 0000000..88beb82 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.sreq @@ -0,0 +1,7 @@ +POST / HTTP/1.1 +Content-Type:application/x-www-form-urlencoded; charset=utf8 +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=1a72ec8f64bd914b0e42e42607c7fbce7fb2c7465f63e3092b3b0d39fa77a6fe + +Param1=value1 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.sts new file mode 100644 index 0000000..3e83c52 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded-parameters/post-x-www-form-urlencoded-parameters.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +2e1cf7ed91881a30569e46552437e4156c823447bf1781b921b5d486c568dd1c \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.authz b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.authz new file mode 100644 index 0000000..d7baf53 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.authz @@ -0,0 +1 @@ +AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=ff11897932ad3f4e8b18135d722051e5ac45fc38421b1da7b9d196a0fe09473a \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.creq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.creq new file mode 100644 index 0000000..d7197f1 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.creq @@ -0,0 +1,9 @@ +POST +/ + +content-type:application/x-www-form-urlencoded +host:example.amazonaws.com +x-amz-date:20150830T123600Z + +content-type;host;x-amz-date +9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.req b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.req new file mode 100644 index 0000000..ada7f87 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.req @@ -0,0 +1,6 @@ +POST / HTTP/1.1 +Content-Type:application/x-www-form-urlencoded +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z + +Param1=value1 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.sreq b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.sreq new file mode 100644 index 0000000..9bac931 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.sreq @@ -0,0 +1,7 @@ +POST / HTTP/1.1 +Content-Type:application/x-www-form-urlencoded +Host:example.amazonaws.com +X-Amz-Date:20150830T123600Z +Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=ff11897932ad3f4e8b18135d722051e5ac45fc38421b1da7b9d196a0fe09473a + +Param1=value1 \ No newline at end of file diff --git a/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.sts b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.sts new file mode 100644 index 0000000..65ab663 --- /dev/null +++ b/pkg/sigv4/testdata/aws-sig-v4-test-suite/post-x-www-form-urlencoded/post-x-www-form-urlencoded.sts @@ -0,0 +1,4 @@ +AWS4-HMAC-SHA256 +20150830T123600Z +20150830/us-east-1/service/aws4_request +42a5e5bb34198acb3e84da4f085bb7927f2bc277ca766e6d19c73c2154021281 \ No newline at end of file diff --git a/pkg/store/accesskey/accesskey_test.go b/pkg/store/accesskey/accesskey_test.go index ed56587..46d06eb 100644 --- a/pkg/store/accesskey/accesskey_test.go +++ b/pkg/store/accesskey/accesskey_test.go @@ -5,11 +5,12 @@ import ( "testing" "time" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/accesskey" accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" accesskeypostgres "github.com/fil-forge/hilt/pkg/store/accesskey/postgres" + "github.com/fil-forge/libforge/testutil" "github.com/fil-forge/ucantone/did" "github.com/stretchr/testify/require" ) @@ -34,15 +35,15 @@ func makeStore(t *testing.T, k StoreKind) accesskey.Store { } func createPostgresStore(t *testing.T) accesskey.Store { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - pool := testutil.CreatePostgres(t) + pool := htestutil.CreatePostgres(t) return accesskeypostgres.New(pool) } diff --git a/pkg/store/bucket/bucket_test.go b/pkg/store/bucket/bucket_test.go index e8816a2..1019451 100644 --- a/pkg/store/bucket/bucket_test.go +++ b/pkg/store/bucket/bucket_test.go @@ -6,11 +6,12 @@ import ( "runtime" "testing" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/bucket" bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" bucketpostgres "github.com/fil-forge/hilt/pkg/store/bucket/postgres" + "github.com/fil-forge/libforge/testutil" "github.com/fil-forge/ucantone/did" "github.com/stretchr/testify/require" ) @@ -35,15 +36,15 @@ func makeStore(t *testing.T, k StoreKind) bucket.Store { } func createPostgresStore(t *testing.T) bucket.Store { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - pool := testutil.CreatePostgres(t) + pool := htestutil.CreatePostgres(t) return bucketpostgres.New(pool) } diff --git a/pkg/store/delegation/delegation.go b/pkg/store/delegation/delegation.go index 9b8fbf6..3b9d74b 100644 --- a/pkg/store/delegation/delegation.go +++ b/pkg/store/delegation/delegation.go @@ -12,6 +12,9 @@ import ( type Store interface { // DeleteByAudience removes all delegation records for a given audience. DeleteByAudience(ctx context.Context, audience did.DID) error + // DeleteBySubject removes all delegation records for a given subject. + // The undefined DID (powerline) is not removed by this method. + DeleteBySubject(ctx context.Context, subject did.DID) error // ListByAudience retrieves a paginated list of delegation records for a given // audience. ListByAudience(ctx context.Context, audience did.DID, opts ...store.PaginationOption) (store.Page[ucan.Delegation], error) diff --git a/pkg/store/delegation/delegation_test.go b/pkg/store/delegation/delegation_test.go index 1001c79..59b474e 100644 --- a/pkg/store/delegation/delegation_test.go +++ b/pkg/store/delegation/delegation_test.go @@ -5,11 +5,12 @@ import ( "runtime" "testing" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/store" dlgstore "github.com/fil-forge/hilt/pkg/store/delegation" delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" delegationpostgres "github.com/fil-forge/hilt/pkg/store/delegation/postgres" + "github.com/fil-forge/libforge/testutil" "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" @@ -37,15 +38,15 @@ func makeStore(t *testing.T, k StoreKind) dlgstore.Store { } func createPostgresStore(t *testing.T) dlgstore.Store { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - pool := testutil.CreatePostgres(t) + pool := htestutil.CreatePostgres(t) return delegationpostgres.New(pool) } @@ -112,6 +113,34 @@ func TestDelegationStore(t *testing.T) { require.Empty(t, page.Results) }) + t.Run("DeleteBySubject removes only that subject's delegations", func(t *testing.T) { + subjectA, subjectB := testutil.RandomDID(t), testutil.RandomDID(t) + audA1, audA2 := testutil.RandomDID(t), testutil.RandomDID(t) + audB, audPowerline := testutil.RandomDID(t), testutil.RandomDID(t) + cmd := command.MustParse("/test/run") + + dA1 := makeDelegation(t, testutil.RandomIssuer(t), audA1, subjectA, cmd) + dA2 := makeDelegation(t, testutil.RandomIssuer(t), audA2, subjectA, cmd) + dB := makeDelegation(t, testutil.RandomIssuer(t), audB, subjectB, cmd) + // Powerline delegation (undefined subject) must be preserved. + dPowerline := makeDelegation(t, testutil.RandomIssuer(t), audPowerline, did.DID{}, cmd) + require.NoError(t, s.PutBatch(t.Context(), []ucan.Delegation{dA1, dA2, dB, dPowerline})) + + require.NoError(t, s.DeleteBySubject(t.Context(), subjectA)) + + for _, aud := range []did.DID{audA1, audA2} { + page, err := s.ListByAudience(t.Context(), aud) + require.NoError(t, err) + require.Empty(t, page.Results, "subject A delegations should be deleted") + } + pageB, err := s.ListByAudience(t.Context(), audB) + require.NoError(t, err) + require.Len(t, pageB.Results, 1, "subject B delegation should remain") + pagePowerline, err := s.ListByAudience(t.Context(), audPowerline) + require.NoError(t, err) + require.Len(t, pagePowerline.Results, 1, "powerline delegation should remain") + }) + t.Run("ListByAudience paginates results", func(t *testing.T) { audience := testutil.RandomDID(t) for range 5 { diff --git a/pkg/store/delegation/memory/store.go b/pkg/store/delegation/memory/store.go index fdbb27b..1f5dc74 100644 --- a/pkg/store/delegation/memory/store.go +++ b/pkg/store/delegation/memory/store.go @@ -2,6 +2,7 @@ package memory import ( "context" + "errors" "iter" "slices" "strings" @@ -91,6 +92,29 @@ func (s *Store) DeleteByAudience(ctx context.Context, audience did.DID) error { return nil } +func (s *Store) DeleteBySubject(ctx context.Context, subject did.DID) error { + if !subject.Defined() { + return errors.New("cannot delete powerline delegations") + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + // The store indexes only by audience, so scan each audience's delegations and + // drop those whose subject matches, removing now-empty audience entries. + for aud, dlgs := range s.byAudience { + kept := slices.DeleteFunc(dlgs, func(d ucan.Delegation) bool { + return d.Subject() == subject + }) + if len(kept) == 0 { + delete(s.byAudience, aud) + } else { + s.byAudience[aud] = kept + } + } + return nil +} + func (s *Store) ProofChain(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) ([]ucan.Delegation, []cid.Cid, error) { matcher := ucanlib.NewDelegationMatcher(s.listExact) return ucanlib.ProofChain(ctx, matcher, aud, cmd, sub) diff --git a/pkg/store/delegation/postgres/store.go b/pkg/store/delegation/postgres/store.go index d7e8781..ca61473 100644 --- a/pkg/store/delegation/postgres/store.go +++ b/pkg/store/delegation/postgres/store.go @@ -134,6 +134,16 @@ func (s *Store) DeleteByAudience(ctx context.Context, audience did.DID) error { return nil } +func (s *Store) DeleteBySubject(ctx context.Context, subject did.DID) error { + if !subject.Defined() { + return errors.New("cannot delete powerline delegations") + } + if _, err := s.pool.Exec(ctx, `DELETE FROM delegation WHERE subject = $1`, subject.String()); err != nil { + return fmt.Errorf("deleting delegations by subject: %w", err) + } + return nil +} + // ProofChain builds the proof chain from aud toward sub for cmd in a single // recursive query. The walk follows edges audience -> issuer, matching the // fixed subject (or NULL powerline delegations) and requiring each delegation's diff --git a/pkg/store/provider/provider_test.go b/pkg/store/provider/provider_test.go index dc34bf7..3146b82 100644 --- a/pkg/store/provider/provider_test.go +++ b/pkg/store/provider/provider_test.go @@ -4,11 +4,12 @@ import ( "runtime" "testing" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/provider" providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory" providerpostgres "github.com/fil-forge/hilt/pkg/store/provider/postgres" + "github.com/fil-forge/libforge/testutil" "github.com/stretchr/testify/require" ) @@ -32,15 +33,15 @@ func makeStore(t *testing.T, k StoreKind) provider.Store { } func createPostgresStore(t *testing.T) provider.Store { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - pool := testutil.CreatePostgres(t) + pool := htestutil.CreatePostgres(t) return providerpostgres.New(pool) } diff --git a/pkg/store/tenant/tenant_test.go b/pkg/store/tenant/tenant_test.go index bbc7719..6abbbf0 100644 --- a/pkg/store/tenant/tenant_test.go +++ b/pkg/store/tenant/tenant_test.go @@ -4,11 +4,12 @@ import ( "runtime" "testing" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/tenant" tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory" tenantpostgres "github.com/fil-forge/hilt/pkg/store/tenant/postgres" + "github.com/fil-forge/libforge/testutil" "github.com/stretchr/testify/require" ) @@ -32,15 +33,15 @@ func makeStore(t *testing.T, k StoreKind) tenant.Store { } func createPostgresStore(t *testing.T) tenant.Store { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - pool := testutil.CreatePostgres(t) + pool := htestutil.CreatePostgres(t) return tenantpostgres.New(pool) } diff --git a/pkg/vault/hashicorp/approle_test.go b/pkg/vault/hashicorp/approle_test.go index 4c3328a..9835ed6 100644 --- a/pkg/vault/hashicorp/approle_test.go +++ b/pkg/vault/hashicorp/approle_test.go @@ -5,7 +5,7 @@ import ( "runtime" "testing" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/vault" vaulthashicorp "github.com/fil-forge/hilt/pkg/vault/hashicorp" vaultclient "github.com/hashicorp/vault-client-go" @@ -54,16 +54,16 @@ func setupAppRole(t *testing.T, address, rootToken string) (roleID, secretID str } func TestAppRoleLogin(t *testing.T) { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - address, rootToken := testutil.CreateVault(t) + address, rootToken := htestutil.CreateVault(t) roleID, secretID := setupAppRole(t, address, rootToken) t.Run("logs in and yields a usable token", func(t *testing.T) { diff --git a/pkg/vault/paths.go b/pkg/vault/paths.go new file mode 100644 index 0000000..19828d6 --- /dev/null +++ b/pkg/vault/paths.go @@ -0,0 +1,14 @@ +package vault + +import "github.com/fil-forge/ucantone/did" + +// TenantKeyPath is the vault key under which a tenant's private key is stored. +func TenantKeyPath(tenantID did.DID) string { + return "/tenant/" + tenantID.String() +} + +// 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-key/" + accessKeyID.String() +} diff --git a/pkg/vault/vault_test.go b/pkg/vault/vault_test.go index 33e10f5..324ba14 100644 --- a/pkg/vault/vault_test.go +++ b/pkg/vault/vault_test.go @@ -4,7 +4,7 @@ import ( "runtime" "testing" - "github.com/fil-forge/hilt/internal/testutil" + htestutil "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/vault" vaulthashicorp "github.com/fil-forge/hilt/pkg/vault/hashicorp" vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" @@ -32,15 +32,15 @@ func makeVault(t *testing.T, k VaultKind) vault.Vault { } func createHashicorpVault(t *testing.T) vault.Vault { - if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { - if !testutil.IsDockerAvailable(t) { + if htestutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !htestutil.IsDockerAvailable(t) { t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") } } - if !testutil.IsDockerAvailable(t) { + if !htestutil.IsDockerAvailable(t) { t.SkipNow() } - address, token := testutil.CreateVault(t) + address, token := htestutil.CreateVault(t) client, err := vaultclient.New(vaultclient.WithAddress(address)) require.NoError(t, err) require.NoError(t, client.SetToken(token))