Skip to content

S3 compatibility

Your deployment’s endpoint root (for example https://c1.permafrost.live) is an S3 endpoint. Clients and SDKs work as against any complete S3 implementation, except as listed here.

The reference for the routes beside the S3 surface (verify, custody, your account) is shared with each deployment’s team.

Two credentials work on every S3 call. Both reach your tenant.

Authorization: Bearer <your token or key>

A token from your issuer or an invite key (Connect your app); the Quickstart shows it with curl.

For an unmodified S3 SDK or tool, mint a pair with your bearer:

POST /me/s3-keys → 201 {"access_key_id": "PFAK…", "secret_access_key": "…"}
GET /me/s3-keys → your pairs and their status, never a secret
DELETE /me/s3-keys/<id> → revokes that pair

The secret is shown once. Up to five pairs are active per tenant. Revoked pairs, and all pairs of a revoked tenant, stop working at once.

Configure the SDK with any region and service s3. Path-style addressing (/<bucket>/<key>) works on every deployment. Virtual-hosted addressing (<bucket>.<host>/<key>) works where your deployment has it turned on; ask at onboarding. Bucket names with dots use path-style. The examples are path-style:

s3 = boto3.client("s3", endpoint_url="https://c1.permafrost.live", region_name="us-east-1",
aws_access_key_id="PFAK…", aws_secret_access_key="…",
config=Config(s3={"addressing_style": "path"}))
const s3 = new S3Client({ endpoint: "https://c1.permafrost.live", region: "us-east-1", forcePathStyle: true,
credentials: { accessKeyId: "PFAK…", secretAccessKey: "…" } });

AWS CLI: aws configure set default.s3.addressing_style path. Leave checksums, signing and multipart at the SDK’s defaults.

For virtual-hosted names, drop forcePathStyle in JavaScript and set addressing_style to "virtual" in boto3 and the AWS CLI. boto3 stays path-style on a custom endpoint unless told.

  • Pre-signed URLs work for GET and PUT, SigV4 only, up to seven days. Send any header the URL signed (such as x-amz-meta-permafrost-tier). boto3 needs Config(signature_version="s3v4"); the AWS SDK for JavaScript v3 already uses SigV4.
  • The body is bound to the signature. A PutObject or UploadPart signed with the payload’s sha256 gets 400 BadDigest if the body doesn’t match.
  • Refused with 401, one answer for all: SigV2 (headers or URLs), session tokens (no STS), a clock more than fifteen minutes off, an expired or altered URL.
  • For end-to-end integrity on streaming (aws-chunked) uploads, send x-amz-meta-permafrost-sha256.
Buckets Objects Multipart
CreateBucket PutObject CreateMultipartUpload
DeleteBucket (empty) GetObject (with Range) UploadPart
HeadBucket HeadObject CompleteMultipartUpload
ListBuckets (your own) DeleteObject, DeleteObjects AbortMultipartUpload
PutBucketPolicy (shared spaces) ListObjects, ListObjectsV2 ListMultipartUploads (with delimiter), ListParts
PutBucketEncryption, GetBucketEncryption, DeleteBucketEncryption (your key service only) CopyObject onto the same key only

A self-copy (same bucket and key, x-amz-metadata-directive: REPLACE) seals a held object, keeps a held one local, or moves a local one onto Walrus, single or multipart (Placement). aws s3api copy-object sends one CopyObject, changing the object in place. aws s3 cp and boto3’s managed copy() split a copy above 8 MiB into CreateMultipartUpload + UploadPartCopy + CompleteMultipartUpload, which also works but rewrites the object as multipart with a new ETag. Copying to a different key isn’t supported. ACL calls return a default and enforce nothing; access is by tenant. S3 Object Lock isn’t supported: a CreateBucket that asks for it (x-amz-bucket-object-lock-enabled: true) is refused with 400 InvalidRequest, and no bucket is created.

DELETE removes an object from your deployment. Data already on Walrus stays readable by blob id until its storage span ends; its receipt is unaffected. To remove it everywhere, use custody (the key is destroyed) or ask for deletion at the end of a contract.

All optional. Without them, an object is plain S3 with encryption at rest.

Header On Meaning
x-amz-meta-permafrost-tier PutObject, CreateMultipartUpload, self-copy LOCAL keeps the object on the deployment’s disk. HOT and ARCHIVE store it on Walrus; only HOT keeps a local copy for reads. Absent, the bucket’s default applies, multipart included. For a receipt with no stored copy: write LOCAL with a receipt, then DELETE (the receipt stays on chain).
x-amz-meta-permafrost-hold PutObject, CreateMultipartUpload Seconds to hold before sealing; replaceable or deletable while held. Up to the deployment’s bound. Refused with LOCAL.
x-amz-meta-permafrost-receipt PutObject, CreateMultipartUpload, self-copy none skips the receipt on Sui. On LOCAL, receipts are off unless you send default. A LOCAL object written without a receipt gets one when it moves to HOT or ARCHIVE only if that self-copy sends default.
x-amz-meta-permafrost-encrypt PutObject, CreateMultipartUpload none stores this one object as plaintext (on a multipart upload: every part). Ignored on UploadPart.
x-amz-meta-permafrost-epochs PutObject, CreateMultipartUpload How long to store on Walrus, instead of the deployment default.
x-amz-meta-permafrost-retention CreateBucket The bucket’s policy as compact JSON: default tier, renewal, default span, what a lapsed hold becomes, the archive backend (archive_backend, one declared for the deployment), and (spaces only) a maximum age. Fixed at creation.
x-amz-meta-permafrost-space CreateBucket 1 makes the bucket a shared space (Shared spaces).
x-amz-meta-permafrost-agent-space CreateBucket {"doom_seconds":N,"referee":"0x…"} makes the bucket an agent space. Both keys are required.
x-amz-meta-permafrost-visibility PutObject in an agent space public or private. Required there, refused anywhere else.
x-amz-meta-permafrost-fingerprint-kind PutObject, CreateMultipartUpload The receipt’s fingerprint: plaintext, salted or stored (Salted fingerprints). Defaults to salted when the stored copy is encrypted (deployment keys or your key service), plaintext with x-amz-meta-permafrost-encrypt: none and no key service.
x-amz-meta-permafrost-sha256 PutObject, UploadPart The sha256 of this request’s body (64 hex). A mismatch gets 400 BadDigest; nothing is stored.

HEAD and GET report:

  • x-amz-meta-permafrost-sync-state (PENDING, HELD, LOCAL, SYNCED, …).
  • x-amz-meta-permafrost-state (LOCAL, SEALED, ARCHIVED); and, only when something is scheduled, x-amz-meta-permafrost-next (seal, keep, delete, release) and x-amz-meta-permafrost-next-at (Unix time in milliseconds) (Placement).
  • Once on Walrus: x-amz-meta-permafrost-blob-id (a multipart object’s is its manifest’s), x-amz-meta-permafrost-kept-until and x-amz-meta-permafrost-manifest-nft-id (its receipt on Sui).
  • Always: x-amz-meta-permafrost-fingerprint-kind and x-amz-meta-permafrost-fingerprint (the value its receipt records).

An unrecognised value is refused with 400 InvalidArgument, naming the header, before any bytes are stored.

Status Means
400 BadDigest The body didn’t match x-amz-meta-permafrost-sha256. Nothing was stored; send it again.
400 EntityTooLarge Over the deployment’s maximum object size (or a multipart upload’s parts would be), or a single PUT or one part to be sealed to Walrus is over the per-PUT limit: use multipart or smaller parts, or LOCAL. The message names the sizes.
400 InvalidRequest A CreateBucket asking for S3 Object Lock, which isn’t supported. Nothing was created.
400 EntityTooSmall At CompleteMultipartUpload: a part other than the last is under 5 MiB. Resend that part larger, or abort.
401 Any credential failure, bearer or access key. Same answer every time, no reason given.
403 A cross-tenant operation, or a revoked tenant.
403 AccessDenied A write with a read-only key, or a read-only key asking for an S3 access-key pair. Use a read-write credential.
403 QuotaExceeded Your tenant’s daily write budget would be exceeded. The body gives Limit, Used, Requested and ResetsAt (the next UTC midnight). Retrying sooner doesn’t help.
403 ReadQuotaExceeded Your tenant’s daily read cap is reached; object GETs are refused until the next UTC midnight. The body is S3’s error XML with Limit, Used and ResetsAt, whatever the credential. Retrying sooner doesn’t help.
404 Not found or not yours, indistinguishably, so existence never leaks.
409 The key is still being stored; retry once HEAD reads SYNCED.
410 A custody object whose key was destroyed.
411 MissingContentLength Your tenant has a byte limit and the write declared no length (no Content-Length, nor x-amz-decoded-content-length on an aws-chunked upload, which a default AWS SDK upload sends). Send the length.
413 EntityTooLarge Over your tenant’s maximum object size: for multipart, the part that takes the upload over it, or a CompleteMultipartUpload whose parts add up to more. The body gives ProposedSize and MaxSizeAllowed.
413 A custody store larger than your tenant’s size ceiling.
429 SlowDown Over your tenant’s request rate, or a new account’s first request while many accounts are being created at once. A short burst is held briefly and served at your rate instead; past that, AWS SDKs and the CLI back off on their own, and other clients honour Retry-After (seconds) or RetryAfterMs in the body.
  • Rate. Each tenant has its own requests-per-second ceiling, shown on your deployment page.
  • Bytes written. A deployment can set your tenant a maximum object size and a daily write budget (per UTC day). Both count each PutObject or form upload body and UploadPart sent, even one that fails after acceptance. With a limit set, every write must declare its length. GET /me reports max_object_bytes, daily_bytes (null = no limit) and written_today_bytes.
  • Reads per day. A deployment can give your tenant a daily read cap on object GETs per UTC day (a range read is one; HEAD, listings and writes don’t count). Every accepted GET counts, a 404 included, so the cap can run ahead of billed reads. Past it: 403 ReadQuotaExceeded until UTC midnight. GET /me reports limits.daily_reads (null = no cap) and today.reads_admitted; your deployment page shows “N of CAP reads today”. Limits for your own users are yours to set in your application.
  • Size. Custody stores are checked against a per-tenant size ceiling before the body is read.
  • Object size, per deployment. Each deployment sets its largest object and a limit on one PUT to be sealed to Walrus, which also caps each part of a multipart upload (400 EntityTooLarge: use multipart, or smaller parts; LOCAL is exempt). Your tenant’s own limits apply first.
  • Multipart. Every part except the last is at least 5 MiB (EntityTooSmall otherwise), and none is over the per-PUT limit above. UploadPart writes nothing to Walrus; the upload is sealed after it completes, per its tier, with consecutive parts stored together up to that limit, so the part size your SDK picks barely changes what is stored. A part resent under the same number replaces the earlier one and counts once against your byte limits.
  • Hold. Up to the deployment’s bound.

A tenant is a Sui address: your issuer token’s sub, or the address an invite was redeemed with. A bucket belongs to the tenant that created it, each object to the tenant that wrote it. Other tenants are refused on read, list, write and delete, and every refusal is logged.

Bucket names are visible across tenants of one deployment. Contents aren’t. Treat a bucket name as public.

Objects are encrypted at rest, per tenant and server-side, by default, multipart parts included; x-amz-meta-permafrost-encrypt: none opts one out (on CreateMultipartUpload for a multipart upload).

To keep the deployment from reading your data, use your own key service (set up per dedicated deployment) or encrypt before upload; then the deployment never holds a key, and you keep your receipts. See Encryption and key control.

A bucket default is accepted only under your own key service (aws:kms with your key id); any other is refused with a 400 giving the reason. Customer-provided keys per request (SSE-C) aren’t supported.

Each custody object has its own key, which makes it individually deletable (Custody and deletion).

Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.