Skip to content

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

Quickstart

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.

  • 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.
  • curl and python3. No SDK, no AWS CLI profile. python3 only reads fields out of JSON answers; jq does 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.

These are snippets, not standalone scripts. They share variables (BUCKET, CID, STORED_HASH), so source them in order into one shell.

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.

00-env.sh
# 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"

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.

10-bucket.sh
# 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"

An ordinary PUT. The bytes are encrypted at rest on the way in, without a header from you — see Encryption at rest.

11-put.sh
# 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"

This is the assertion that matters. cmp prints nothing and the line reads byte-equal, or the round trip failed.

12-get.sh
# 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-equal

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.

13-head.sh
# 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=0
BLOB_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)"

204, then 404 on the read path. Full detail in Store, read back, and know when it is certified.

14-delete.sh
# 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.

20-multipart.sh
# 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.bin
XML="<CompleteMultipartUpload>"
i=1
while [ "$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))
done
XML="$XML</CompleteMultipartUpload>"
# Completing is the slow call: the server assembles and registers the object.
printf '%s' "$XML" > complete.xml
curl -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"

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.

30-custody-store.sh
# 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"

alive is true while the key that decrypts it still exists.

31-custody-status.sh
# alive is true while the key still exists.
curl -sS -m 60 -H "$AUTH" "$HOST/v1/api/custody/status/$CID"
echo

While the key lives, the plaintext comes back.

32-custody-plaintext.sh
# While the key lives, the plaintext comes back.
curl -sS -m 60 -H "$AUTH" "$HOST/v1/api/custody/plaintext/$CID"
echo

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.

33-custody-incinerate.sh
# 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 json
d = json.load(open("incinerate.json"))
for k in ("certificate_status", "certificate_object_id", "certificate_tx_digest"):
print(k, "=", d.get(k))
PY

410 on the plaintext route, and the status says alive: false. Both stay that way. See Hold, serve, incinerate.

34-custody-after.sh
# 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 json
d = json.load(open("status.json"))
print("alive =", d.get("alive"))
print("deletion_mode =", d.get("deletion_mode"))
PY

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.

40-verify.sh
# 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"
echo

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.