Skip to content

feat(keysmith): per-dataset key derivation package - #983

Open
JAG-UK wants to merge 7 commits into
FilOzone:masterfrom
JAG-UK:feat/keysmith
Open

JAG-UK wants to merge 7 commits into
FilOzone:masterfrom
JAG-UK:feat/keysmith

Conversation

@JAG-UK

@JAG-UK JAG-UK commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

A package, @filoz/keysmith, that turns one wallet signature per dataset into every key needed to encrypt and share data in that dataset on FOC. It stores nothing, needs no key server, and puts no key material on chain — so a user who only has their wallet can always decrypt and read their data.

Keysmith only generates and derives keys. It does not encrypt. The actual encryption step happens in the Filecoin Encryption Envelope library.

your app ──▶ @filoz/keysmith ──key──▶ FEE (envelope) ──bytes──▶ @filoz/synapse-sdk ──▶ FWSS + Curio

The key derivation tree

sig = signTypedData(DatasetKey{chainId, service, payer, clientDataSetId, epoch})
DK  = HKDF(r‖s, "foc/acl/dataset/v1")      one dataset
SK  = HKDF(DK,  "foc/acl/scope/v1"‖name)   one section of it
PK  = HKDF(node,"foc/acl/piece/v1"‖salt)   one piece

Every step is one-way, and datasets share no common ancestor, so no key anywhere opens more than one dataset, and sharing a portion doesn't leak the rest.

Three decisions worth reviewing

  1. Keyed on clientDataSetId, not dataSetId. The client must pick it before the dataset exists so the first piece can be encrypted before createDataSet completes. This is ergonomic in the case of first upload but it does mean the client needs to remember the ID they gave it, and never reuse IDs. Possibly should use a UUID and store it in Dataset metadata(?)
  2. Determinism is checked twice. datasetSecret() signs the same message twice and refuses a signer that disagrees with itself; a non-secret foc/kc commitment rides into the createDataSet metadata that happens anyway, so recovery verifies the key before decrypting anything. s is normalised to the low half and v is dropped, so both malleable forms of a signature give one key.
  3. Grants use secp256k1 ECDH-ES + AES-256-GCM. Wallets and Session Key Registry session keys are already secp256k1, so a recipient needs no published encryption key and nothing new becomes stateful.

Contents

  • src/derive.ts — the tree, the commitment, the metadata a reader needs
  • src/wrap.ts — wrap/unwrap a node key to a recipient
  • README.md — developer guide: write, read, share, recover, and the caveats
  • tests, passing in both node and browser (pnpm --filter @filoz/keysmith test)

Dependencies are @noble/curves and @noble/hashes, with viem as a peer for hex and typed-data types. No changes to any existing package; knip, biome and markdownlint are clean.

Known limits

  • Sharing cannot be undone. A grant hands over a symmetric key with access to all Pieces below it in the hierarchy. Cutting someone off means moving that content to a new scope or dataset and re-encrypting.
  • A scope name travels in clear inside the envelope, because a reader needs it to derive scope keys. Bytes stay secret; labels do not.
  • Contract accounts and hardware wallets that cannot do ECDH can hold keys but cannot receive grants this way. They need a signature-derived encryption key, which is not in this package (yet).

Questions for reviewers

  • Should this be its own package, or a module inside @filoz/synapse-core? It is dependency-light and useful without the SDK, which is why it starts separate. FEE is going in synapse so that's why I put this here.
  • Worth wiring into Synapse.storage.upload() behind an encrypt flag for transparent encryption once FEE (feat(encryption): add Filecoin encryption envelope package #967) lands, or leaving it composable?

This has been exercised end to end against foc-devnet — dataset creation, delegated writes by a session key, dataset and scope sharing, plain GET from Curio, and recovery from a wallet alone — using a JavaScript port of this same code.

Keysmith turns one wallet signature into every key for a dataset, so that
encrypted data on FOC needs no keystore, no key server and no key material
on chain. It sources keys only: FEE makes the envelope, the SDK uploads it.

    sig = signTypedData(DatasetKey{chainId, service, payer, clientDataSetId, epoch})
    DK  = HKDF(r‖s, "foc/acl/dataset/v1")      one dataset
    SK  = HKDF(DK,  "foc/acl/scope/v1"‖name)   one section of it
    PK  = HKDF(node,"foc/acl/piece/v1"‖salt)   one piece

Keying on clientDataSetId rather than the chain-assigned dataSetId lets a
client encrypt its first piece before createDataSet runs, so the upload
pipeline gains no ordering constraint.

Determinism is what the scheme rests on, so it is checked twice: signing
the same message twice must agree, and a non-secret foc/kc commitment rides
into the existing createDataSet metadata for recovery to verify against. s
is normalised to the low half and v is dropped, so the two malleable forms
of a signature yield one key.

Sharing wraps a node key to a recipient's secp256k1 public key (ECDH-ES +
AES-256-GCM), so wallets and session keys can receive grants without
publishing an encryption key. The grant descriptor is authenticated, so it
cannot be relabelled as another dataset or scope.

21 tests, node and browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-project-automation github-project-automation Bot moved this to 📌 Triage in FOC Sep 24, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/FilOzone/synapse-sdk/@filoz/keysmith@983
npm i https://pkg.pr.new/FilOzone/synapse-sdk/@filoz/synapse-core@983
npm i https://pkg.pr.new/FilOzone/synapse-sdk/@filoz/synapse-react@983
npm i https://pkg.pr.new/FilOzone/synapse-sdk/@filoz/synapse-sdk@983

commit: fbbe9fc

@JAG-UK
JAG-UK marked this pull request as draft September 24, 2026 17:44
@FilOzzy FilOzzy moved this from 📌 Triage to ⌨️ In Progress in FOC Sep 24, 2026
@JAG-UK

JAG-UK commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Example transcript of derivation:

── 01-create-dataset.mjs
devnet 20260924T1552_BorkyMomo · payer 0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c · SP http://localhost:5711

[1] Pick clientDataSetId (client-chosen, before the dataset exists)
    clientDataSetId = 6415748696276765185

[2] Sign DatasetKey once — twice, and compare (D3a)
    │ ──── EIP-712 DatasetKey ────────────────────────────────────────────
    │ domain {"name":"FOC Encryption","version":"1"}
    │ types  ["string purpose","uint256 chainId","address service","address payer","uint256 clientDataSetId","uint32 epoch"]
    │   purpose: foc/enc/v1 dataset key
    │   chainId: 31415926
    │   service: 0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3
    │   payer: 0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c
    │   clientDataSetId: 6415748696276765185
    │   epoch: 0
    │ signature (65 B, r‖s‖v) (65 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │ key material (r‖s, low-S, v dropped) (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │ HKDF-SHA256 → DK (dataset key)
    │   IKM  (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │   info  "foc/acl/dataset/v1"
    │   out  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │ HKDF-SHA256 → foc/kc (public commitment)
    │   IKM  (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │   info  "foc/kc/v1"
    │   out  (16 B) 0f8efc844a3867b30d5a0838f7c033cb
    signature is deterministic; DK = 0xd738b4e79e3f3e00…
    foc/kc = v1.0f8efc844a3867b30d5a0838f7c033cb   (non-secret commitment, D3b)

[3] Derive a key for this piece, and encrypt with FEE
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/piece/v10x4bbeb3109e9031d15f7741cef4330036"
    │   out  (32 B) 91dc13dc1704eeeafef21c4b65386717d6853c41561d5239a0d80342de5d3dee
    │ ──── the piece as it will be stored ────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 170 B · ciphertext 277 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'a7b799a13dcf2a', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x4bbeb3109e9031d15f7741cef4330036", "foc/epoch": 0}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'a7b799a13dcf2a', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x4bbeb3109e9031d15f7741cef4330036", "foc/epoch": 0}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x4bbeb3109e9031d15f7741cef4330036"
    │   "foc/epoch" => 0
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) a7b799a13dcf2a
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..261) ← blob [170..447) = 261 B + 16 B tag · nonce a7b799a13dcf2a 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    plaintext 261 B → envelope 447 B
    app_metadata: {"foc/v":1,"foc/cds":"0x5909517279c08a01","foc/epoch":0,"foc/salt":"0x4bbeb3109e9031d15f7741cef4330036"}

[4] Fund the rails (ordinary Synapse payment setup, nothing to do with keys)
    available 2.379530547306534102 USDFC
    already funded

[5] PUT the ciphertext to Curio
    │ ──── piece CID — computed over ciphertext, not plaintext ───────────
    │ 447 B → bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi
    │ fr32-padded merkle root (multicodec fil-commitment-unsealed, multihash sha2-256-trunc254-padded)
    │ POST http://localhost:5711/pdp/piece {"pieceCid":"bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi"}
    │ PUT http://localhost:5711/pdp/piece/upload/<uuid> 447 B octet-stream
    │ GET http://localhost:5711/pdp/piece?pieceCid=bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi → parked
    pieceCid bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi — Curio never saw a key

[6] createDataSet + addPieces, carrying foc/kc in metadata
    │ eth_call PDPVerifier.getNextPieceId(2) → 1
    dataSetId 2 · tx 0x37782ef5a3e95b162e638838bf33230135113086bc478f50bcd5c59fd10fee63
    piece confirmed live on PDPVerifier

Stored. The only secret is a signature the payer can reproduce at will.

@JAG-UK

JAG-UK commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Example transcript of selective sharing:

── 04-share-scope-read.mjs

[1] Payer derives DK, then two scope keys
    │ ──── EIP-712 DatasetKey ────────────────────────────────────────────
    │ domain {"name":"FOC Encryption","version":"1"}
    │ types  ["string purpose","uint256 chainId","address service","address payer","uint256 clientDataSetId","uint32 epoch"]
    │   purpose: foc/enc/v1 dataset key
    │   chainId: 31415926
    │   service: 0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3
    │   payer: 0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c
    │   clientDataSetId: 6415748696276765185
    │   epoch: 0
    │ signature (65 B, r‖s‖v) (65 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │ key material (r‖s, low-S, v dropped) (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │ HKDF-SHA256 → DK (dataset key)
    │   IKM  (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │   info  "foc/acl/dataset/v1"
    │   out  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │ HKDF-SHA256 → SK (scope "invoices")
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/scope/v1invoices"
    │   out  (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │ HKDF-SHA256 → SK (scope "payroll")
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/scope/v1payroll"
    │   out  (32 B) 0e2041b0b0b6bf79337dd853ead508429de42dbb6d8f6eb762abbd3e3fe107de
    SK(invoices) = 0x92b80e8752ef06d0…
    SK(payroll)  = 0x0e2041b0b0b6bf79…  (unrelated)

[2] Write one piece into each scope
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │   info  "foc/acl/piece/v10x6a9876dcf140e9e79163351b82c19071"
    │   out  (32 B) f8245f8c55f3b083dad4e3058ebaa21634c66c60c4c20541fa95d14e6323fdb4
    │ ──── piece CID — computed over ciphertext, not plaintext ───────────
    │ 444 B → bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi
    │ fr32-padded merkle root (multicodec fil-commitment-unsealed, multihash sha2-256-trunc254-padded)
    │ POST http://localhost:5711/pdp/piece {"pieceCid":"bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi"}
    │ PUT http://localhost:5711/pdp/piece/upload/<uuid> 444 B octet-stream
    │ GET http://localhost:5711/pdp/piece?pieceCid=bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi → parked
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 2
    │ eth_call PDPVerifier.getNextPieceId(2) → 3
    invoices: bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi · tx 0xc2ed480adcd9395b9912d3ccbe55b8924c87568bb109678b80bddf9c945f5ebc
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 0e2041b0b0b6bf79337dd853ead508429de42dbb6d8f6eb762abbd3e3fe107de
    │   info  "foc/acl/piece/v10x08c751c06f51f1acf87d59d39e001f23"
    │   out  (32 B) 8778297047995acc0d42f6a980f5f3dd5b7f69909c7e67a215d072c7e00ee77e
    │ ──── piece CID — computed over ciphertext, not plaintext ───────────
    │ 442 B → bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi
    │ fr32-padded merkle root (multicodec fil-commitment-unsealed, multihash sha2-256-trunc254-padded)
    │ POST http://localhost:5711/pdp/piece {"pieceCid":"bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi"}
    │ PUT http://localhost:5711/pdp/piece/upload/<uuid> 442 B octet-stream
    │ GET http://localhost:5711/pdp/piece?pieceCid=bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi → parked
    │ eth_call PDPVerifier.getNextPieceId(2) → 3
    │ eth_call PDPVerifier.getNextPieceId(2) → 3
    │ eth_call PDPVerifier.getNextPieceId(2) → 3
    │ eth_call PDPVerifier.getNextPieceId(2) → 3
    │ eth_call PDPVerifier.getNextPieceId(2) → 3
    │ eth_call PDPVerifier.getNextPieceId(2) → 4
    payroll: bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi · tx 0xb3cfb5104c40cc67f0a5289af08bbb80e75c4d29cb29f7b93b4585e7dd54aa54

[3] Share the invoices scope only
    │ ──── wrap to 0x04c1620e3273… · ECDH-ES + AES-256-GCM over secp256k1 
    │ ephemeral public key (epk) (65 B) 04e917ec947e8ab8fde010de52de1f4693c1ce8df5e237b27cff0ff762041fe2…
    │ ECDH shared secret (32 B) 342b5ecfa4749ebfd73d4a0f5057ff3f3d358ae49de38beb44d08e0600f2b3e5
    │ KEK = HKDF-SHA256(shared ‖ epk, info "foc/acl/wrap/v1")
    │ KEK (32 B) ec9306acd1b88242ac018f07e631378586c3ae38148879ac09903e34ab81ffe5
    │ AAD = canonical descriptor JSON: {"v":1,"node":"scope:invoices","chainId":31415926,"service":"0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3","payer":"0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c","clientDataSetId":"6415748696276765185","dataSetId":"2"}
    │ GCM nonce (12 B) e9576e54bbe42b97fbd6e978
    │ wrapped key (ct ‖ tag) (48 B) 3564634879298e10ba10950f347abb9f42f81d2b09c2fd10de5d756ac9f369d8…
    grant → /Users/jag/repos/filecoin_stuff/encrypted_retrievals/transparent/demos/out/grant-scope-invoices.json

[4] Auditor unwraps the scope key and reads what it covers
    │ ──── unwrap grant for scope:invoices · ECDH-ES + AES-256-GCM over secp256k1 
    │ ephemeral public key (epk) (65 B) 04e917ec947e8ab8fde010de52de1f4693c1ce8df5e237b27cff0ff762041fe2…
    │ ECDH shared secret (32 B) 342b5ecfa4749ebfd73d4a0f5057ff3f3d358ae49de38beb44d08e0600f2b3e5
    │ KEK = HKDF-SHA256(shared ‖ epk, info "foc/acl/wrap/v1")
    │ KEK (32 B) ec9306acd1b88242ac018f07e631378586c3ae38148879ac09903e34ab81ffe5
    │ AAD = canonical descriptor JSON: {"v":1,"node":"scope:invoices","chainId":31415926,"service":"0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3","payer":"0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c","clientDataSetId":"6415748696276765185","dataSetId":"2"}
    │ GCM nonce (12 B) e9576e54bbe42b97fbd6e978
    │ wrapped key (ct ‖ tag) (48 B) 3564634879298e10ba10950f347abb9f42f81d2b09c2fd10de5d756ac9f369d8…
    │ unwrapped node key (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │ GET http://localhost:5711/piece/bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi (no auth header, no chain call)
    │ 200 OK · 444 B · application/octet-stream
    │ ──── piece in scope invoices ───────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 189 B · ciphertext 255 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'e30ae16537b85c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x6a9876dcf140e9e79163351b82c19071", "foc/epoch": 0, "foc/scope": "invoices"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'e30ae16537b85c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x6a9876dcf140e9e79163351b82c19071", "foc/epoch": 0, "foc/scope": "invoices"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x6a9876dcf140e9e79163351b82c19071"
    │   "foc/epoch" => 0
    │   "foc/scope" => "invoices"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) e30ae16537b85c
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..239) ← blob [189..444) = 239 B + 16 B tag · nonce e30ae16537b85c 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: SK — one scope
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope invoices · salt 0x6a9876dcf140e9e79163351b82c19071
    │ walk: SK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │   info  "foc/acl/piece/v10x6a9876dcf140e9e79163351b82c19071"
    │   out  (32 B) f8245f8c55f3b083dad4e3058ebaa21634c66c60c4c20541fa95d14e6323fdb4
    invoices: readable → "invoices for 2026-09 — confidential. ###…"
    │ GET http://localhost:5711/piece/bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi (no auth header, no chain call)
    │ 200 OK · 442 B · application/octet-stream
    │ ──── piece in scope payroll ────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 188 B · ciphertext 254 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'2e749353cdc47c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x08c751c06f51f1acf87d59d39e001f23", "foc/epoch": 0, "foc/scope": "payroll"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'2e749353cdc47c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x08c751c06f51f1acf87d59d39e001f23", "foc/epoch": 0, "foc/scope": "payroll"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x08c751c06f51f1acf87d59d39e001f23"
    │   "foc/epoch" => 0
    │   "foc/scope" => "payroll"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) 2e749353cdc47c
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..238) ← blob [188..442) = 238 B + 16 B tag · nonce 2e749353cdc47c 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: SK — one scope
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope payroll · salt 0x08c751c06f51f1acf87d59d39e001f23
    │ walk: SK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │   info  "foc/acl/piece/v10x08c751c06f51f1acf87d59d39e001f23"
    │   out  (32 B) a335abf353cbafe2ba4f1e38064dec8608a2401ac1341bb254d36f9c8c8b2f8a
    payroll: NOT readable → AuthenticationError (payroll needs its own key)

The auditor holds one key for one section. The payer still reads everything,
because both scopes hang below the DK the wallet reproduces on demand.

@JAG-UK

JAG-UK commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Example transcript of pure read:

── 05-curio-get.mjs

[1] GET the piece — curl would do
    curl -s http://localhost:5711/piece/bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi
    200 OK · 447 B · content-type application/octet-stream

[2] Anyone can read the envelope; nobody can read the content
    │ ──── exactly what the SP served ────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 170 B · ciphertext 277 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'a7b799a13dcf2a', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x4bbeb3109e9031d15f7741cef4330036", "foc/epoch": 0}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'a7b799a13dcf2a', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x4bbeb3109e9031d15f7741cef4330036", "foc/epoch": 0}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x4bbeb3109e9031d15f7741cef4330036"
    │   "foc/epoch" => 0
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) a7b799a13dcf2a
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..261) ← blob [170..447) = 261 B + 16 B tag · nonce a7b799a13dcf2a 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    algorithm -65793 · seekable true · chunks 1
    app_metadata {"foc/v":1,"foc/cds":"0x5909517279c08a01","foc/salt":"0x4bbeb3109e9031d15f7741cef4330036","foc/epoch":0}
    recipients in object: 0 (sharing happens off-chain, not in the piece)

[3] Without the key, that is where it ends
    decrypt with a wrong key → AuthenticationError

[4] With the grant from demo 3, the same bytes open
    │ ──── unwrap grant for dataset · ECDH-ES + AES-256-GCM over secp256k1 
    │ ephemeral public key (epk) (65 B) 0445ad44993a86d55233cb15f979503e5f419dd5b220383cebec4458df6ad747…
    │ ECDH shared secret (32 B) c772b25946c0210a1b1ee5816e45764151b6aad956d2c37d8a8585f430a8c80a
    │ KEK = HKDF-SHA256(shared ‖ epk, info "foc/acl/wrap/v1")
    │ KEK (32 B) 7d30c4bd4a83b81863d38eba4c5e9ef17ac0bcd4de7ca832fe4d11a27517c5d8
    │ AAD = canonical descriptor JSON: {"v":1,"node":"dataset","chainId":31415926,"service":"0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3","payer":"0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c","clientDataSetId":"6415748696276765185","dataSetId":"2"}
    │ GCM nonce (12 B) 4e826f3a0975aeca7e08d747
    │ wrapped key (ct ‖ tag) (48 B) 3f2508e1bb1e5f07c0688d07b4d44c911dbcef719d07067169a05b02f140d60a…
    │ unwrapped node key (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope (none) · salt 0x4bbeb3109e9031d15f7741cef4330036
    │ walk: DK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/piece/v10x4bbeb3109e9031d15f7741cef4330036"
    │   out  (32 B) 91dc13dc1704eeeafef21c4b65386717d6853c41561d5239a0d80342de5d3dee
    plaintext: "invoice 2026-09: 42 FIL. devnet run 20260924T1552_BorkyMomo.…"

The retrieval path never changed. Curio, gateways and CDNs stay exactly as they are.

@JAG-UK

JAG-UK commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Example transcript of key recovery:

── 06-recover-from-nothing.mjs

[1] Ask the chain what this wallet pays for
    2 dataset(s) for 0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c

[2] Dataset 1 — clientDataSetId 10529154183291210794
    re-signing DatasetKey from the wallet alone
    │ ──── EIP-712 DatasetKey ────────────────────────────────────────────
    │ domain {"name":"FOC Encryption","version":"1"}
    │ types  ["string purpose","uint256 chainId","address service","address payer","uint256 clientDataSetId","uint32 epoch"]
    │   purpose: foc/enc/v1 dataset key
    │   chainId: 31415926
    │   service: 0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3
    │   payer: 0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c
    │   clientDataSetId: 10529154183291210794
    │   epoch: 0
    │ signature (65 B, r‖s‖v) (65 B) 71576d938d52ceb7e8139ccc372cfb6bc0a59332a99ccaec5273a25d26405c82…
    │ key material (r‖s, low-S, v dropped) (64 B) 71576d938d52ceb7e8139ccc372cfb6bc0a59332a99ccaec5273a25d26405c82…
    │ HKDF-SHA256 → foc/kc (public commitment)
    │   IKM  (64 B) 71576d938d52ceb7e8139ccc372cfb6bc0a59332a99ccaec5273a25d26405c82…
    │   info  "foc/kc/v1"
    │   out  (16 B) ac41e4cae23d8d789b69f206e4cf5783
    foc/kc on chain   v1.ac41e4cae23d8d789b69f206e4cf5783
    foc/kc recomputed v1.ac41e4cae23d8d789b69f206e4cf5783
    match: this is the right key, before a single byte is decrypted
    │ HKDF-SHA256 → DK (dataset key)
    │   IKM  (64 B) 71576d938d52ceb7e8139ccc372cfb6bc0a59332a99ccaec5273a25d26405c82…
    │   info  "foc/acl/dataset/v1"
    │   out  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │ eth_call PDPVerifier.getNextPieceId(1) → 8
    │ eth_call PDPVerifier.getPieceCid(1, 0) → 0x01559120223d04f960… → bafkzcibchucpsyarytqn2kpta6jaxiwkezmtmxvwljnrnkclzyw5nkureyn3eka
    │ eth_call PDPVerifier.getPieceCid(1, 1) → 0x01559120223504034a… → bafkzcibcgucagsxgcur24k4tu3p337qjdvuujc2i5rkchydaor4ph4ry76ofoia
    │ eth_call PDPVerifier.getPieceCid(1, 2) → 0x01559120224004f57f… → bafkzcibciacpk7zgjvgl5debndttbayauron2xt4nla67nkgv7o2stsoj6hgoiq
    │ eth_call PDPVerifier.getPieceCid(1, 3) → 0x01559120224204c5d2… → bafkzcibciicmlutw26bu544g2s6tn5h4teexzejiv7f7fe56gmiics5677ll4by
    │ eth_call PDPVerifier.getPieceCid(1, 4) → 0x015591202240043280… → bafkzcibciacdfahbeaedgg7kzymnnpjcoi6eauav2rdsmna7hqjrmnp7vhfj2mq
    │ eth_call PDPVerifier.getPieceCid(1, 5) → 0x015591202242044df8… → bafkzcibciice36hfvzjhlgih7dqr4z4ei5jkmrxp4pd5kofpe25jdoqs3aunwdq
    │ eth_call PDPVerifier.getPieceCid(1, 6) → 0x0155912022400430a2… → bafkzcibciacdbiw6b34sjcymux7vttzshnpyvopfgz6vukrvnqdhc35ujj57cfa
    │ eth_call PDPVerifier.getPieceCid(1, 7) → 0x0155912022420456ed… → bafkzcibciicfn3nskohmejr7ieiu4wduuuva5hkhmqgtacmi2r7l76ax3s3hebi
    8 live piece(s), listed from PDPVerifier
    │ GET http://localhost:5711/piece/bafkzcibchucpsyarytqn2kpta6jaxiwkezmtmxvwljnrnkclzyw5nkureyn3eka (no auth header, no chain call)
    │ 200 OK · 447 B · application/octet-stream
    │ ──── piece 0 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 170 B · ciphertext 277 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'd980d0cd5976f4', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0xa4e7b446031eb4f8d614945dc2ff1156", "foc/epoch": 0}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'd980d0cd5976f4', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0xa4e7b446031eb4f8d614945dc2ff1156", "foc/epoch": 0}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0xa4e7b446031eb4f8d614945dc2ff1156"
    │   "foc/epoch" => 0
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) d980d0cd5976f4
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..261) ← blob [170..447) = 261 B + 16 B tag · nonce d980d0cd5976f4 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope (none) · salt 0xa4e7b446031eb4f8d614945dc2ff1156
    │ walk: DK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/piece/v10xa4e7b446031eb4f8d614945dc2ff1156"
    │   out  (32 B) 898272078ded0cddd40b4207bc6df74275ef08b232e805fc1bd288d7fca52934
      piece 0 [/] → "invoice 2026-09: 42 FIL. devnet run 20260924…"
    │ GET http://localhost:5711/piece/bafkzcibcgucagsxgcur24k4tu3p337qjdvuujc2i5rkchydaor4ph4ry76ofoia (no auth header, no chain call)
    │ 200 OK · 455 B · application/octet-stream
    │ ──── piece 1 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 170 B · ciphertext 285 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'f8b34056a97e95', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x56356282f60d06fa7d797b6e71fa2a01", "foc/epoch": 0}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'f8b34056a97e95', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x56356282f60d06fa7d797b6e71fa2a01", "foc/epoch": 0}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0x56356282f60d06fa7d797b6e71fa2a01"
    │   "foc/epoch" => 0
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) f8b34056a97e95
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..269) ← blob [170..455) = 269 B + 16 B tag · nonce f8b34056a97e95 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope (none) · salt 0x56356282f60d06fa7d797b6e71fa2a01
    │ walk: DK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/piece/v10x56356282f60d06fa7d797b6e71fa2a01"
    │   out  (32 B) abf2eec7d25d0a5912d7a2a1660a6d8ec5ed544e16980887b86986dbc96b723a
      piece 1 [/] → "agent report, written by 0x01C4FAf1f4E9A8032…"
    │ GET http://localhost:5711/piece/bafkzcibciacpk7zgjvgl5debndttbayauron2xt4nla67nkgv7o2stsoj6hgoiq (no auth header, no chain call)
    │ 200 OK · 444 B · application/octet-stream
    │ ──── piece 2 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 189 B · ciphertext 255 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'a794b3e85840ee', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x12703eafb4020ac0f9bacdcf99eb35ef", "foc/epoch": 0, "foc/scope": "invoices"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'a794b3e85840ee', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x12703eafb4020ac0f9bacdcf99eb35ef", "foc/epoch": 0, "foc/scope": "invoices"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0x12703eafb4020ac0f9bacdcf99eb35ef"
    │   "foc/epoch" => 0
    │   "foc/scope" => "invoices"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) a794b3e85840ee
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..239) ← blob [189..444) = 239 B + 16 B tag · nonce a794b3e85840ee 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope invoices · salt 0x12703eafb4020ac0f9bacdcf99eb35ef
    │ walk: DK ──scope "invoices"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "invoices")
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/scope/v1invoices"
    │   out  (32 B) b1266b74fc26997ef56f2a0a378797563853f72a7d0773542b93917b303c7382
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) b1266b74fc26997ef56f2a0a378797563853f72a7d0773542b93917b303c7382
    │   info  "foc/acl/piece/v10x12703eafb4020ac0f9bacdcf99eb35ef"
    │   out  (32 B) bd1c7922549673e5dfd3887609a69527b6aca89be9e7edef24014affe88e3540
      piece 2 [invoices] → "invoices for 2026-09 — confidential. #######…"
    │ GET http://localhost:5711/piece/bafkzcibciicmlutw26bu544g2s6tn5h4teexzejiv7f7fe56gmiics5677ll4by (no auth header, no chain call)
    │ 200 OK · 442 B · application/octet-stream
    │ ──── piece 3 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 188 B · ciphertext 254 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'58cf6ec10018f3', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x75004da1415b0e88c9ba5cceb0e8f773", "foc/epoch": 0, "foc/scope": "payroll"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'58cf6ec10018f3', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x75004da1415b0e88c9ba5cceb0e8f773", "foc/epoch": 0, "foc/scope": "payroll"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0x75004da1415b0e88c9ba5cceb0e8f773"
    │   "foc/epoch" => 0
    │   "foc/scope" => "payroll"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) 58cf6ec10018f3
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..238) ← blob [188..442) = 238 B + 16 B tag · nonce 58cf6ec10018f3 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope payroll · salt 0x75004da1415b0e88c9ba5cceb0e8f773
    │ walk: DK ──scope "payroll"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "payroll")
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/scope/v1payroll"
    │   out  (32 B) 33a64c70eed1516c96448eed971951b03d6527832324d3e063586a710d8c89c7
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 33a64c70eed1516c96448eed971951b03d6527832324d3e063586a710d8c89c7
    │   info  "foc/acl/piece/v10x75004da1415b0e88c9ba5cceb0e8f773"
    │   out  (32 B) c5a1b6d24578efc3a9d86be743626a03fe1088ecec2d2fdcb05bdc916942d823
      piece 3 [payroll] → "payroll for 2026-09 — confidential. ########…"
    │ GET http://localhost:5711/piece/bafkzcibciacdfahbeaedgg7kzymnnpjcoi6eauav2rdsmna7hqjrmnp7vhfj2mq (no auth header, no chain call)
    │ 200 OK · 444 B · application/octet-stream
    │ ──── piece 4 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 189 B · ciphertext 255 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'692602cf4f0b0c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x0d4d2c29b540ef1c54436264d7932e72", "foc/epoch": 0, "foc/scope": "invoices"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'692602cf4f0b0c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x0d4d2c29b540ef1c54436264d7932e72", "foc/epoch": 0, "foc/scope": "invoices"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0x0d4d2c29b540ef1c54436264d7932e72"
    │   "foc/epoch" => 0
    │   "foc/scope" => "invoices"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) 692602cf4f0b0c
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..239) ← blob [189..444) = 239 B + 16 B tag · nonce 692602cf4f0b0c 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope invoices · salt 0x0d4d2c29b540ef1c54436264d7932e72
    │ walk: DK ──scope "invoices"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "invoices")
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/scope/v1invoices"
    │   out  (32 B) b1266b74fc26997ef56f2a0a378797563853f72a7d0773542b93917b303c7382
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) b1266b74fc26997ef56f2a0a378797563853f72a7d0773542b93917b303c7382
    │   info  "foc/acl/piece/v10x0d4d2c29b540ef1c54436264d7932e72"
    │   out  (32 B) 5fad692d7f0b9d3853f6404af34cdd2a4ad84d28438d7016cea247d2c43de75a
      piece 4 [invoices] → "invoices for 2026-09 — confidential. #######…"
    │ GET http://localhost:5711/piece/bafkzcibciice36hfvzjhlgih7dqr4z4ei5jkmrxp4pd5kofpe25jdoqs3aunwdq (no auth header, no chain call)
    │ 200 OK · 442 B · application/octet-stream
    │ ──── piece 5 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 188 B · ciphertext 254 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'a7bdf2569cee1d', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0xdd3d6ef378ddbae8f843f88d35dfe060", "foc/epoch": 0, "foc/scope": "payroll"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'a7bdf2569cee1d', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0xdd3d6ef378ddbae8f843f88d35dfe060", "foc/epoch": 0, "foc/scope": "payroll"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0xdd3d6ef378ddbae8f843f88d35dfe060"
    │   "foc/epoch" => 0
    │   "foc/scope" => "payroll"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) a7bdf2569cee1d
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..238) ← blob [188..442) = 238 B + 16 B tag · nonce a7bdf2569cee1d 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope payroll · salt 0xdd3d6ef378ddbae8f843f88d35dfe060
    │ walk: DK ──scope "payroll"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "payroll")
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/scope/v1payroll"
    │   out  (32 B) 33a64c70eed1516c96448eed971951b03d6527832324d3e063586a710d8c89c7
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 33a64c70eed1516c96448eed971951b03d6527832324d3e063586a710d8c89c7
    │   info  "foc/acl/piece/v10xdd3d6ef378ddbae8f843f88d35dfe060"
    │   out  (32 B) 20c9079b5c7cbd829e7b32aef5b768b2fdd92f22f367b62536074d99518e82f1
      piece 5 [payroll] → "payroll for 2026-09 — confidential. ########…"
    │ GET http://localhost:5711/piece/bafkzcibciacdbiw6b34sjcymux7vttzshnpyvopfgz6vukrvnqdhc35ujj57cfa (no auth header, no chain call)
    │ 200 OK · 444 B · application/octet-stream
    │ ──── piece 6 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 189 B · ciphertext 255 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'd4eb1c6d22dd79', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x57eec281a437777771e3fae3d76404a6", "foc/epoch": 0, "foc/scope": "invoices"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'd4eb1c6d22dd79', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0x57eec281a437777771e3fae3d76404a6", "foc/epoch": 0, "foc/scope": "invoices"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0x57eec281a437777771e3fae3d76404a6"
    │   "foc/epoch" => 0
    │   "foc/scope" => "invoices"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) d4eb1c6d22dd79
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..239) ← blob [189..444) = 239 B + 16 B tag · nonce d4eb1c6d22dd79 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope invoices · salt 0x57eec281a437777771e3fae3d76404a6
    │ walk: DK ──scope "invoices"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "invoices")
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/scope/v1invoices"
    │   out  (32 B) b1266b74fc26997ef56f2a0a378797563853f72a7d0773542b93917b303c7382
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) b1266b74fc26997ef56f2a0a378797563853f72a7d0773542b93917b303c7382
    │   info  "foc/acl/piece/v10x57eec281a437777771e3fae3d76404a6"
    │   out  (32 B) 22ca9a274aa63aeb8b45de866f9d8264e51b1926fbff1e067fa59e0c928a5379
      piece 6 [invoices] → "invoices for 2026-09 — confidential. #######…"
    │ GET http://localhost:5711/piece/bafkzcibciicfn3nskohmejr7ieiu4wduuuva5hkhmqgtacmi2r7l76ax3s3hebi (no auth header, no chain call)
    │ 200 OK · 442 B · application/octet-stream
    │ ──── piece 7 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 188 B · ciphertext 254 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'e2df9b95d6ff8b', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0xfae8d79c0efb11f5b62f0bee3e328485", "foc/epoch": 0, "foc/scope": "payroll"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'e2df9b95d6ff8b', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x921f11f1a0640c2a", "foc/salt": "0xfae8d79c0efb11f5b62f0bee3e328485", "foc/epoch": 0, "foc/scope": "payroll"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x921f11f1a0640c2a"
    │   "foc/salt" => "0xfae8d79c0efb11f5b62f0bee3e328485"
    │   "foc/epoch" => 0
    │   "foc/scope" => "payroll"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) e2df9b95d6ff8b
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..238) ← blob [188..442) = 238 B + 16 B tag · nonce e2df9b95d6ff8b 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x921f11f1a0640c2a · epoch 0 · scope payroll · salt 0xfae8d79c0efb11f5b62f0bee3e328485
    │ walk: DK ──scope "payroll"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "payroll")
    │   IKM  (32 B) 0fa944a3bf60acabb7eb58efae2a77789bd7d68a3ac94cb9b0fe619ed986ea32
    │   info  "foc/acl/scope/v1payroll"
    │   out  (32 B) 33a64c70eed1516c96448eed971951b03d6527832324d3e063586a710d8c89c7
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 33a64c70eed1516c96448eed971951b03d6527832324d3e063586a710d8c89c7
    │   info  "foc/acl/piece/v10xfae8d79c0efb11f5b62f0bee3e328485"
    │   out  (32 B) 26aa1c9a3de8a92908b656e5821efd18647395c4dd56705bada55d942c95a96f
      piece 7 [payroll] → "payroll for 2026-09 — confidential. ########…"

[3] Dataset 2 — clientDataSetId 6415748696276765185
    re-signing DatasetKey from the wallet alone
    │ ──── EIP-712 DatasetKey ────────────────────────────────────────────
    │ domain {"name":"FOC Encryption","version":"1"}
    │ types  ["string purpose","uint256 chainId","address service","address payer","uint256 clientDataSetId","uint32 epoch"]
    │   purpose: foc/enc/v1 dataset key
    │   chainId: 31415926
    │   service: 0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3
    │   payer: 0x47cc9101fd026fc112d7fadf6b3c9df5be7d4a8c
    │   clientDataSetId: 6415748696276765185
    │   epoch: 0
    │ signature (65 B, r‖s‖v) (65 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │ key material (r‖s, low-S, v dropped) (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │ HKDF-SHA256 → foc/kc (public commitment)
    │   IKM  (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │   info  "foc/kc/v1"
    │   out  (16 B) 0f8efc844a3867b30d5a0838f7c033cb
    foc/kc on chain   v1.0f8efc844a3867b30d5a0838f7c033cb
    foc/kc recomputed v1.0f8efc844a3867b30d5a0838f7c033cb
    match: this is the right key, before a single byte is decrypted
    │ HKDF-SHA256 → DK (dataset key)
    │   IKM  (64 B) 630f91972c880fe2a610d84fc1e04be56b4f7935dd0b398cb17bc7c4ecb9c446…
    │   info  "foc/acl/dataset/v1"
    │   out  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │ eth_call PDPVerifier.getNextPieceId(2) → 4
    │ eth_call PDPVerifier.getPieceCid(2, 0) → 0x01559120223d04dcf2… → bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi
    │ eth_call PDPVerifier.getPieceCid(2, 1) → 0x015591202235041feb… → bafkzcibcgucb722n52ki37ge3ne5vcfn5ygejr2ctt2gioa425mhdewkk5524fq
    │ eth_call PDPVerifier.getPieceCid(2, 2) → 0x01559120224004d2d2… → bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi
    │ eth_call PDPVerifier.getPieceCid(2, 3) → 0x01559120224204d679… → bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi
    4 live piece(s), listed from PDPVerifier
    │ GET http://localhost:5711/piece/bafkzcibchucnz4xivspmdbfiyvhjvbwsuakf474hb7qxpmxbeooqiiunvb5zebi (no auth header, no chain call)
    │ 200 OK · 447 B · application/octet-stream
    │ ──── piece 0 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 170 B · ciphertext 277 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'a7b799a13dcf2a', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x4bbeb3109e9031d15f7741cef4330036", "foc/epoch": 0}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'a7b799a13dcf2a', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x4bbeb3109e9031d15f7741cef4330036", "foc/epoch": 0}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x4bbeb3109e9031d15f7741cef4330036"
    │   "foc/epoch" => 0
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) a7b799a13dcf2a
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..261) ← blob [170..447) = 261 B + 16 B tag · nonce a7b799a13dcf2a 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope (none) · salt 0x4bbeb3109e9031d15f7741cef4330036
    │ walk: DK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/piece/v10x4bbeb3109e9031d15f7741cef4330036"
    │   out  (32 B) 91dc13dc1704eeeafef21c4b65386717d6853c41561d5239a0d80342de5d3dee
      piece 0 [/] → "invoice 2026-09: 42 FIL. devnet run 20260924…"
    │ GET http://localhost:5711/piece/bafkzcibcgucb722n52ki37ge3ne5vcfn5ygejr2ctt2gioa425mhdewkk5524fq (no auth header, no chain call)
    │ 200 OK · 455 B · application/octet-stream
    │ ──── piece 1 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 170 B · ciphertext 285 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'fbcbbd124bc4e6', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x7fbf04f63a524a41115c26f111632b6a", "foc/epoch": 0}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'fbcbbd124bc4e6', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x7fbf04f63a524a41115c26f111632b6a", "foc/epoch": 0}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x7fbf04f63a524a41115c26f111632b6a"
    │   "foc/epoch" => 0
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) fbcbbd124bc4e6
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..269) ← blob [170..455) = 269 B + 16 B tag · nonce fbcbbd124bc4e6 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope (none) · salt 0x7fbf04f63a524a41115c26f111632b6a
    │ walk: DK ──salt──▶ PK
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/piece/v10x7fbf04f63a524a41115c26f111632b6a"
    │   out  (32 B) 31464b9cfafce4bd23062aaad46f05aacb891df11449192411c4fceee2f3abac
      piece 1 [/] → "agent report, written by 0xc352a4db4992DF902…"
    │ GET http://localhost:5711/piece/bafkzcibciacnfutpftj5qkt2y6qom6rnygnkgpfwsat4cre3o6kf2qijnubhkmi (no auth header, no chain call)
    │ 200 OK · 444 B · application/octet-stream
    │ ──── piece 2 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 189 B · ciphertext 255 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'e30ae16537b85c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x6a9876dcf140e9e79163351b82c19071", "foc/epoch": 0, "foc/scope": "invoices"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'e30ae16537b85c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x6a9876dcf140e9e79163351b82c19071", "foc/epoch": 0, "foc/scope": "invoices"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x6a9876dcf140e9e79163351b82c19071"
    │   "foc/epoch" => 0
    │   "foc/scope" => "invoices"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) e30ae16537b85c
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..239) ← blob [189..444) = 239 B + 16 B tag · nonce e30ae16537b85c 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope invoices · salt 0x6a9876dcf140e9e79163351b82c19071
    │ walk: DK ──scope "invoices"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "invoices")
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/scope/v1invoices"
    │   out  (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 92b80e8752ef06d0824734afc073ab4ea8573daa2c7843ce9003b684d71d31cf
    │   info  "foc/acl/piece/v10x6a9876dcf140e9e79163351b82c19071"
    │   out  (32 B) f8245f8c55f3b083dad4e3058ebaa21634c66c60c4c20541fa95d14e6323fdb4
      piece 2 [invoices] → "invoices for 2026-09 — confidential. #######…"
    │ GET http://localhost:5711/piece/bafkzcibciicnm6pzqcg7cwzztkgsat7tc7zzqa5whxtgvcezulbtleut7jgjipi (no auth header, no chain call)
    │ 200 OK · 442 B · application/octet-stream
    │ ──── piece 3 ───────────────────────────────────────────────────────
    │ ; RFC 9052 §5, as FEE uses it. The blob is [COSE envelope][ciphertext].
    │ COSE_Encrypt0 = #6.16([ protected: bstr .cbor header_map, unprotected: header_map, null ])
    │ COSE_Encrypt  = #6.96([ protected, unprotected, null, recipients: [+ COSE_recipient] ])
    │ COSE_recipient = [ protected: bstr .cbor {1: alg}, unprotected: {4: kid, ...}, encrypted_key: bstr ]
    │ header_map = { 1 => alg, 5 => IV, -65790 => chunk_size, -65791 => chunk_count, -65792 => app_metadata }
    │ tag 16 (COSE_Encrypt0) · envelope 188 B · ciphertext 254 B
    │ diagnostic: #6.16([h'a2013a000101001078216170706c69636174696f6e2f766e…', {5/IV/: h'2e749353cdc47c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x08c751c06f51f1acf87d59d39e001f23", "foc/epoch": 0, "foc/scope": "payroll"}}, null])
    │ protected (bstr, AAD-covered) (43 B) a2013a000101001078216170706c69636174696f6e2f766e642e666f632d656e…
    │ protected decoded: {1/alg/: -65793, 16/typ/: "application/vnd.foc-envelope+cose"}
    │ alg -65793 = Chunked-AES-256-GCM-STREAM
    │ unprotected: {5/IV/: h'2e749353cdc47c', -65790/chunk_size/: 262144, -65791/chunk_count/: 1, -65792/app_metadata/: {"foc/v": 1, "foc/cds": "0x5909517279c08a01", "foc/salt": "0x08c751c06f51f1acf87d59d39e001f23", "foc/epoch": 0, "foc/scope": "payroll"}}
    │ detached ciphertext slot: null
    │ app_metadata (unprotected in this build; #967 moves it under protected):
    │   "foc/v" => 1
    │   "foc/cds" => "0x5909517279c08a01"
    │   "foc/salt" => "0x08c751c06f51f1acf87d59d39e001f23"
    │   "foc/epoch" => 0
    │   "foc/scope" => "payroll"
    │ recipients: none — this is COSE_Encrypt0, sharing happens outside the object
    │ base nonce (IV) (7 B) 2e749353cdc47c
    │ chunk_size 262144 B · chunk_count 1 · tag overhead 16 B
    │ per-chunk nonce = base[0..6] ‖ uint32be(index) ‖ last_flag
    │ decryption walks this table; a range read touches only the chunks it overlaps:
    │   #0 plaintext [0..238) ← blob [188..442) = 238 B + 16 B tag · nonce 2e749353cdc47c 00000000 01
    │ Enc_structure = [ context: "Encrypt0" / "Encrypt", protected: bstr, external_aad: bstr ]
    │ AAD (Enc_structure, CBOR) (56 B) 8368456e637279707430582ba2013a000101001078216170706c69636174696f…
    │ ──── key identification from app_metadata ──────────────────────────
    │ holding: DK — the whole dataset
    │ envelope says: cds 0x5909517279c08a01 · epoch 0 · scope payroll · salt 0x08c751c06f51f1acf87d59d39e001f23
    │ walk: DK ──scope "payroll"──▶ SK ──salt──▶ PK
    │ HKDF-SHA256 → SK (scope "payroll")
    │   IKM  (32 B) d738b4e79e3f3e00cce1302263fe4cb82d6cc301b4f2a16d968e5f903f55853a
    │   info  "foc/acl/scope/v1payroll"
    │   out  (32 B) 0e2041b0b0b6bf79337dd853ead508429de42dbb6d8f6eb762abbd3e3fe107de
    │ HKDF-SHA256 → PK (piece key) → FEE
    │   IKM  (32 B) 0e2041b0b0b6bf79337dd853ead508429de42dbb6d8f6eb762abbd3e3fe107de
    │   info  "foc/acl/piece/v10x08c751c06f51f1acf87d59d39e001f23"
    │   out  (32 B) 8778297047995acc0d42f6a980f5f3dd5b7f69909c7e67a215d072c7e00ee77e
      piece 3 [payroll] → "payroll for 2026-09 — confidential. ########…"

No keystore, no backup file, no service. A wallet and a public chain were enough.

JAG-UK and others added 6 commits September 25, 2026 18:15
…ngOf

The reading example assumed the caller held the dataset key. A reader given a
scope key has to pass holding: 'scope', and one given a piece key derives
nothing at all — neither was written down.

DK and SK are both 32 bytes of HKDF output, so the envelope cannot say which
is in hand; the grant that delivered it can. holdingOf(grant) reads that back,
so callers stop hand-rolling the mapping.

Passing the wrong level derives a plausible key that fails at the GCM tag with
nothing to say why. One case is always a mistake — a scope key against a piece
at the root of the dataset — so keyForEnvelope now throws there instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JAG-UK
JAG-UK marked this pull request as ready for review September 28, 2026 15:27

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🔎 Awaiting review

Development

Successfully merging this pull request may close these issues.

2 participants