Skip to main content

Security model

What you'll learn: who is allowed to do what, and how OpenBucket proves it on every request. There are two planes — the S3 data plane (SigV4) and the admin plane (JWT) — and they never share credentials.

The two planes at a glance

S3 clients ──► SigV4Guard ──► PolicyAuthorizationGuard ──► S3 handler
(AWS SDK) verify sig bucket policy AND key scope

Admin console ─► JwtAuthGuard ──► RolesGuard ──────────────► /api/admin/* handler
(browser) verify JWT full-admin vs read-only
  • Data plane — any S3 client authenticates with AWS Signature V4 using an access-key pair. Requests are then authorized by bucket policy and the key's scope.
  • Admin plane — the console and CLI authenticate with a short-lived JWT minted from an argon2id password check, and are authorized by an admin role.

SigV4 for the S3 API

Every S3 request is verified by SigV4Guard before any handler runs. It supports both signing forms AWS SDKs use:

  • Header-signed requests (Authorization: AWS4-HMAC-SHA256 …).
  • Presigned URLs (the X-Amz-Signature query parameter).

The guard:

  • Reverse-computes the signature from the resolved secret and compares it in constant time (crypto.timingSafeEqual) — a mismatch is a generic SignatureDoesNotMatch, so a wrong secret and a wrong key look identical.
  • Enforces a ±15-minute clock skew on X-Amz-Date (RequestTimeTooSkewed beyond that), the AWS default.
  • Requires host (and any wire-present x-amz-* header) to be signed, so those headers can't be left unbound by a crafted SignedHeaders list.
  • Accepts the streaming-upload forms STREAMING-AWS4-HMAC-SHA256-PAYLOAD (signed chunks) and STREAMING-UNSIGNED-PAYLOAD-TRAILER; the signed trailing-checksum form (STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER) is rejected with a clear InvalidArgument.
Path-style only

OpenBucket addresses buckets path-style (host/bucket/key), never virtual-host style. Configure your SDK with forcePathStyle: true and a region that matches OPENBUCKET_REGION (default us-east-1).

The root key vs scoped sub-keys

The root credential (ROOT_ACCESS_KEY_ID / ROOT_SECRET_ACCESS_KEY) is loaded from the environment at boot, cached in memory, never persisted, and is always unrestricted.

On top of it you mint scoped sub-keys — full SigV4 access keys confined to a bucket + key-prefix (or an inline policy) — via POST /api/admin/keys:

const { data } = await keysApi.createKey({
label: 'tenant-a-uploader',
scope: { kind: 'prefix', bucket: 'tenants', prefix: 'tenant-a/' },
});
// data.accessKeyId / data.secretAccessKey — returned ONCE; hand them to the tenant.

A key minted with a scope records role: 'scoped'; without one it records role: 'root' (unscoped, root-equivalent).

How a request gets authorized

After SigV4 proves who you are, PolicyAuthorizationGuard decides what you may do. The scope you gave a sub-key is compiled once, at mint time, into the same IAM-style PolicyDocument the bucket-policy evaluator already understands. The effective decision is bucket policy AND scope:

  • The root key is evaluated with defaultAllow: true — a bucket policy without a matching statement leaves root's access intact; only an explicit Deny blocks it.
  • A scoped key is evaluated with implicit deny (defaultAllow: false) — an action or resource the scope doesn't Allow is denied even when the bucket has no policy.
  • An explicit bucket-policy Deny always wins (checked first, never masked).
  • A prefix scope grants s3:ListBucket only under a StringLike s3:prefix condition, so a tenant key can't enumerate the whole bucket with an unprefixed ListObjectsV2.
  • A scoped key calling a service-level op (ListBuckets) is denied unless its scope explicitly allows s3:ListAllMyBuckets.

The evaluator understands Action/Resource/Principal globs plus two advertised conditions — Bool aws:SecureTransport and IpAddress/NotIpAddress aws:SourceIp — and fails closed: an unknown condition operator satisfies a Deny but never grants an Allow.

Inspect before you ship

Before handing a scoped key to a tenant, call getKeyEffectivePermissions(id) for an allow/deny matrix, or simulateKeyAction(id, { action, resource }) for a single decision — both run through the same evaluator the live request path uses, so the console and production agree.

Admin auth: JWT over argon2id

The admin plane is protected by a global JwtAuthGuard that authenticates every /api/admin/* request (the S3 and SPA trees pass through untouched).

  • Passwords are verified with argon2id. On a username miss, OpenBucket runs a constant-time verify against a dummy hash so login timing never reveals whether a username exists.
  • Login mints a 15-minute access JWT plus a rotating refresh token (7-day default). Both TTLs are configurable (JWT_ACCESS_TTL_SECONDS, JWT_REFRESH_TTL_SECONDS).
  • JWTs are verified with a fixed issuer/audience; the signing key is JWT_SECRET (≥ 32 chars, checked for low entropy and known placeholders at boot).

Roles are read fresh every request

Admins are full admin (admin) or read-only (readonly). A global RolesGuard is default-deny by HTTP method: it 403s any POST/PUT/PATCH/DELETE under /api/admin/* for a read-only principal (except a couple of self-service routes and handlers marked @AllowReadOnly()).

The role is read fresh from the database on every request, not trusted from the JWT claim — so demoting an admin takes effect immediately, even while an old token still verifies. Two anti-lockout invariants always hold: you can't delete or demote the last full admin, and you can't delete your own account.

Secret handling

OpenBucket treats secrets as radioactive:

  • Scoped sub-key secrets are encrypted at rest. SigV4 needs the plaintext to verify a signature, so a sub-key's secret is stored AES-256-GCM encrypted (SecretCipher), never plaintext, and decrypted only on the hot path. The GCM auth tag rejects tampering; a tampered blob fails closed (the key is treated as unknown).
  • The root secret and the SSE key are never persisted as sub-key material — root lives only in memory from the environment.
  • Logs are redacted. The pino logger censors password, secret, secretAccessKey, token, authorization, KEY_ENCRYPTION_SECRET, and friends; SigV4 query-auth parameters (X-Amz-Signature/-Credential/ -Security-Token) are stripped from logged URLs so a presigned request never logs a replayable signature. Access-key ids are truncated (AKIA…-xy) when logged.
  • The audit log strips secret-looking fields before persisting an event, and drops an oversized detail payload — defense in depth on top of a catalogue that never carries secrets.

The reversible key-secret encryption

The 32-byte key-encryption key (KEK) that wraps sub-key secrets is HKDF-SHA256 derived from KEY_ENCRYPTION_SECRET when set, otherwise from ROOT_SECRET_ACCESS_KEY.

warning
Set KEY_ENCRYPTION_SECRET up front

If you rotate ROOT_SECRET_ACCESS_KEY without having set a dedicated KEY_ENCRYPTION_SECRET, the KEK changes and every existing sub-key secret becomes undecryptable — those keys must be re-minted. Set a strong KEY_ENCRYPTION_SECRET (≥ 32 chars) from day one to decouple sub-key storage from the root credential.

At-rest object encryption (SSE-S3)

Objects are optionally encrypted at rest with a single, backend-managed 32-byte key (the SSE-S3 model) using AES-256-CTR — one key for the whole instance, no per-object derivation, no in-place rotation in v1. Losing the key (<DATA_DIR>/sse.key or OPENBUCKET_SSE_KEY) makes every encrypted object unreadable, so back it up with your other break-glass secrets. See Durability and Storage layout for how the ciphertext and its IV are stored and integrity-checked.

Next steps