Skip to content

proven on the hosted testnet instance · 2026-09-13 · QA-14

The S3 surface

The root of https://testnet.permafrost.live is an S3 endpoint. Point an S3 client at it, hand it a bearer credential, and the operations below work as they do anywhere else. This page is the list of what is there, what is not, and what every object read carries back.

Authentication is a bearer, and only a bearer

Section titled “Authentication is a bearer, and only a bearer”
Authorization: Bearer <your key or your token>

The edge resolves that header, refuses the request if it cannot, and forwards it with the calling tenant attached. Two consequences a reader coming from AWS should know before writing any code:

  • A SigV4-signed request is refused. Not because the signature is wrong — because the header is not a bearer, so the edge never gets as far as a signature. The refusal is the same 401 as every other credential failure.
  • Pre-signed URLs do not work through this endpoint. A pre-signed URL carries its signature in the query string and no Authorization header at all, which is exactly the case the edge rejects.

SigV4 is the storage layer’s own scheme on its own port. That port is not reachable from outside this instance, and the edge strips Authorization before the request reaches it, so a client’s credential never arrives there in any form.

Most S3 SDKs need a small amount of persuading to send a bearer instead of signing. The Quickstart shows the shape.

Operation Notes
CreateBucket The creating tenant owns the bucket from that moment. No region argument.
DeleteBucket The bucket must be empty.
HeadBucket Answers for any existing bucket name — see Tenancy.
ListBuckets Your own buckets only.

Three calls answer rather than refuse, with a fixed default and nothing behind it: GetBucketLocation returns the default region, and GetBucketAcl and GetObjectAcl return a default ACL. Nothing is enforced from any of them. Access is decided by tenant, not by ACL.

Operation Notes
PutObject The core write. Single-object PUTs are encrypted at rest — see Encryption at rest.
GetObject Supports Range, answering 206 with Content-Range.
HeadObject Metadata only, including the certification headers below.
DeleteObject Marks the object deleted; a subsequent read answers 404.
DeleteObjects Bulk delete.
ListObjects V1 listing.
ListObjectsV2 V2 listing, with continuation tokens.
Operation Notes
CreateMultipartUpload Validates the encryption-policy header, but see the caveat below.
UploadPart Part bytes are not encrypted by this instance.
CompleteMultipartUpload Writes the upload’s manifest.
AbortMultipartUpload
ListMultipartUploads
ListParts

A completed multipart object’s bytes resolve through its manifest and its part rows, not through one blob. That is why a multipart object carries neither of the certification headers below, and why x-amz-meta-permafrost-encrypt is accepted at initiate and then does nothing. A team that needs its large media encrypted at rest encrypts the bytes before upload. Worked end to end in Upload media in parts.

Header Behaviour
Content-Type Stored, and echoed back verbatim on read.
Content-Length Required on PUT.
Content-MD5 Validated when supplied; a mismatch is 400 InvalidDigest.
Range Byte ranges on GET.
x-amz-meta-* Stored as user metadata and returned on read.
x-amz-meta-permafrost-encrypt The per-object encryption policy — Encryption at rest.

Cache-Control, Content-Disposition and Content-Encoding are stored on the object but not acted on. A stored Content-Disposition in particular is overwritten on the way out, for the reason in Every stored-byte response below.

Each of these refuses or is absent. None of them is a thing to plan around later — they are the shape of the surface today.

Not there What to do instead
CopyObject Read the object and write it back under the new key.
GetObjectTagging, PutObjectTagging Carry your own labels in x-amz-meta-*.
Versioning — GetBucketVersioning, PutBucketVersioning, x-amz-version-id, delete markers Overwriting replaces the object. Version lineage lives on chain instead: see On-chain objects.
ACL writes — PutBucketAcl, PutObjectAcl Access is by tenant. Tenancy.
Bucket policies, CORS configuration
Lifecycle rules Storage on Walrus is leased; see Leases and renewal.
Storage classes — x-amz-storage-class One tier.
Conditional requests — If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since
Pre-signed URLs Hand your service the bearer, or proxy the read.
Replication, event notifications, object lock, inventory reports
AWS Signature V2
Posture What is served Where
Echo the stored type, verbatim the S3 surface — GET and HEAD on an object
Opaque application/octet-stream, with the true stored type on X-Permafrost-Content-Type routes a browser never decodes as a media element

The echo posture is contract, not an oversight. A <video src> or <img src> picks its decoder from the served type, and because nosniff is set the browser may not fall back to sniffing — so declaring everything opaque would break playback outright. Where a URL is only ever read by a program, the attacker-chosen type is simply not echoed.

Object bytes are served from an origin an application also uses, so a stored text/html or image/svg+xml would otherwise execute as a page on that origin. Five headers are set on every response that returns stored bytes, overwriting anything stored under the same name:

Content-Disposition: attachment
Content-Security-Policy: default-src 'none'; sandbox
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer

They ride along on 206 partial responses too, and on HEAD. What changes for a caller: a direct top-level navigation to an object URL downloads the object instead of rendering it. Subresource loads — <img>, <video>, <audio>, fetch() — ignore a disposition, so an embed keeps working and so does a byte-range video seek.

An S3 refusal is XML in the ordinary S3 error shape, with a Code, a Message, the Resource and a request id.

Code Status When
AccessDenied 403 A cross-tenant operation, or a read whose key cannot be selected.
NoSuchBucket 404
NoSuchKey 404
BucketAlreadyExists 409
BucketNotEmpty 409 DeleteBucket on a bucket that still holds objects.
InvalidArgument 400 Including an unrecognised encryption-policy value.
InvalidBucketName 400
InvalidDigest 400 Content-MD5 did not match the body.
InvalidRange 416
MalformedXML 400
NotImplemented 501
InternalError 500
ServiceUnavailable 503 Walrus could not be reached.

The refusals that are not S3-shaped are the ones the edge makes before the request reaches the storage layer — the credential and rate refusals. Those are JSON, and they are on Headers, status codes, 401 and 429.

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