proven on the hosted testnet instance · 2026-09-13 · QA-14
Quickstart
What you will have at the end
Section titled “What you will have at the end”A bucket of your own. An object stored and read back byte-equal. A HEAD that
carries the object’s sync state and, once it is certified, its blob id. A
multipart object read back whole and by byte range. A custody object served
while its key lived, then incinerated to a certificate on Sui. And a provenance
verdict from a call that carries no credential at all.
Every block below is a file in the repository, rendered verbatim. The same files are sourced by the gate that drove this run against the hosted instance, so what you read here is what was executed.
Prerequisites
Section titled “Prerequisites”- A credential. Either a key from an invite
(Onboard with an invite) or a token your own
issuer signed (Bring your own issuer). One
goes in
PF_SK, the other goes in the same bearer slot — nothing else changes. curlandpython3. No SDK, no AWS CLI profile.python3only reads fields out of JSON answers;jqdoes the same job.- The instance has to be up. It is stopped between evaluation windows — The testnet instance explains when, and how to ask for a window.
Work in an empty directory: several steps write files next to each other and compare them.
The walkthrough
Section titled “The walkthrough”These are snippets, not standalone scripts. They share variables (BUCKET,
CID, STORED_HASH), so source them in order into one shell.
Set the endpoint and your credential
Section titled “Set the endpoint and your credential”Nothing is called here. This fixes the host, the bearer header every later command sends, and a bucket name that will not collide with an earlier attempt.
# Where the API is, who you are, and the bucket this walkthrough uses.
HOST=${HOST:-https://testnet.permafrost.live}
# PF_SK is the key from your invite. Put a JWT your issuer signed in its place# and every command below works unchanged.AUTH=${AUTH:-"Authorization: Bearer $PF_SK"}
BUCKET=${BUCKET:-demo-$(date +%s)}
echo "host $HOST"echo "bucket $BUCKET"Create a bucket
Section titled “Create a bucket”A bucket belongs to the tenant that created it. 200 means it exists and it is
yours; another tenant asking for the same name is refused.
# Create the bucket. 200 means it exists and it is yours.
curl -sS -m 60 -X PUT -H "$AUTH" \ -o /dev/null -w 'bucket %{http_code}\n' "$HOST/$BUCKET"Store an object
Section titled “Store an object”An ordinary PUT. The bytes are encrypted at rest on the way in, without a
header from you — see Encryption at rest.
# Store a local file as report.bin. Bring your own bytes, or make 64 KiB.
[ -f local.bin ] || head -c 65536 /dev/urandom > local.bin
curl -sS -m 120 -X PUT -H "$AUTH" --data-binary @local.bin \ -o /dev/null -w 'put %{http_code}\n' "$HOST/$BUCKET/report.bin"Read it back and compare
Section titled “Read it back and compare”This is the assertion that matters. cmp prints nothing and the line reads
byte-equal, or the round trip failed.
# Read it back and compare it to what you sent.
curl -sS -m 120 -H "$AUTH" -o roundtrip.bin "$HOST/$BUCKET/report.bin"
cmp local.bin roundtrip.bin && echo byte-equalAsk what happened to it
Section titled “Ask what happened to it”HEAD carries Permafrost’s own metadata. The sync state is always there; the
blob id appears only once the object is certified, so its absence is “not yet”,
not an error. This block polls until it arrives.
# HEAD carries Permafrost's own metadata. The sync state is always there. The# blob id appears only once the object is certified, so its absence is# "not yet certified", not an error.
HEADERS=$(curl -sS -m 60 -I -H "$AUTH" "$HOST/$BUCKET/report.bin")printf '%s\n' "$HEADERS" | grep -i '^x-amz-meta-permafrost-'
WAIT_SECS=${BLOBID_WAIT_SECS:-300}echo "waiting up to ${WAIT_SECS}s for the blob id"
WAITED=0BLOB_ID=""while [ "$WAITED" -lt "$WAIT_SECS" ]; do HEADERS=$(curl -sS -m 60 -I -H "$AUTH" "$HOST/$BUCKET/report.bin") BLOB_ID=$(printf '%s\n' "$HEADERS" \ | grep -i '^x-amz-meta-permafrost-blob-id:' | cut -d' ' -f2- | tr -d '\r' || true) if [ -n "$BLOB_ID" ]; then break; fi sleep 5 WAITED=$((WAITED + 5))done
echo "blob id: ${BLOB_ID:-not yet certified} (after ${WAITED}s)"Delete it
Section titled “Delete it”204, then 404 on the read path. Full detail in
Store, read back, and know when it is certified.
# Delete it. Expect 204, then 404 on the read path.
curl -sS -m 60 -X DELETE -H "$AUTH" \ -o /dev/null -w 'delete %{http_code}\n' "$HOST/$BUCKET/report.bin"
curl -sS -m 60 -H "$AUTH" \ -o /dev/null -w 'get after delete %{http_code}\n' "$HOST/$BUCKET/report.bin"Upload media in parts, then read a byte range
Section titled “Upload media in parts, then read a byte range”A large object goes up in parts, completes in one request, reads back
byte-identical, and serves a range as 206. This is the longest block: it
uploads every part, keeps each ETag, and completes with them.
# A large object goes up in parts. Every part but the last must be at least# 5 MiB - that is S3's minimum - so the last part is deliberately smaller.
KEY=${KEY:-big.bin}PARTS=${PARTS:-3}PART_MIB=${PART_MIB:-5}
UPLOAD_ID=$(curl -sS -m 60 -X POST -H "$AUTH" "$HOST/$BUCKET/$KEY?uploads" \ | sed -n 's:.*<UploadId>\(.*\)</UploadId>.*:\1:p')echo "upload id: $UPLOAD_ID"
# Upload each part, keeping the ETag the server answers with.: > all.binXML="<CompleteMultipartUpload>"i=1while [ "$i" -le "$PARTS" ]; do if [ "$i" -eq "$PARTS" ]; then MIB=1; else MIB=$PART_MIB; fi head -c "$((MIB * 1048576))" /dev/urandom > "part$i.bin" cat "part$i.bin" >> all.bin
ETAG=$(curl -sS -m 300 -X PUT -H "$AUTH" --data-binary @"part$i.bin" -D - -o /dev/null \ "$HOST/$BUCKET/$KEY?partNumber=$i&uploadId=$UPLOAD_ID" \ | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r') XML="$XML<Part><PartNumber>$i</PartNumber><ETag>$ETAG</ETag></Part>" echo "part $i/$PARTS ($MIB MiB) $ETAG" i=$((i + 1))doneXML="$XML</CompleteMultipartUpload>"
# Completing is the slow call: the server assembles and registers the object.printf '%s' "$XML" > complete.xmlcurl -sS -m 600 -X POST -H "$AUTH" -H 'Content-Type: application/xml' \ --data-binary @complete.xml \ -o complete-response.xml -w 'complete %{http_code}\n' \ "$HOST/$BUCKET/$KEY?uploadId=$UPLOAD_ID"
curl -sS -m 600 -H "$AUTH" -o whole.bin "$HOST/$BUCKET/$KEY"cmp all.bin whole.bin && echo "byte-equal over $(wc -c < all.bin | tr -d ' ') bytes"
# 16 bytes at the 1 MiB mark. 206 means the range was served, not the object.curl -sS -m 120 -H "$AUTH" -H 'Range: bytes=1048576-1048591' \ -o slice.bin -w 'range %{http_code}\n' "$HOST/$BUCKET/$KEY"
curl -sS -m 120 -X DELETE -H "$AUTH" \ -o /dev/null -w 'delete %{http_code}\n' "$HOST/$BUCKET/$KEY"Hold something in custody
Section titled “Hold something in custody”The custody surface holds material on a user’s behalf rather than serving it. It answers with two hashes, and they are not interchangeable — keep both.
# Custody holds a secret for you, encrypted, and answers with two hashes:# content_hash is the hash of your plaintext, stored_hash is the hash of the# bytes as stored. Keep both - they are used by different verifiers.
TEXT=${TEXT:-"hello, custody"}
curl -sS -m 60 -X POST -H "$AUTH" -H 'Content-Type: application/json' \ -d "$(python3 -c 'import json,sys; print(json.dumps({"text": sys.argv[1]}))' "$TEXT")" \ -o store.json -w 'store %{http_code}\n' "$HOST/v1/api/custody/store"
# jq does this just as well: CID=$(jq -r .id store.json)CID=$(python3 -c 'import json; print(json.load(open("store.json"))["id"])')CONTENT_HASH=$(python3 -c 'import json; print(json.load(open("store.json"))["content_hash"])')STORED_HASH=$(python3 -c 'import json; print(json.load(open("store.json"))["stored_hash"])')
echo "id $CID"echo "content_hash $CONTENT_HASH"echo "stored_hash $STORED_HASH"Ask whether it is alive
Section titled “Ask whether it is alive”alive is true while the key that decrypts it still exists.
# alive is true while the key still exists.
curl -sS -m 60 -H "$AUTH" "$HOST/v1/api/custody/status/$CID"echoRead the plaintext back
Section titled “Read the plaintext back”While the key lives, the plaintext comes back.
# While the key lives, the plaintext comes back.
curl -sS -m 60 -H "$AUTH" "$HOST/v1/api/custody/plaintext/$CID"echoIncinerate it
Section titled “Incinerate it”The key is destroyed and a certificate is minted on Sui. minted means the
certificate landed and the digest resolves; not_minted means it did not, and
the key is destroyed either way.
# Incinerate: deletion by key destruction, with a certificate as the receipt.# minted means the certificate landed on chain and the digest below resolves.
curl -sS -m 120 -X POST -H "$AUTH" \ -o incinerate.json -w 'incinerate %{http_code}\n' \ "$HOST/v1/api/custody/incinerate/$CID"
python3 - <<'PY'import jsond = json.load(open("incinerate.json"))for k in ("certificate_status", "certificate_object_id", "certificate_tx_digest"): print(k, "=", d.get(k))PYConfirm the plaintext is gone
Section titled “Confirm the plaintext is gone”410 on the plaintext route, and the status says alive: false. Both stay that
way. See Hold, serve, incinerate.
# The plaintext is gone for good: 410, and the status says so.
curl -sS -m 60 -H "$AUTH" \ -o after.json -w 'plaintext %{http_code}\n' "$HOST/v1/api/custody/plaintext/$CID"
curl -sS -m 60 -H "$AUTH" -o status.json "$HOST/v1/api/custody/status/$CID"
python3 - <<'PY'import jsond = json.load(open("status.json"))print("alive =", d.get("alive"))print("deletion_mode =", d.get("deletion_mode"))PYVerify, with no credential at all
Section titled “Verify, with no credential at all”Neither call below sends an Authorization header. The first asks with the
stored hash and is answered provenance: true; the second asks with the
plaintext hash and is answered provenance: false, which is the wrong hash
rather than a missing record.
# Verification takes no key: no Authorization header on either call below.# Anyone holding the bytes can ask whether Permafrost holds provenance.## Verify indexes the STORED hash - the bytes as stored - so the first call# answers provenance true. Asking with CONTENT_HASH, the plaintext hash,# legitimately answers provenance false: the plaintext hash is what the# certificate names, not what storage indexes.
curl -sS -m 60 "$HOST/v1/api/verify?hash=$STORED_HASH"echo
curl -sS -m 60 "$HOST/v1/api/verify?hash=$CONTENT_HASH"echoWhere this was proven
Section titled “Where this was proven”The run named in the badge above drove these exact files against the hosted
instance at https://testnet.permafrost.live — not a local stack, not a
transcript written afterwards. For what a 401 and a 429 mean when one
arrives, see 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.