Quickstart
What you will have at the end
Section titled “What you will have at the end”- A bucket, and an object read back byte-equal.
- A
HEADwith sync state and, once certified, the blob id. - A multipart object read whole and by range.
- A custody object incinerated to a certificate on Sui.
- A provenance verdict from a call with no credential.
Every block runs against a live deployment before each release.
Prerequisites
Section titled “Prerequisites”- A credential: an invite key (ask
hello@permafrost.live) or a token from your own issuer, inPF_SK. → Connect your app curlandpython3(orjq) to read JSON fields. No SDK needed.- Your endpoint.
HOSTdefaults tohttps://c1.permafrost.live; set it to yours. → Dedicated deployments
Work in an empty directory; steps write and compare files.
The walkthrough
Section titled “The walkthrough”Source the snippets in order into one shell; they share BUCKET, CID and STORED_HASH.
Set the endpoint and your credential
Section titled “Set the endpoint and your credential”No calls. Sets the host, the bearer header and a fresh bucket name.
# Where the API is, who you are, and the bucket this walkthrough uses.
HOST=${HOST:-https://c1.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”200 means it’s yours. Another tenant asking for the 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”A plain PUT. Encrypted at rest with no header from you. → Encryption choices
# 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”The assertion that matters: cmp is silent and the line reads byte-equal, or it 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”Sync state is always there. The blob id appears once certified; until then it’s “not yet”, not an error. This block polls for 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 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 read. → 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”Upload parts, keep each ETag, complete in one request. Reads back byte-identical; a range returns 206.
# 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”Custody holds material for a user. It returns two hashes that aren’t interchangeable — keep both.
# Custody holds a secret for you, encrypted, and answers with two hashes:# certificate_fingerprint is the salted fingerprint the deletion certificate# will name (only someone holding the salt can check it), 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"])')CERT_FINGERPRINT=$(python3 -c 'import json; print(json.load(open("store.json"))["certificate_fingerprint"])')STORED_HASH=$(python3 -c 'import json; print(json.load(open("store.json"))["stored_hash"])')
echo "id $CID"echo "certificate_fingerprint $CERT_FINGERPRINT"echo "stored_hash $STORED_HASH"Ask whether it is alive
Section titled “Ask whether it is alive”alive is true while its key 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, 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 minted on Sui our statement. minted: it landed and the digest resolves. not_minted: it didn’t. 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 plaintext, alive: false on status, for good. → 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”No Authorization header. The stored hash answers provenance: true. The plaintext hash answers provenance: false — wrong hash, not 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 CERT_FINGERPRINT legitimately answers# provenance false: that 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=$CERT_FINGERPRINT"echo- Placement: local, held, or Walrus.
- Receipts: reading a verify answer and a receipt on Sui.
- S3 compatibility: exact surface, headers, status codes.
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.