proven on the hosted testnet instance · 2026-09-13 · QA-14
Hold, serve, incinerate — and the certificate
What you will have at the end
Section titled “What you will have at the end”Something your application holds on a user’s behalf rather than serves: stored as ciphertext under a key held here, readable while that key lives, and then incinerated — the key destroyed, the plaintext unrecoverable, and a certificate on Sui naming what was destroyed.
You will also hold two hashes that do two different jobs, and know which verifier takes which.
Prerequisites
Section titled “Prerequisites”- A credential — a key from an invite or a token from your own issuer. This surface sits behind the same check as the S3 path, so the same credential reaches it.
curlandpython3, and the instance up (The testnet instance).HOSTandAUTHset — the first block of the Quickstart. No bucket is needed: this surface is not addressed by bucket and key.
1. Store something
Section titled “1. Store something”One call, and the answer carries the id you will use for everything else plus both hashes.
# 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"stored_hash can also come back null. That means the storage row is not
readable yet, not that there is a substitute hash — it is never a guess.
2. Ask whether it is alive
Section titled “2. Ask whether it is alive”# alive is true while the key still exists.
curl -sS -m 60 -H "$AUTH" "$HOST/v1/api/custody/status/$CID"echoalive is true while the key that decrypts it still exists. An id belonging to
another tenant, and an id that was never stored, both answer 404 {"error": "not found"} — identically, so this surface cannot be used to discover ids.
3. Read the plaintext back
Section titled “3. Read the plaintext back”# While the key lives, the plaintext comes back.
curl -sS -m 60 -H "$AUTH" "$HOST/v1/api/custody/plaintext/$CID"echoWhile the key lives, the plaintext comes back. This is the “serve it” half: your application decides who gets to call this, and Permafrost holds the material in the meantime.
4. Incinerate it
Section titled “4. Incinerate it”This is irreversible. There is no undo, no grace window and no support call that reverses it.
# 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))PYThe answer carries certificate_status, and it has two values:
minted— the certificate landed on Sui.certificate_object_idandcertificate_tx_digestare the on-chain handles, and they resolve.not_minted— no certificate landed. Both handles are null.
The key is destroyed either way. not_minted is a missing receipt, not a
surviving secret: the difference between the two values is whether you have
something to show a third party, never whether the material is still readable.
Incineration also draws on a tighter budget than reads do. A burst of them is
answered 429 {"error": "rate_limited"} with a Retry-After header in seconds —
see Limits.
5. Confirm it is gone
Section titled “5. Confirm it is gone”# 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"))PYThree things change, and they stay changed:
- The plaintext route answers
410 {"error": "plaintext_unrecoverable"}. - Status reads
alive: false. - Status reads
deletion_mode: "auditable_vendor_side"— which is the honest name for what happened. We destroyed the key we were holding, and the record of that destruction is on a public chain. It is not a claim that no copy of the ciphertext exists anywhere; the stored bytes remain, and they are meaningless without the key.
Deletion by key destruction is the longer argument for why that is the right shape.
What the certificate proves, and to whom
Section titled “What the certificate proves, and to whom”The certificate carries no tenant field. It names the plaintext content hash of the destroyed document and an internal key identifier — not you, not your user, not a bucket.
That is deliberate, and it means the proof is the hash match: a third party who holds the original document recomputes its sha256, compares it to the hash the certificate names, and either it matches or it does not. Nothing about who stored it leaks from the chain, and nothing needs to.
The second, independent check runs against the storage layer with the other hash:
GET /v1/api/verify?hash=<stored_hash>No credential. Both checks survive incineration, because the shred destroyed the key rather than the stored object. Verify a file by hash, with no account is that route in full.
How you know it worked
Section titled “How you know it worked”plaintext 410 from the last block, and alive = False on the line under it.
Then, if certificate_status said minted, open certificate_object_id in a
Sui explorer and read the hash it names.
A real one, minted by the run named in this page’s badge:
0x87f2ec4d42ce7c9c713fe0275726d5f78007436b85181a73a2b7019765fa2241, in
transaction CEMSERk3vRZCpBwznisPZxsQ7eS9RUxdV45x2thrXDS9 on Sui testnet. Open it
at https://suiscan.xyz/testnet/object/0x87f2ec4d42ce7c9c713fe0275726d5f78007436b85181a73a2b7019765fa2241
and read content_hash and deletion_mode off the object.
What to read next
Section titled “What to read next”- Deletion by key destruction — what this does and does not assert.
- Verify a file by hash, with no account — the other half of the proof.
- On-chain objects — the certificate’s fields.
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.