Conversation
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>
commit: |
Collaborator
Author
|
Example transcript of derivation: |
Collaborator
Author
|
Example transcript of selective sharing: |
Collaborator
Author
|
Example transcript of pure read: |
Collaborator
Author
|
Example transcript of key recovery: |
…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
marked this pull request as ready for review
September 28, 2026 15:27
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
The key derivation tree
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
clientDataSetId, notdataSetId. The client must pick it before the dataset exists so the first piece can be encrypted beforecreateDataSetcompletes. 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(?)datasetSecret()signs the same message twice and refuses a signer that disagrees with itself; a non-secretfoc/kccommitment rides into thecreateDataSetmetadata that happens anyway, so recovery verifies the key before decrypting anything.sis normalised to the low half andvis dropped, so both malleable forms of a signature give one key.Contents
src/derive.ts— the tree, the commitment, the metadata a reader needssrc/wrap.ts— wrap/unwrap a node key to a recipientREADME.md— developer guide: write, read, share, recover, and the caveatspnpm --filter @filoz/keysmith test)Dependencies are
@noble/curvesand@noble/hashes, withviemas a peer for hex and typed-data types. No changes to any existing package;knip,biomeandmarkdownlintare clean.Known limits
Questions for reviewers
@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.Synapse.storage.upload()behind anencryptflag 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, plainGETfrom Curio, and recovery from a wallet alone — using a JavaScript port of this same code.