Skip to content

Quickstart

  • A bucket, and an object read back byte-equal.
  • A HEAD with 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.

  • A credential: an invite key (ask hello@permafrost.live) or a token from your own issuer, in PF_SK. → Connect your app
  • curl and python3 (or jq) to read JSON fields. No SDK needed.
  • Your endpoint. HOST defaults to https://c1.permafrost.live; set it to yours. → Dedicated deployments

Work in an empty directory; steps write and compare files.

Source the snippets in order into one shell; they share BUCKET, CID and STORED_HASH.

No calls. Sets the host, the bearer header and a fresh bucket name.

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

200 means it’s yours. Another tenant asking for the 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"

A plain PUT. Encrypted at rest with no header from you. → Encryption choices

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"

The assertion that matters: cmp is silent and the line reads byte-equal, or it 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

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.

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 read. → 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”

Upload parts, keep each ETag, complete in one request. Reads back byte-identical; a range returns 206.

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"

Custody holds material for a user. It returns two hashes that aren’t interchangeable — keep both.

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

alive is true while its key 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, 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 minted on Sui our statement. minted: it landed and the digest resolves. not_minted: it didn’t. 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 plaintext, alive: false on status, for good. → 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

No Authorization header. The stored hash answers provenance: true. The plaintext hash answers provenance: false — wrong hash, not 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 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

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