Keysmith encryption keys
Keysmith and FEE
Section titled “Keysmith and FEE”The Keysmith package takes the hard work out of encryption key management for files stored on Filecoin. No matter where you access your data from, or how far in the future, or if you forget all your application passwords…only your wallet is required to recover everything.
Three packages share the work:
| Package | Role |
|---|---|
filecoin-encryption-envelope (FEE) |
The encrypted format: an envelope header, then the ciphertext. Supports streams and range reads |
@filoz/keysmith |
The keys: derives every encryption key from one wallet signature, and enables fine-grained sharing |
@filoz/synapse-sdk |
Storage: uploads the encrypted bytes, unchanged |
Simply:
your app ──▶ Keysmith ──key──▶ FEE ──encrypted bytes──▶ Synapse SDK ──▶ storage providersA tree of keys
Section titled “A tree of keys”Keysmith derives encryption keys for data as a tree, starting with the wallet (which can always see everything) and then allowing application-defined tightening of access to smaller groups until you reach the individual piece key.
The concepts that enable this are:
- The keyspace is an application-chosen identifier for a tree of encryption keys. Keysmith derives its root key from the owner’s wallet signature. Every key in the keyspace is derived from this, enabling full recovery from just another wallet operation. The application can choose to have one or many keyspaces depending on the need for hard cryptographic separation.
- Encryption scopes group pieces within a keyspace, enabling fine-grained separation of key access. Scopes can contain other scopes: a holder of
app-datacan derive keys forapp-data/notes, but a holder ofapp-data/notescannot derive keys for its parent or sibling scopes. - Each piece gets its own key, derived from its keyspace (and optional scope) and a random salt. The salt is saved in the FEE metadata so that the piece can always be recovered by a suitably authorized client.
⚠️ Keysmith derives a symmetric hierarchy of keys from the root signature: this has significant benefits, but also some things to watch out for:
- ✅ No need for a centralized auth server: everything stays on the client and is truly point to point.
- ✅ No need to re-encrypt files or update any kind of stored list when sharing with new parties
- ✅ Nothing to lose: as long as you still have your wallet you can get everything back
- ⚠️ No true revocation: once you’ve given someone a symmetric key they effectively have encrypt/decrypt capability forever.
- ⚠️ No true expiry: for the same reason as above. Manage scopes carefully, and only share as little of the keyspace as needed for the use case (potentially just single pieces).
For deeper details on limits and lifecycle, refer to the Keysmith README.
// The owner's wallet signs to derive the keyspace's root key.
const const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref = { owner: `0x${string}`owner: const account: {
address: Address;
nonceManager?: NonceManager | undefined;
sign: (parameters: {
hash: Hash;
}) => Promise<Hex>;
signAuthorization: (parameters: AuthorizationRequest) => Promise<SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: SignableMessage;
}) => Promise<Hex>;
signTransaction: <serializer extends SerializeTransactionFn<TransactionSerializable> = SerializeTransactionFn<TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Hex>;
signTypedData: <const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
publicKey: Hex;
source: "privateKey";
type: "local";
}
account.address: `0x${string}`address, keyspace: `0x${string}`keyspace: import KeysmithKeysmith.function newKeyspace(): Hex
export newKeyspace
newKeyspace() }
const { KeyspaceKeys.kk: Uint8Array<ArrayBufferLike>kk: const keyspaceKey: Uint8Array<ArrayBufferLike>keyspaceKey } = await import KeysmithKeysmith.function keyspaceKeys(signer: Keysmith.TypedDataSigner, ref: Keysmith.KeyspaceRef, options?: Keysmith.KeyspaceKeysOptions): Promise<Keysmith.KeyspaceKeys>
export keyspaceKeys
keyspaceKeys(const account: {
address: Address;
nonceManager?: NonceManager | undefined;
sign: (parameters: {
hash: Hash;
}) => Promise<Hex>;
signAuthorization: (parameters: AuthorizationRequest) => Promise<SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: SignableMessage;
}) => Promise<Hex>;
signTransaction: <serializer extends SerializeTransactionFn<TransactionSerializable> = SerializeTransactionFn<TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Hex>;
signTypedData: <const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
publicKey: Hex;
source: "privateKey";
type: "local";
}
account, const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref)
// Scope keys derive from the keyspace key, and nested scopes from their parents.
const const appData: Uint8Array<ArrayBufferLike>appData = import KeysmithKeysmith.function scopeKey(kk: Uint8Array, scope: string): Uint8Array
export scopeKey
scopeKey(const keyspaceKey: Uint8Array<ArrayBufferLike>keyspaceKey, 'app-data')
const const notes: Uint8Array<ArrayBufferLike>notes = import KeysmithKeysmith.function scopeKey(kk: Uint8Array, scope: string): Uint8Array
export scopeKey
scopeKey(const appData: Uint8Array<ArrayBufferLike>appData, 'notes')
// Or, a holder of the keyspaceKey can derive nested scopes in one shot
// const notes = Keysmith.scopeKey(keyspaceKey, 'app-data/notes')
// Each piece gets its own key, from a fresh salt.
const const salt: `0x${string}`salt = import KeysmithKeysmith.function newSalt(): Hex
export newSalt
newSalt()
const const key: Uint8Array<ArrayBufferLike>key = import KeysmithKeysmith.function pieceKey(node: Uint8Array, salt: Hex): Uint8Array
export pieceKey
pieceKey(const notes: Uint8Array<ArrayBufferLike>notes, const salt: `0x${string}`salt)Fine-tuning access to encryption keys
Section titled “Fine-tuning access to encryption keys”Scopes are the intermediate nodes on the tree of keys between the all powerful root keyspace key and single piece keys. They allow delegation of a portion of the entire keyspace to non-owner actors, processes, or agents. A scope key is derived from its parent’s key, so holding a scope means you can access all data and sub-scopes beneath it, but nothing above or beside it, , allowing apps/users to limit access to different session keys/apps.
Just like scopes in other security protocols they are highly flexible and user/application-defined, and can model metaphors like ‘subfolders’, or ‘roles’, or ‘security classification’. Any of these metaphors (and more!) work, but it would be very dangerous to try to mix them! So pick the one that works best for your application and stick with it consistently.
The scope is a user/application-chosen access label (string) such as
agent-memory or super-secret. Keysmith is unopinionated about how scopes
are used, or what’s in them, other than the / separator. The / allows
specification of sub-scopes and finer divisions. Because we’re dealing with
strings and need deterministic encoding each segment must be well-formed Unicode
(no lone surrogates), case-sensitive, non-empty, unpadded and without /.
Examples:
- Clearance levels/MLS:
top-secretcontainstop-secret/secret, which containstop-secret/secret/official. A holder oftop-secret/secretcan grant an agent access totop-secret/secret/officialand they can happily operate in thatofficiallayer without being able to walk back up the tree. - Naive RBAC roles:
super-admincontainssuper-admin/admin, which containssuper-admin/admin/user. A holder ofsuper-admincan grant a session key access tosuper-admin/adminand they can happily manageuserswithout being able to walk back up the tree. - Groups:
agents,teams/a-team,teams/tiger-team. Holders ofagentsare disjoint from the other groups. A holder ofteamsis able to see an delegate data of all teams, while holders ofteams/a-teamandteams/tiger-teamare kept separate and cannot read and write each other’s data.
By convention, filecoin-pin and the Filecoin Pay Console adopt the ‘groups’ model.
This may look counter-intuitive, starting at most privileged and walking outward,
or specifying the whole top-secret/secret/official rather than just official,
but this enables verifiers to walk the entire path to look at and verify
where the grant came from (all the way back to the root). The actual node key
shared is only the last element of the path. So you can verify up, but only
derive and decrypt down.
Recovery metadata
Section titled “Recovery metadata”Nothing is required from the chain or any secrets store to recover keys and data: the application only needs the keyspace metadata when writing data, and the piece’s FEE metadata when reading.
| Location | What it records | Purpose |
|---|---|---|
| Each piece’s envelope | Owner, keyspace, scope, and salt | Identify the key needed to decrypt that piece. |
| Data set metadata | Keyspace and commitment | Identify the derivation tree that the piece’s key lives in. |
Synapse SDK does not derive or add these automatically so the application needs to do this. Generate the FEE metadata entries with pieceMetadata(), and pass the data set entries explicitly through the SDK’s metadata option.
const { KeyspaceKeys.kk: Uint8Array<ArrayBufferLike>kk: const keyspaceKey: Uint8Array<ArrayBufferLike>keyspaceKey, const commitment: stringcommitment, const descriptor: Keysmith.GrantDescriptordescriptor } = await import KeysmithKeysmith.function keyspaceKeys(signer: Keysmith.TypedDataSigner, ref: Keysmith.KeyspaceRef, options?: Keysmith.KeyspaceKeysOptions): Promise<Keysmith.KeyspaceKeys>
export keyspaceKeys
keyspaceKeys(const account: {
address: Address;
nonceManager?: NonceManager | undefined;
sign: (parameters: {
hash: Hash;
}) => Promise<Hex>;
signAuthorization: (parameters: AuthorizationRequest) => Promise<SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: SignableMessage;
}) => Promise<Hex>;
signTransaction: <serializer extends SerializeTransactionFn<TransactionSerializable> = SerializeTransactionFn<TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Hex>;
signTypedData: <const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
publicKey: Hex;
source: "privateKey";
type: "local";
}
account, const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref)
// Envelope entries: pass these to FEE as appMetadata when encrypting the piece.
const const target: Keysmith.WriteTargettarget = import KeysmithKeysmith.function writeTarget(grant: Keysmith.GrantDescriptor, nodeKey: Uint8Array, scope?: string): Keysmith.WriteTarget
export writeTarget
writeTarget(const descriptor: Keysmith.GrantDescriptordescriptor, const keyspaceKey: Uint8Array<ArrayBufferLike>keyspaceKey, 'app-data/notes')
const const salt: `0x${string}`salt = import KeysmithKeysmith.function newSalt(): Hex
export newSalt
newSalt()
const const appMetadata: Keysmith.PieceMetadataappMetadata = import KeysmithKeysmith.function pieceMetadata(ref: Keysmith.KeyspaceRef, options: {
salt: Hex;
scope?: string;
}): Keysmith.PieceMetadata
export pieceMetadata
pieceMetadata(const target: Keysmith.WriteTargettarget.WriteTarget.ref: Keysmith.KeyspaceRefref, { salt: `0x${string}`salt, scope?: string | undefinedscope: const target: Keysmith.WriteTargettarget.WriteTarget.scope?: string | undefinedscope })
// Data set entries: pass these to the SDK when uploading.
const const metadata: {
"foc/ks": `0x${string}`;
"foc/kc": string;
}
metadata = {
[import KeysmithKeysmith.const KEYSPACE_ID_KEY: "foc/ks"
export KEYSPACE_ID_KEY
KEYSPACE_ID_KEY]: const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref.keyspace: `0x${string}`keyspace,
[import KeysmithKeysmith.const COMMITMENT_KEY: "foc/kc"
export COMMITMENT_KEY
COMMITMENT_KEY]: const commitment: stringcommitment,
}
await const synapse: Synapsesynapse.Synapse.storage: StorageManagerstorage.StorageManager.upload(data: UploadPieceStreamingData, options?: StorageManagerUploadOptions): Promise<UploadResult>upload(const encrypted: Uint8Array<ArrayBufferLike>encrypted, { BaseContextOptions.metadata?: Record<string, string> | undefinedmetadata })The FEE envelope lives with the encrypted piece and identifies all that is needed to decrypt it. Scope names, owner addresses, and salts are all stored in clear and visible to anyone. Do not put secrets in these fields.
Recover keys
Section titled “Recover keys”Avoid over-exposure of the wallet
By rooting the derivation tree in the owner wallet, data protected by Keysmith are extremely resilient and recoverable. However this does mean that abuse of the wallet key could lead to compromise of the secrets.
As with any crypto wallet operation, only approve key-derivation requests in applications you trust, and always read the sign message carefully.
To recover a key for a piece you have found, read the keyspace from the piece’s envelope and check that its owner matches the account you expect, then call keyspaceKeys() with that keyspace and owner.
The data set metadata is optional. If you’ve lost it, or it records a different keyspace, recovery still works without the commitment: Keysmith asks the wallet to sign twice instead of once.
Pass the recovered key to keyResolver() when decrypting. Keysmith derives the piece key entirely from that piece’s metadata, so everything you need to recover the data is self-contained.
// The envelope names the keyspace and its owner.
const { const appMetadata: fee.AppMetadata | undefinedappMetadata } = await import feefee.function parse(source: Uint8Array | fee.RandomAccessSource): Promise<fee.EnvelopeInfo>
export parse
parse(const downloaded: Uint8Array<ArrayBufferLike>downloaded)
const const envelope: Keysmith.PieceMetadataenvelope = const appMetadata: fee.AppMetadata | undefinedappMetadata as import KeysmithKeysmith.type PieceMetadata = {
'foc/v': 1;
'foc/own': Address;
'foc/ks': Hex;
'foc/scope'?: string;
'foc/salt': Hex;
}
export PieceMetadata
PieceMetadata
if (const envelope: Keysmith.PieceMetadataenvelope['foc/own'] !== const account: {
address: Address;
nonceManager?: NonceManager | undefined;
sign: (parameters: {
hash: Hash;
}) => Promise<Hex>;
signAuthorization: (parameters: AuthorizationRequest) => Promise<SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: SignableMessage;
}) => Promise<Hex>;
signTransaction: <serializer extends SerializeTransactionFn<TransactionSerializable> = SerializeTransactionFn<TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Hex>;
signTypedData: <const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
publicKey: Hex;
source: "privateKey";
type: "local";
}
account.address: `0x${string}`address.String.toLowerCase(): stringConverts all the alphabetic characters in a string to lowercase.toLowerCase()) {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('This piece belongs to another owner')
}
const const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref = { owner: `0x${string}`owner: const account: {
address: Address;
nonceManager?: NonceManager | undefined;
sign: (parameters: {
hash: Hash;
}) => Promise<Hex>;
signAuthorization: (parameters: AuthorizationRequest) => Promise<SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: SignableMessage;
}) => Promise<Hex>;
signTransaction: <serializer extends SerializeTransactionFn<TransactionSerializable> = SerializeTransactionFn<TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Hex>;
signTypedData: <const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
publicKey: Hex;
source: "privateKey";
type: "local";
}
account.address: `0x${string}`address, keyspace: `0x${string}`keyspace: const envelope: Keysmith.PieceMetadataenvelope['foc/ks'] }
// Use the data set's commitment only if it records the same keyspace.
// Without it, recovery still works: the wallet just needs to sign twice.
const const commitment: string | undefinedcommitment = const dataSetMetadata: Record<string, string> | undefineddataSetMetadata?.[import KeysmithKeysmith.const KEYSPACE_ID_KEY: "foc/ks"
export KEYSPACE_ID_KEY
KEYSPACE_ID_KEY] === const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref.keyspace: `0x${string}`keyspace
? const dataSetMetadata: Record<string, string>dataSetMetadata[import KeysmithKeysmith.const COMMITMENT_KEY: "foc/kc"
export COMMITMENT_KEY
COMMITMENT_KEY]
: var undefinedundefined
const { KeyspaceKeys.kk: Uint8Array<ArrayBufferLike>kk: const keyspaceKey: Uint8Array<ArrayBufferLike>keyspaceKey } = await import KeysmithKeysmith.function keyspaceKeys(signer: Keysmith.TypedDataSigner, ref: Keysmith.KeyspaceRef, options?: Keysmith.KeyspaceKeysOptions): Promise<Keysmith.KeyspaceKeys>
export keyspaceKeys
keyspaceKeys(const account: {
address: Address;
nonceManager?: NonceManager | undefined;
sign: (parameters: {
hash: Hash;
}) => Promise<Hex>;
signAuthorization: (parameters: AuthorizationRequest) => Promise<SignAuthorizationReturnType>;
signMessage: ({ message }: {
message: SignableMessage;
}) => Promise<Hex>;
signTransaction: <serializer extends SerializeTransactionFn<TransactionSerializable> = SerializeTransactionFn<TransactionSerializable>, transaction extends Parameters<serializer>[0] = Parameters<serializer>[0]>(transaction: transaction, options?: {
serializer?: serializer | undefined;
} | undefined) => Promise<Hex>;
signTypedData: <const typedData extends TypedData | Record<string, unknown>, primaryType extends keyof typedData | "EIP712Domain" = keyof typedData>(parameters: TypedDataDefinition<typedData, primaryType>) => Promise<Hex>;
publicKey: Hex;
source: "privateKey";
type: "local";
}
account, const ref: {
owner: `0x${string}`;
keyspace: `0x${string}`;
}
ref, { KeyspaceKeysOptions.commitment?: string | undefinedcommitment })
// Now we have the key, pipe this piece, or any other in the keyspace, through this to decrypt it.
const const decryptor: ReadableWritablePair<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>decryptor = import feefee.function decrypt(key: Uint8Array | fee.KeyResolver): ReadableWritablePair<Uint8Array, Uint8Array>
export decrypt
decrypt(import KeysmithKeysmith.function keyResolver(node: Uint8Array, holding?: Keysmith.Holding | Keysmith.GrantDescriptor): (info: {
appMetadata?: Readonly<Record<string, unknown>>;
}) => Uint8Array
export keyResolver
keyResolver(const keyspaceKey: Uint8Array<ArrayBufferLike>keyspaceKey))