@shelby-protocol/s3-gateway
v1.12.0
Published
S3-compatible gateway for Shelby Protocol blob storage
Maintainers
Readme
Shelby S3 Gateway
An S3-compatible API gateway for Shelby Protocol blob storage.
Use familiar S3 tools and libraries (AWS SDK, rclone, Cyberduck, DuckDB, etc.) to access blobs stored on Shelby.
Quick Start
- Create a
shelby.config.yamlfile:
network:
name: shelbynet
rpcEndpoint: https://api.shelbynet.shelby.xyz/shelby
aptosFullnode: https://api.shelbynet.shelby.xyz/v1
aptosIndexer: https://api.shelbynet.shelby.xyz/v1/graphql
locationHint: shelbynet-1
apiKey: your-api-key
server:
port: 9000
credentials:
- accessKeyId: AKIAIOSFODNN7EXAMPLE
secretAccessKey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
buckets:
- name: clickhouse-data
accountAddress: "0x0694a79e492d268acf0c6c0b01f42654ac050071a343ebc4226cb6717d63e4ea"network.locationHint selects the Shelby location used for blob writes. It
defaults to the SDK's location for the selected network (shelbynet-1 on
Shelbynet and localnet-1 locally). server.region is only the S3 region used
for SigV4 compatibility and does not control where Shelby stores blobs. The
gateway does not read the CLI's ~/.shelby/config.yaml; when it runs in Docker,
use the gateway's mounted shelby.config.yaml instead.
- Start the gateway:
npx @shelby-protocol/s3-gateway --config shelby.config.yaml- Connect with any S3 client:
# Using rclone
rclone config create shelby s3 \
provider=Other \
access_key_id=AKIAIOSFODNN7EXAMPLE \
secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
endpoint=http://localhost:9000 \
force_path_style=true
rclone ls shelby:clickhouse-dataBucket aliases must follow the Amazon S3 general-purpose bucket naming rules. For backward compatibility, a quoted account address can still be used directly:
buckets:
- "0x0694a79e492d268acf0c6c0b01f42654ac050071a343ebc4226cb6717d63e4ea"If server-side encrypted objects were previously written through an
address-style bucket, accountAddress must preserve the exact address spelling
used in that bucket path. The encryption identity is byte-sensitive.
Replicated deployments such as Geomi should implement the exported
BucketResolver interface against their shared control-plane database and
inject it as bucketResolver. A gateway-local database is suitable only for a
single self-hosted instance: aliases must resolve consistently across replicas,
restarts, and deployments.
CLI Options
npx @shelby-protocol/s3-gateway --help
Options:
-c, --config <path> Path to config file
-p, --port <port> Port to listen on (overrides config)
-h, --help Show help messageManaged Encryption With ACE
Encrypted reads and writes recover a bucket key-encryption key (KEK) from ACE. The gateway encrypts bytes locally using a fresh data-encryption key (DEK) per blob, including each multipart upload part. That DEK is wrapped with the bucket KEK and stored in the encrypted container; reusing a KEK does not reuse a DEK.
The package exposes the runtime adapter under
@shelby-protocol/s3-gateway/ace. The companion Move policy lives in this
repository at move/s3_gateway_ace_policy. For each network bundle, deploy and
initialize that policy with at least one S3 gateway Ed25519 public key, then
configure the gateway with:
- ACE deployment chain id, endpoint, contract address, and IBE keypair id.
- Policy contract chain id and module address. This is the policy admin account that deploys the Move module and manages allowed gateway public keys.
- Gateway signing account private key, held by each S3 gateway server process only. Its Ed25519 public key must be registered in the policy by the admin.
YAML local-server example:
encryption:
enabled: true
bucketDefaults:
"0xYOUR_SHELBY_ACCOUNT": AES256
ace:
aceChainId: 118
apiEndpoint: "https://api.shelbynet.shelby.xyz/v1"
aceContractAddress: "0x2a800d06b231476e045e874b5319409f80aa4449d7cabcdc68d2e0b5a66ee43d"
ibeKeypairId: "0xa36e6db16b015c6c2c9a376afe3075b11031ee0df393c226e7d599f615759a17"
policyChainId: 118
policyModuleAddress: "0xYOUR_POLICY_ADMIN_ACCOUNT"
policyModuleName: "s3_gateway_ace_policy"
accountFile: "./gateway-signing-account.json"The gateway runtime config contains both identities needed by ACE:
- ACE deployment identity:
aceChainId,aceContractAddress, andibeKeypairId.apiEndpointtells the SDK where to fetch that deployment's public key, but it is not persisted in Shelby blob metadata. - Policy identity:
policyChainId,policyModuleAddress, andpolicyModuleName. The gateway passes this toIBE_Aptos.fetchIdentityKeySharesCustomFlowwhen recovering the bucket KEK on either path.
An optional clientConfig block is forwarded verbatim to the Aptos client ACE
reads the chain with, using that SDK's key names. Runtimes that cannot use the
defaults need it — on Bun, HTTP/2 leaves every ACE read hanging until it times
out, so it has to be turned off:
ace:
# ...
clientConfig:
http2: falseUnknown keys here are dropped rather than rejected, so check the spelling: a
misspelled http2 reads as a hang, not as a config error.
Use the same ACE deployment and bucket identity for reads and writes: recovering a different KEK cannot unwrap an existing object's DEK.
The account file is local-only and should not be committed:
{
"accountAddress": "0xYOUR_GATEWAY_SIGNING_ACCOUNT",
"privateKey": "ed25519-priv-0x..."
}The accountAddress must be the actual gateway signing account address. Do not
derive it from the private key at startup; Aptos account auth keys can be
rotated, so the account address may differ from the address derived from the
current signing key.
The policy uses ACE custom flow and verifies a gateway payload shaped as
public_key || signature, where public_key must already be registered in the
policy and signature covers the framework chain id, policy module address,
policy module name, ACE label, and caller encryption public key. Registered
gateway public keys can be added or removed without scanning the key set. SigV4
authorization remains in the S3/API gateway layer; it is not re-verified by ACE.
Optional bucket KEK cache
Caching is disabled by default. To opt in for the standard gateway signer:
encryption:
# ...existing encryption settings...
ace:
# ...existing ACE deployment and signing settings...
kekCache:
ttlMs: 60000
maxEntries: 1024The same setting is available to createAceKekProvider and
createAceKekProviderFromEnvironment. backend: memory makes the local backend
explicit; omitting backend preserves existing behavior. maxEntries defaults
to 1024 and applies only to memory. Custom decryptionSigner callbacks cannot
be cached because they may perform request-specific authorization.
To share KEKs across replicas, use a dedicated Redis 7+ instance:
encryption:
ace:
# ...existing ACE deployment and signing settings...
kekCache:
backend: redis
ttlMs: 60000
redis:
namespace: shelby-beta-ams2
urlEnv: KEK_CACHE_REDIS_URL
encryptionKeyEnv: KEK_CACHE_ENCRYPTION_KEY
caFile: /etc/kek-cache/ca.crt # optional for a publicly trusted CA
timeoutMs: 200
maxInFlight: 128KEK_CACHE_REDIS_URL contains a rediss:// URL with Redis credentials.
KEK_CACHE_ENCRYPTION_KEY contains a dedicated 32-byte key encoded as 64 hex
characters, shared by these replicas. Generate it with openssl rand -hex 32
and distribute it through a secret manager. Do not reuse an ACE signing key or
managed-account encryption key. redis:// is supported for local tests; use TLS
and network access controls in production. The CA option never disables
certificate verification. Missing or invalid configuration fails startup.
Redis stores AES-256-GCM ciphertext, authenticated against the complete cache identity and a fixed expiry. Network access controls restrict who can connect; wrapping also keeps plaintext KEKs out of Redis memory and binds cached values to their identity. It does not protect a compromised gateway that holds the wrapping key. The scope includes the network/cluster namespace, ACE chain, contract, IBE keypair, policy, gateway account and signing public key, TTL and wrapping-key fingerprint. Changing any of these creates a cold cache. The account/bucket label separates keys within that scope. Wrapping-key rotation does not change stored objects or their KEKs; roll it across replicas to stop sharing with the old scope. Object encryption still creates a fresh DEK per blob.
Each seal uses a random 96-bit IV. Rotate the wrapping key well before 2^32
seal invocations across all replicas and scopes using that key, following
NIST SP 800-38D, section 8.3.
Count seals even if publication fails or another fill wins. Cache TTL, live entry
count, namespace changes and restarts do not reset this budget; only a fresh
wrapping key does. Before production enablement, establish an aggregate workload
bound and a security-reviewed rotation schedule with margin for bursts and rollout
overlap. Production volume has not been measured by this change, and the adapter
does not enforce a lifetime invocation limit. The miss event count is a
conservative upper bound on seals when collected completely (each miss seals at
most once); monitoring must account for all replicas, counter resets and gaps.
Redis uses its own clock for expiry. Hits never extend the deadline, which starts before ACE recovery. Expired or malformed entries are misses; ACE errors never permit serving stale keys. A configured TTL allows reuse after ACE gateway-key revocation for up to that TTL. SigV4 and tenant read/write authorization must still run before every key lookup. Do not enable caching for policies requiring a fresh ACE decision on every request.
Concurrent misses recover from ACE independently, with no distributed lock or
polling. The first completed recovery fills the cache with SET ... NX; later
fills cannot overwrite that entry or renew its fixed expiry. Redis commands have
bounded timeouts and offline queuing is disabled. maxInFlight caps outstanding
ACE recoveries per provider, including concurrent requests for the same bucket
and fallbacks; exceeding it fails the request. There is no second local KEK cache
in Redis mode. The onKekCacheEvent runtime callback reports hit, miss,
fallback and invalid without account or key labels.
Library callers should await provider.clearCache() for fleet-wide invalidation
of that provider scope. It changes a random generation and fences older recoveries;
old entries expire naturally. Invalidation fails visibly if Redis is unavailable.
It does not cancel requests or reclaim keys already handed to callers. Restarting
one gateway only clears its local memory cache; it does not invalidate Redis.
Call await provider.close() at shutdown to release the Redis connection and wipe
locally owned wrapping-key bytes. JavaScript cannot guarantee erasure of all copies.
Use a standalone Redis primary (the adapter does not support Redis Cluster or
Sentinel discovery). Disable persistence, bound memory, and prefer volatile-lru:
values expire, while generation keys intentionally have no TTL and are
not evicted by that policy. There is one generation key per provider scope, not
per bucket. Clearing a scope replaces its generation value without creating
another generation key. Scope changes (including key or TTL changes) leave the
old generation key behind. Once all replicas using an old scope have stopped and
its maximum TTL has elapsed, an operator may delete that exact retired scope's
generation key. Do not delete active scopes or use FLUSHDB for cleanup.
Restrict its ACL to
~shelby:kek:v1:<namespace>:* and +@connection +get +set +del +eval +time.
A Redis restart gives a cold cache and ACE fallback. Failover can restore a prior
generation, so the TTL remains the revocation bound even after explicit clearing.
Run the cross-client integration tests against an isolated Redis:
TEST_KEK_REDIS_URL=redis://localhost:6379 pnpm exec vitest run src/aceCI runs these tests over TLS with the restricted cache ACL above. Set
TEST_KEK_REDIS_CA_FILE to a private CA file and TEST_KEK_REDIS_ADMIN_URL to a
separate test-only administrative URL when reproducing that setup; inspection and
tampering tests need permissions the gateway does not. The suite also verifies
certificate rejection, ACL isolation and overlapping wrapping-key rotations.
