From 31fe13ed165d9eb6cac654b1ff10ec4804fefb9e Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Thu, 24 Sep 2026 18:37:19 +0100 Subject: [PATCH 1/6] feat(keysmith): add per-dataset key derivation package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/keysmith/README.md | 153 +++++++++++++++++++++++ packages/keysmith/package.json | 121 ++++++++++++++++++ packages/keysmith/src/derive.ts | 171 ++++++++++++++++++++++++++ packages/keysmith/src/index.ts | 49 ++++++++ packages/keysmith/src/types.ts | 84 +++++++++++++ packages/keysmith/src/wrap.ts | 98 +++++++++++++++ packages/keysmith/test/derive.test.ts | 149 ++++++++++++++++++++++ packages/keysmith/test/wrap.test.ts | 85 +++++++++++++ packages/keysmith/tsconfig.json | 13 ++ pnpm-lock.yaml | 41 +++++- 10 files changed, 962 insertions(+), 2 deletions(-) create mode 100644 packages/keysmith/README.md create mode 100644 packages/keysmith/package.json create mode 100644 packages/keysmith/src/derive.ts create mode 100644 packages/keysmith/src/index.ts create mode 100644 packages/keysmith/src/types.ts create mode 100644 packages/keysmith/src/wrap.ts create mode 100644 packages/keysmith/test/derive.test.ts create mode 100644 packages/keysmith/test/wrap.test.ts create mode 100644 packages/keysmith/tsconfig.json diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md new file mode 100644 index 000000000..eebe404eb --- /dev/null +++ b/packages/keysmith/README.md @@ -0,0 +1,153 @@ +# @filoz/keysmith + +Deterministic key derivation for encrypted data on Filecoin Onchain Cloud. + +One wallet signature per dataset produces every key beneath it. Keysmith stores nothing, +needs no key server, and puts no key material on chain — so a user who still has their +wallet can always read their data, and a user who loses everything else has lost nothing. + +Keysmith **sources keys**. It does not encrypt: hand the key it gives you to +[FEE](https://github.com/FilOzone/synapse-sdk/pull/967), which produces the envelope, and +upload that through the Synapse SDK like any other bytes. + +```text +your app ──▶ @filoz/keysmith ──key──▶ FEE (envelope) ──bytes──▶ @filoz/synapse-sdk ──▶ FWSS + Curio +``` + +## Install + +```bash +pnpm add @filoz/keysmith +``` + +Requires `viem` 2.x as a peer dependency. Works in Node.js and browsers. + +## The tree + +```text +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 edge is one-way: a piece key says nothing about its neighbours, its scope, its +dataset, or the wallet. Datasets share no common ancestor, so no key anywhere opens more +than one of them. + +## Writing a piece + +```ts +import * as Keysmith from '@filoz/keysmith' + +const ref = { + chainId: 314, + service: FWSS_ADDRESS, + payer: account.address, + clientDataSetId: Keysmith.newClientDataSetId(), // chosen before the dataset exists +} + +const secret = await Keysmith.datasetSecret(account, ref) // one wallet prompt +const dk = Keysmith.datasetKey(secret) + +const salt = Keysmith.newSalt() +const key = Keysmith.pieceKey(dk, salt) +const metadata = Keysmith.pieceMetadata(ref, { salt }) // goes in the FEE envelope + +// Write the commitment into the createDataSet call you were making anyway: +// metadata: { [Keysmith.COMMITMENT_KEY]: Keysmith.commitment(secret) } +``` + +`clientDataSetId` is picked by the client and never reused by FWSS for a payer, so the +first piece can be encrypted **before** the dataset exists on chain — no ordering +constraint on the upload pipeline. + +## Reading a piece + +The envelope carries everything a reader needs, so there is no index to keep in sync: + +```ts +const key = Keysmith.keyForEnvelope(dk, metadata) // walks any scope named in the metadata +``` + +## Sharing + +A grant is a node key wrapped to a recipient's secp256k1 public key — their wallet, or a +session key. Nothing is written on chain, and no piece is rewritten: + +```ts +const grant = await Keysmith.wrapTo(Keysmith.publicKeyOf(theirKey), dk, { + v: 1, + node: 'dataset', + chainId: ref.chainId, + service: ref.service, + payer: ref.payer, + clientDataSetId: String(ref.clientDataSetId), +}) + +// recipient, elsewhere: +const dk = await Keysmith.unwrapWith(myPrivateKey, grant) +``` + +Share a **scope** instead to hand over one section of a dataset: + +```ts +const sk = Keysmith.scopeKey(dk, 'invoices') +const grant = await Keysmith.wrapTo(theirPublicKey, sk, { ...descriptor, node: 'scope:invoices' }) +``` + +The recipient reads `invoices` and nothing else, including pieces written after the grant. +The grant's descriptor is authenticated, so it cannot be relabelled as another dataset or +scope. + +## Recovery + +With the wallet and the chain, and nothing else: + +1. List the payer's datasets from FWSS — each carries its `clientDataSetId`. +2. Re-sign `DatasetKey` and compare `Keysmith.commitment(secret)` against the dataset's + `foc/kc` metadata. A mismatch is a loud error, never a silently wrong key. +3. Derive each piece key from the metadata in its own envelope. + +## API + +| Function | Purpose | +| --- | --- | +| `datasetSecret(signer, ref)` | One signature per dataset; signs twice and compares | +| `datasetKey(secret)` | The key for a whole dataset | +| `scopeKey(dk, name)` | The key for one section of it | +| `pieceKey(node, salt)` | The key for one piece — hand this to FEE | +| `keyForEnvelope(node, metadata, holding?)` | Derive a piece key from whatever node you hold | +| `pieceMetadata(ref, { salt, scope? })` | What the envelope must record | +| `commitment(secret)` / `COMMITMENT_KEY` | Non-secret check value for FWSS metadata | +| `wrapTo(publicKey, key, descriptor)` | Wrap a node key for a recipient | +| `unwrapWith(privateKey, grant)` | Open a grant | +| `publicKeyOf(privateKey)` | Uncompressed secp256k1 public key | +| `newSalt()` / `newClientDataSetId()` | Fresh public identifiers | + +## What to know before you rely on it + +- **A signature is a bearer credential.** Anything that can elicit the `DatasetKey` + signature for a dataset can derive that dataset's keys. Scope is the defence: the + message names one dataset, so one careless approval costs one dataset, not the wallet's + whole history. Day-to-day reads never sign, so a prompt is itself an anomaly. +- **Determinism is checked twice**, because the whole scheme rests on it: `datasetSecret` + signs the same message twice and refuses a signer that disagrees with itself, and the + `foc/kc` commitment catches a wrong wallet at recovery time. +- **`s` is normalised and `v` is dropped**, so the two malleable forms of a signature + yield one key. +- **Sharing cannot be undone.** A grant hands over a key; ending future delivery does not + recall it. To genuinely cut someone off, move that content to a new scope or dataset + and re-encrypt. +- **A scope name travels in the clear** inside the envelope, because the reader needs it + to derive. The bytes stay secret; the label does 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. + +## Development + +```bash +pnpm --filter @filoz/keysmith build +pnpm --filter @filoz/keysmith test # node + browser +``` diff --git a/packages/keysmith/package.json b/packages/keysmith/package.json new file mode 100644 index 000000000..2fe794b70 --- /dev/null +++ b/packages/keysmith/package.json @@ -0,0 +1,121 @@ +{ + "name": "@filoz/keysmith", + "version": "0.0.1", + "description": "Deterministic per-dataset key derivation for Filecoin Onchain Cloud", + "repository": { + "type": "git", + "url": "git+https://github.com/FilOzone/synapse-sdk.git" + }, + "keywords": [ + "filecoin", + "synapse", + "encryption", + "key derivation", + "filecoin onchain cloud", + "web3" + ], + "license": "Apache-2.0 OR MIT", + "bugs": { + "url": "https://github.com/FilOzone/synapse-sdk/issues" + }, + "homepage": "https://github.com/FilOzone/synapse-sdk/tree/master/packages/keysmith", + "type": "module", + "main": "dist/src/index.js", + "module": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "files": [ + "src", + "dist/src", + "dist/src/**/*.d.ts", + "dist/src/**/*.d.ts.map" + ], + "scripts": { + "build": "wireit", + "test": "wireit", + "test:node": "wireit", + "test:browser": "wireit", + "lint": "wireit", + "lint:fix": "biome check --fix ." + }, + "wireit": { + "build": { + "command": "tsc --build --pretty", + "clean": "if-file-deleted", + "files": [ + "src/**/*.ts", + "test/**/*.ts", + "tsconfig.json" + ], + "output": [ + "dist/**" + ] + }, + "test": { + "command": "pnpm run test:node && pnpm run test:browser", + "files": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "output": [], + "dependencies": [ + "lint" + ] + }, + "test:node": { + "command": "playwright-test \"test/**/*.test.ts\" --mode node", + "files": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "output": [] + }, + "test:browser": { + "command": "playwright-test \"test/**/*.test.ts\"", + "files": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "output": [] + }, + "lint": { + "command": "biome check .", + "files": [ + "src/**/*.ts", + "test/**/*.ts", + "../../biome.json" + ], + "output": [], + "dependencies": [ + "build" + ] + } + }, + "dependencies": { + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "peerDependencies": { + "viem": "2.x" + }, + "devDependencies": { + "@biomejs/biome": "catalog:", + "@types/assert": "^1.5.11", + "@types/mocha": "catalog:", + "@types/node": "catalog:", + "assert": "^2.1.0", + "mocha": "catalog:", + "playwright-test": "^14.1.12", + "typescript": "catalog:", + "viem": "catalog:" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/keysmith/src/derive.ts b/packages/keysmith/src/derive.ts new file mode 100644 index 000000000..c83f142f1 --- /dev/null +++ b/packages/keysmith/src/derive.ts @@ -0,0 +1,171 @@ +/** + * Derivation: one wallet signature per dataset, then HKDF all the way down. + * + * ```text + * 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 + * ``` + * + * @module + */ +import { hkdf } from '@noble/hashes/hkdf' +import { sha256 } from '@noble/hashes/sha256' +import type { Hex } from 'viem' +import { bytesToHex, hexToBytes } from 'viem' +import type { DatasetKeyMessage, DatasetRef, Holding, PieceMetadata, TypedDataSigner } from './types.ts' + +const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n +/** Half the secp256k1 group order; an `s` above this is the malleable form. */ +const HALF_N = N / 2n + +/** + * Deliberately carries neither `chainId` nor `verifyingContract`: a redeployed + * contract, or a wallet pointed at another network, must not orphan a + * dataset's key. + */ +export const DOMAIN = { name: 'FOC Encryption', version: '1' } as const + +export const DATASET_KEY_TYPES = { + DatasetKey: [ + { name: 'purpose', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'service', type: 'address' }, + { name: 'payer', type: 'address' }, + { name: 'clientDataSetId', type: 'uint256' }, + { name: 'epoch', type: 'uint32' }, + ], +} as const + +const PURPOSE = 'foc/enc/v1 dataset key' +const INFO = { + dataset: 'foc/acl/dataset/v1', + scope: 'foc/acl/scope/v1', + piece: 'foc/acl/piece/v1', + commitment: 'foc/kc/v1', +} as const + +/** The FWSS data-set metadata key the commitment is written to. */ +export const COMMITMENT_KEY = 'foc/kc' + +const derive = (ikm: Uint8Array, info: string, length = 32): Uint8Array => hkdf(sha256, ikm, undefined, info, length) + +function randomHex(length: number): Hex { + const bytes = new Uint8Array(length) + crypto.getRandomValues(bytes) + return bytesToHex(bytes) +} + +/** A fresh per-piece salt. Public: it only has to travel with the piece. */ +export const newSalt = (): Hex => randomHex(16) + +/** A fresh `clientDataSetId`. FWSS rejects one this payer has used before. */ +export const newClientDataSetId = (): bigint => BigInt(randomHex(8)) + +export function datasetKeyMessage(ref: DatasetRef): DatasetKeyMessage { + return { + purpose: PURPOSE, + chainId: BigInt(ref.chainId), + service: ref.service, + payer: ref.payer, + clientDataSetId: ref.clientDataSetId, + epoch: ref.epoch ?? 0, + } +} + +/** + * Sign for one dataset and return the bytes every key below it derives from. + * + * Signs twice and compares: a signer that does not follow RFC 6979 would + * produce a different key on every call, and this catches it before any data + * depends on it. + * + * @throws If the signer is not deterministic. + */ +export async function datasetSecret(signer: TypedDataSigner, ref: DatasetRef): Promise { + const args = { + domain: DOMAIN, + types: DATASET_KEY_TYPES, + primaryType: 'DatasetKey' as const, + message: datasetKeyMessage(ref), + } + const first = await signer.signTypedData(args) + const second = await signer.signTypedData(args) + if (first !== second) { + throw new Error( + 'Signer is not deterministic (RFC 6979 expected), so it cannot root a dataset key. ' + + 'Signing the same message twice produced different signatures.' + ) + } + return lowSrs(first) +} + +/** + * `r‖s` with `s` normalised to the low half, and `v` dropped. + * + * Both `(r, s)` and `(r, n−s)` are valid signatures, so a signer returning the + * high form would otherwise derive a different key for the same wallet. The + * `v` byte is excluded because wallets report it as 0/1 or 27/28. + */ +export function lowSrs(signature: Hex): Uint8Array { + const raw = hexToBytes(signature) + if (raw.length < 64) { + throw new Error(`Expected a 64- or 65-byte signature, got ${raw.length} bytes`) + } + const s = BigInt(bytesToHex(raw.subarray(32, 64))) + if (s <= HALF_N) { + return raw.subarray(0, 64) + } + const out = new Uint8Array(64) + out.set(raw.subarray(0, 32), 0) + out.set(hexToBytes(`0x${(N - s).toString(16).padStart(64, '0')}`), 32) + return out +} + +/** The key for one dataset. Opens every piece in it, and nothing else. */ +export const datasetKey = (secret: Uint8Array): Uint8Array => derive(secret, INFO.dataset) + +/** + * A non-secret commitment to the dataset key, for FWSS data-set metadata. + * + * Written inside the `createDataSet` call that happens anyway. On recovery the + * payer re-signs and compares, so a wrong wallet or a randomising signer is a + * loud error rather than a silently wrong key. It reveals nothing: it is a + * one-way function of the signature, and the signature is what an attacker + * would need. + */ +export const commitment = (secret: Uint8Array): string => + `v1.${bytesToHex(derive(secret, INFO.commitment, 16)).slice(2)}` + +/** + * The key for one section of a dataset. Opens every piece written into that + * scope, and nothing outside it. The name is an HKDF input, never a secret. + */ +export const scopeKey = (dk: Uint8Array, scope: string): Uint8Array => derive(dk, `${INFO.scope}${scope}`) + +/** The key for one piece. Never reused: FEE requires a fresh key per object. */ +export const pieceKey = (node: Uint8Array, salt: Hex): Uint8Array => derive(node, `${INFO.piece}${salt}`) + +/** What to record in a piece's envelope so that a reader can derive its key. */ +export function pieceMetadata(ref: DatasetRef, options: { salt: Hex; scope?: string }): PieceMetadata { + return { + 'foc/v': 1, + 'foc/cds': `0x${ref.clientDataSetId.toString(16)}`, + 'foc/epoch': ref.epoch ?? 0, + ...(options.scope == null ? {} : { 'foc/scope': options.scope }), + 'foc/salt': options.salt, + } +} + +/** + * Derive a piece's key from whichever node the caller holds. + * + * A dataset-key holder walks down through the scope named in the metadata; a + * scope-key holder is already there. Neither needs records of its own. + */ +export function keyForEnvelope(node: Uint8Array, metadata: PieceMetadata, holding: Holding = 'dataset'): Uint8Array { + const scope = metadata['foc/scope'] + const at = holding === 'dataset' && scope != null ? scopeKey(node, scope) : node + return pieceKey(at, metadata['foc/salt']) +} diff --git a/packages/keysmith/src/index.ts b/packages/keysmith/src/index.ts new file mode 100644 index 000000000..d6a82a93d --- /dev/null +++ b/packages/keysmith/src/index.ts @@ -0,0 +1,49 @@ +/** + * Keysmith — deterministic per-dataset key derivation for Filecoin Onchain Cloud. + * + * One wallet signature per dataset produces every key beneath it. Nothing is + * stored by this layer, nothing goes on chain but a 16-byte commitment, and a + * wallet alone recovers everything. + * + * @example + * ```ts + * import * as Keysmith from '@filoz/keysmith' + * + * const ref = { chainId: 314, service: fwss, payer: account.address, clientDataSetId } + * const secret = await Keysmith.datasetSecret(account, ref) + * const dk = Keysmith.datasetKey(secret) + * + * const salt = Keysmith.newSalt() + * const key = Keysmith.pieceKey(dk, salt) // hand to FEE + * const metadata = Keysmith.pieceMetadata(ref, { salt }) // put in the envelope + * const grant = await Keysmith.wrapTo(theirPublicKey, dk, descriptor) // share it + * ``` + * + * @module + */ +export { + COMMITMENT_KEY, + commitment, + DATASET_KEY_TYPES, + DOMAIN, + datasetKey, + datasetKeyMessage, + datasetSecret, + keyForEnvelope, + lowSrs, + newClientDataSetId, + newSalt, + pieceKey, + pieceMetadata, + scopeKey, +} from './derive.ts' +export type { + DatasetKeyMessage, + DatasetRef, + Grant, + GrantDescriptor, + Holding, + PieceMetadata, + TypedDataSigner, +} from './types.ts' +export { publicKeyOf, unwrapWith, wrapTo } from './wrap.ts' diff --git a/packages/keysmith/src/types.ts b/packages/keysmith/src/types.ts new file mode 100644 index 000000000..66f70bb03 --- /dev/null +++ b/packages/keysmith/src/types.ts @@ -0,0 +1,84 @@ +import type { Address, Hex } from 'viem' + +/** Identifies the dataset a key belongs to. All of it is public. */ +export interface DatasetRef { + chainId: number + /** The FWSS service contract this dataset is created against. */ + service: Address + /** The account that pays for the dataset. */ + payer: Address + /** + * Chosen by the client before the dataset exists on chain, and never reused + * by FWSS for the same payer. Keying on it means the first piece can be + * encrypted before `createDataSet` assigns an id. + */ + clientDataSetId: bigint + /** Reserved for re-keying a dataset in place. Defaults to 0. */ + epoch?: number +} + +/** + * The EIP-712 message a payer signs, once per dataset. + * + * Intersected with an index signature so that viem's generic `signTypedData` + * accepts it, and so any signer shaped like one satisfies {@link TypedDataSigner}. + */ +export type DatasetKeyMessage = { + purpose: string + chainId: bigint + service: Address + payer: Address + clientDataSetId: bigint + epoch: number +} & Record + +/** + * Anything that can sign EIP-712 typed data: a viem Account, a WalletClient, or + * a session key. Keysmith never sees a private key. + */ +export interface TypedDataSigner { + signTypedData: (args: { + domain: { readonly name: string; readonly version: string } + types: Record + primaryType: 'DatasetKey' + message: DatasetKeyMessage + }) => Promise +} + +/** + * What a piece's envelope records so that a reader can derive its key. + * Keysmith produces it; the envelope carries it; nothing else stores it. + */ +export interface PieceMetadata { + 'foc/v': number + 'foc/cds': Hex + 'foc/epoch': number + 'foc/scope'?: string + 'foc/salt': Hex +} + +/** Names the node a grant unlocks. Authenticated, so it cannot be relabelled. */ +export interface GrantDescriptor { + v: 1 + /** `'dataset'`, or `'scope:'`. */ + node: string + chainId: number + service: Address + payer: Address + clientDataSetId: string + [key: string]: unknown +} + +/** A node key wrapped to one recipient. Safe to store or send anywhere. */ +export interface Grant extends GrantDescriptor { + alg: 'ECDH-ES+A256GCM/secp256k1' + /** Ephemeral public key, uncompressed. */ + epk: Hex + /** AES-GCM nonce. */ + iv: Hex + /** Wrapped key: ciphertext ‖ tag. */ + ct: Hex +} + +/** Which node the caller holds when deriving a piece key. */ +export type Holding = 'dataset' | 'scope' diff --git a/packages/keysmith/src/wrap.ts b/packages/keysmith/src/wrap.ts new file mode 100644 index 000000000..2603f04a1 --- /dev/null +++ b/packages/keysmith/src/wrap.ts @@ -0,0 +1,98 @@ +/** + * Sharing: wrap a node key to a recipient's public key. + * + * ECDH-ES over secp256k1 plus AES-256-GCM, so a recipient uses the key they + * already have — a wallet, or a Session Key Registry session key — and nothing + * new has to be published, registered or stored. + * + * @module + */ +import { secp256k1 } from '@noble/curves/secp256k1' +import { hkdf } from '@noble/hashes/hkdf' +import { sha256 } from '@noble/hashes/sha256' +import type { Hex } from 'viem' +import { bytesToHex, hexToBytes } from 'viem' +import type { Grant, GrantDescriptor } from './types.ts' + +const WRAP_INFO = 'foc/acl/wrap/v1' +const ALG = 'ECDH-ES+A256GCM/secp256k1' + +/** The public half of a secp256k1 private key, uncompressed. */ +export const publicKeyOf = (privateKey: Hex): Hex => bytesToHex(secp256k1.getPublicKey(hexToBytes(privateKey), false)) + +/** + * Wrap a node key — a dataset key, or a scope key — to a recipient. + * + * The descriptor is authenticated, so a grant cannot be relabelled as one + * naming a different dataset or scope. The result is inert without the + * recipient's private key, so it can be delivered or stored anywhere. + */ +export async function wrapTo(recipientPublicKey: Hex, key: Uint8Array, descriptor: GrantDescriptor): Promise { + const ephemeral = secp256k1.utils.randomPrivateKey() + const epk = secp256k1.getPublicKey(ephemeral, false) + const kek = wrapKek(sharedSecret(ephemeral, hexToBytes(recipientPublicKey)), epk) + const iv = new Uint8Array(12) + crypto.getRandomValues(iv) + const aesKey = await crypto.subtle.importKey('raw', buffer(kek), 'AES-GCM', false, ['encrypt']) + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: buffer(iv), additionalData: aad(descriptor) }, + aesKey, + buffer(key) + ) + return { + ...descriptor, + alg: ALG, + epk: bytesToHex(epk), + iv: bytesToHex(iv), + ct: bytesToHex(new Uint8Array(ct)), + } +} + +/** + * Open a grant with the recipient's private key. + * + * @throws If the grant was not addressed to this key, or its descriptor was altered. + */ +export async function unwrapWith(privateKey: Hex, grant: Grant): Promise { + const { alg, epk, iv, ct, ...descriptor } = grant + if (alg !== ALG) { + throw new Error(`Unsupported grant algorithm: ${String(alg)}`) + } + const epkBytes = hexToBytes(epk) + const kek = wrapKek(sharedSecret(hexToBytes(privateKey), epkBytes), epkBytes) + const aesKey = await crypto.subtle.importKey('raw', buffer(kek), 'AES-GCM', false, ['decrypt']) + const out = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: buffer(hexToBytes(iv)), + additionalData: aad(descriptor as GrantDescriptor), + }, + aesKey, + buffer(hexToBytes(ct)) + ) + return new Uint8Array(out) +} + +/** X coordinate of the ECDH point, as both halves compute it. */ +const sharedSecret = (privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array => + secp256k1.getSharedSecret(privateKey, publicKey, true).subarray(1) + +function wrapKek(shared: Uint8Array, epk: Uint8Array): Uint8Array { + const ikm = new Uint8Array(shared.length + epk.length) + ikm.set(shared, 0) + ikm.set(epk, shared.length) + return hkdf(sha256, ikm, undefined, WRAP_INFO, 32) +} + +/** Key order must not matter, so the descriptor is serialised with sorted keys. */ +function aad(descriptor: GrantDescriptor): ArrayBuffer { + const sorted: Record = {} + for (const key of Object.keys(descriptor).sort()) { + sorted[key] = descriptor[key] + } + return buffer(new TextEncoder().encode(JSON.stringify(sorted))) +} + +/** WebCrypto takes ArrayBuffer-backed data; noble and viem return views. */ +const buffer = (view: Uint8Array): ArrayBuffer => + view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer diff --git a/packages/keysmith/test/derive.test.ts b/packages/keysmith/test/derive.test.ts new file mode 100644 index 000000000..6d127d374 --- /dev/null +++ b/packages/keysmith/test/derive.test.ts @@ -0,0 +1,149 @@ +import { secp256k1 } from '@noble/curves/secp256k1' +import assert from 'assert' +import { bytesToHex, hexToBytes } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { + commitment, + datasetKey, + datasetKeyMessage, + datasetSecret, + keyForEnvelope, + lowSrs, + newClientDataSetId, + newSalt, + pieceKey, + pieceMetadata, + scopeKey, +} from '../src/derive.ts' +import type { DatasetRef, TypedDataSigner } from '../src/types.ts' + +const account = privateKeyToAccount(generatePrivateKey()) +const ref: DatasetRef = { + chainId: 314159, + service: '0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3', + payer: account.address, + clientDataSetId: 42n, +} + +describe('datasetKeyMessage', () => { + it('names the dataset and defaults the epoch', () => { + const message = datasetKeyMessage(ref) + assert.equal(message.purpose, 'foc/enc/v1 dataset key') + assert.equal(message.chainId, 314159n) + assert.equal(message.clientDataSetId, 42n) + assert.equal(message.epoch, 0) + }) +}) + +describe('datasetSecret', () => { + it('derives the same key for the same wallet and dataset', async () => { + const once = datasetKey(await datasetSecret(account, ref)) + const twice = datasetKey(await datasetSecret(account, ref)) + assert.deepEqual(once, twice) + }) + + it('derives an unrelated key for another dataset', async () => { + const a = datasetKey(await datasetSecret(account, ref)) + const b = datasetKey(await datasetSecret(account, { ...ref, clientDataSetId: 43n })) + assert.notDeepEqual(a, b) + }) + + it('derives an unrelated key for another wallet', async () => { + const other = privateKeyToAccount(generatePrivateKey()) + const a = datasetKey(await datasetSecret(account, ref)) + const b = datasetKey(await datasetSecret(other, { ...ref, payer: other.address })) + assert.notDeepEqual(a, b) + }) + + it('rejects a randomising signer', async () => { + let calls = 0 + const flaky: TypedDataSigner = { + signTypedData: async () => `0x${String(++calls).padStart(130, '0')}` as const, + } + await assert.rejects(datasetSecret(flaky, ref), /not deterministic/) + }) +}) + +describe('lowSrs', () => { + it('drops v and normalises a high-S signature to the low form', () => { + const r = new Uint8Array(32).fill(0xab) + const low = 0x0123456789abcdefn + const high = secp256k1.CURVE.n - low + + const asSig = (s: bigint, v: string) => `${bytesToHex(r)}${s.toString(16).padStart(64, '0')}${v}` as `0x${string}` + + const fromLow = lowSrs(asSig(low, '1b')) + const fromHigh = lowSrs(asSig(high, '1c')) + assert.equal(fromLow.length, 64) + assert.deepEqual(fromLow, fromHigh, 'both malleable forms must yield one key') + }) + + it('rejects a short signature', () => { + assert.throws(() => lowSrs('0xdeadbeef'), /64- or 65-byte/) + }) +}) + +describe('commitment', () => { + it('is stable, public and dataset-specific', async () => { + const secret = await datasetSecret(account, ref) + const other = await datasetSecret(account, { ...ref, clientDataSetId: 43n }) + assert.equal(commitment(secret), commitment(secret)) + assert.notEqual(commitment(secret), commitment(other)) + assert.match(commitment(secret), /^v1\.[0-9a-f]{32}$/) + }) + + it('does not leak the dataset key', async () => { + const secret = await datasetSecret(account, ref) + assert.ok(!commitment(secret).includes(bytesToHex(datasetKey(secret)).slice(2, 34))) + }) +}) + +describe('derivation tree', () => { + it('separates scopes, and pieces within a scope', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const invoices = scopeKey(dk, 'invoices') + const payroll = scopeKey(dk, 'payroll') + assert.notDeepEqual(invoices, payroll) + + const salt = newSalt() + assert.notDeepEqual(pieceKey(invoices, salt), pieceKey(payroll, salt)) + assert.notDeepEqual(pieceKey(dk, salt), pieceKey(dk, newSalt())) + }) + + it('gives a dataset holder and a scope holder the same piece key', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const salt = newSalt() + const metadata = pieceMetadata(ref, { salt, scope: 'invoices' }) + assert.deepEqual(keyForEnvelope(dk, metadata), keyForEnvelope(scopeKey(dk, 'invoices'), metadata, 'scope')) + }) + + it('keeps an unscoped piece out of any scope', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const salt = newSalt() + const metadata = pieceMetadata(ref, { salt }) + assert.equal(metadata['foc/scope'], undefined) + assert.deepEqual(keyForEnvelope(dk, metadata), pieceKey(dk, salt)) + }) +}) + +describe('pieceMetadata', () => { + it('records what a reader needs and nothing secret', () => { + const salt = newSalt() + const metadata = pieceMetadata(ref, { salt, scope: 'invoices' }) + assert.deepEqual(metadata, { + 'foc/v': 1, + 'foc/cds': '0x2a', + 'foc/epoch': 0, + 'foc/scope': 'invoices', + 'foc/salt': salt, + }) + }) +}) + +describe('identifiers', () => { + it('mints distinct salts and client data set ids', () => { + assert.notEqual(newSalt(), newSalt()) + assert.notEqual(newClientDataSetId(), newClientDataSetId()) + assert.equal(hexToBytes(newSalt()).length, 16) + }) +}) diff --git a/packages/keysmith/test/wrap.test.ts b/packages/keysmith/test/wrap.test.ts new file mode 100644 index 000000000..0a98877c7 --- /dev/null +++ b/packages/keysmith/test/wrap.test.ts @@ -0,0 +1,85 @@ +import assert from 'assert' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { datasetKey, datasetSecret, scopeKey } from '../src/derive.ts' +import type { DatasetRef, GrantDescriptor } from '../src/types.ts' +import { publicKeyOf, unwrapWith, wrapTo } from '../src/wrap.ts' + +const account = privateKeyToAccount(generatePrivateKey()) +const ref: DatasetRef = { + chainId: 314159, + service: '0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3', + payer: account.address, + clientDataSetId: 42n, +} +const descriptor: GrantDescriptor = { + v: 1, + node: 'dataset', + chainId: ref.chainId, + service: ref.service, + payer: ref.payer, + clientDataSetId: '42', +} + +describe('wrapTo / unwrapWith', () => { + it('round-trips a dataset key to the named recipient', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const recipient = generatePrivateKey() + + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + assert.equal(grant.alg, 'ECDH-ES+A256GCM/secp256k1') + assert.equal(grant.node, 'dataset') + assert.deepEqual(await unwrapWith(recipient, grant), dk) + }) + + it('survives JSON delivery', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const recipient = generatePrivateKey() + + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + const delivered = JSON.parse(JSON.stringify(grant)) + assert.deepEqual(await unwrapWith(recipient, delivered), dk) + }) + + it('tells a stranger nothing', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const grant = await wrapTo(publicKeyOf(generatePrivateKey()), dk, descriptor) + await assert.rejects(unwrapWith(generatePrivateKey(), grant)) + }) + + it('refuses a grant relabelled as another node', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const recipient = generatePrivateKey() + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + await assert.rejects(unwrapWith(recipient, { ...grant, node: 'scope:payroll' })) + await assert.rejects(unwrapWith(recipient, { ...grant, clientDataSetId: '43' })) + }) + + it('does not care what order the descriptor was built in', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const recipient = generatePrivateKey() + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + const reordered = { + ...grant, + ...(Object.fromEntries(Object.entries(descriptor).reverse()) as GrantDescriptor), + } + assert.deepEqual(await unwrapWith(recipient, reordered), dk) + }) + + it('carries a scope key just as well', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const sk = scopeKey(dk, 'invoices') + const recipient = generatePrivateKey() + + const grant = await wrapTo(publicKeyOf(recipient), sk, { ...descriptor, node: 'scope:invoices' }) + const opened = await unwrapWith(recipient, grant) + assert.deepEqual(opened, sk) + assert.notDeepEqual(opened, dk) + }) + + it('rejects an unknown algorithm', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const recipient = generatePrivateKey() + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + await assert.rejects(unwrapWith(recipient, { ...grant, alg: 'RSA-OAEP' } as never), /Unsupported grant algorithm/) + }) +}) diff --git a/packages/keysmith/tsconfig.json b/packages/keysmith/tsconfig.json new file mode 100644 index 000000000..73c1e4a8e --- /dev/null +++ b/packages/keysmith/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "types": ["mocha", "node"] + }, + "include": ["src", "test"], + "exclude": ["node_modules", "dist"], + "typedocOptions": { + "entryPointStrategy": "resolve", + "entryPoints": ["src/index.ts"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba71ee3d4..5427134c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,43 @@ importers: specifier: 4.0.2 version: 4.0.2 + packages/keysmith: + dependencies: + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + devDependencies: + '@biomejs/biome': + specifier: 'catalog:' + version: 2.5.9 + '@types/assert': + specifier: ^1.5.11 + version: 1.5.11 + '@types/mocha': + specifier: 'catalog:' + version: 10.0.10 + '@types/node': + specifier: 'catalog:' + version: 26.2.0 + assert: + specifier: ^2.1.0 + version: 2.1.0 + mocha: + specifier: 'catalog:' + version: 11.8.0 + playwright-test: + specifier: ^14.1.12 + version: 14.1.15 + typescript: + specifier: 'catalog:' + version: 6.0.3 + viem: + specifier: 'catalog:' + version: 2.55.19(typescript@6.0.3)(zod@4.4.3) + packages/synapse-core: dependencies: dnum: @@ -9350,7 +9387,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -9721,7 +9758,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.13.3 + '@types/node': 26.2.0 '@types/set-cookie-parser@2.4.10': dependencies: From 2dfd5c9f21e4eb2b061e7f42a1bf1523720671cb Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Fri, 25 Sep 2026 18:15:50 +0100 Subject: [PATCH 2/6] docs(keysmith): show reading with a scope or piece key, and add holdingOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/keysmith/README.md | 84 ++++++++++++++++++--------- packages/keysmith/src/derive.ts | 33 ++++++++++- packages/keysmith/src/index.ts | 1 + packages/keysmith/test/derive.test.ts | 32 ++++++++++ 4 files changed, 121 insertions(+), 29 deletions(-) diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md index eebe404eb..b08aabe33 100644 --- a/packages/keysmith/README.md +++ b/packages/keysmith/README.md @@ -1,12 +1,12 @@ # @filoz/keysmith -Deterministic key derivation for encrypted data on Filecoin Onchain Cloud. +Deterministic key derivation for robust, recoverable, transparent encryption of data on Filecoin Onchain Cloud. One wallet signature per dataset produces every key beneath it. Keysmith stores nothing, needs no key server, and puts no key material on chain — so a user who still has their -wallet can always read their data, and a user who loses everything else has lost nothing. +wallet can always read their data. -Keysmith **sources keys**. It does not encrypt: hand the key it gives you to +Keysmith **generates and derives keys**. It does not encrypt: hand the key it gives you to [FEE](https://github.com/FilOzone/synapse-sdk/pull/967), which produces the envelope, and upload that through the Synapse SDK like any other bytes. @@ -22,7 +22,11 @@ pnpm add @filoz/keysmith Requires `viem` 2.x as a peer dependency. Works in Node.js and browsers. -## The tree +## The key derivation tree + +Each FWSS dataset has its own primary decryption key, `DK`. From there are derived intermediate keys for protecting identified sections of the dataset, and then from there are derived keys for individual Pieces. + +In this way, read and write delegations can be made to other entities over the whole dataset; or a fenced-off section of it; or just a single Piece. ```text sig = signTypedData(DatasetKey{chainId, service, payer, clientDataSetId, epoch}) @@ -31,9 +35,8 @@ SK = HKDF(DK, "foc/acl/scope/v1"‖name) one section of it PK = HKDF(node,"foc/acl/piece/v1"‖salt) one piece ``` -Every edge is one-way: a piece key says nothing about its neighbours, its scope, its -dataset, or the wallet. Datasets share no common ancestor, so no key anywhere opens more -than one of them. +Every derivation is one-way: while sharing a scope key allows access to all Pieces under that scope, a Piece key says nothing about its neighbours, its scope, its +dataset, or the wallet. ## Writing a piece @@ -41,13 +44,13 @@ than one of them. import * as Keysmith from '@filoz/keysmith' const ref = { - chainId: 314, - service: FWSS_ADDRESS, + chainId: 314, // mainnet + service: FWSS_ADDRESS, payer: account.address, - clientDataSetId: Keysmith.newClientDataSetId(), // chosen before the dataset exists + clientDataSetId: Keysmith.newClientDataSetId(), // or choose your own } -const secret = await Keysmith.datasetSecret(account, ref) // one wallet prompt +const secret = await Keysmith.datasetSecret(account, ref) // requires payer wallet signature const dk = Keysmith.datasetKey(secret) const salt = Keysmith.newSalt() @@ -58,24 +61,44 @@ const metadata = Keysmith.pieceMetadata(ref, { salt }) // goes in the FEE envelo // metadata: { [Keysmith.COMMITMENT_KEY]: Keysmith.commitment(secret) } ``` -`clientDataSetId` is picked by the client and never reused by FWSS for a payer, so the -first piece can be encrypted **before** the dataset exists on chain — no ordering -constraint on the upload pipeline. - ## Reading a piece -The envelope carries everything a reader needs, so there is no index to keep in sync: +The FEE envelope carries everything a reader needs, so there is no index to keep in sync. +What you pass depends on which key you were given: ```ts -const key = Keysmith.keyForEnvelope(dk, metadata) // walks any scope named in the metadata +// the dataset key: walks down into whatever scope the metadata names +const key = Keysmith.keyForEnvelope(dk, metadata) + +// a scope key: already at the scope, so don't walk into it again +const key = Keysmith.keyForEnvelope(sk, metadata, 'scope') + +// a piece key: nothing to derive — hand it straight to FEE +await decrypt(blob, pk) ``` +`DK` and `SK` are both 32 bytes of HKDF output, so nothing in the envelope says which +one you are holding — you have to tell it. The grant that delivered the key knows, so +let it answer: + +```ts +const key = Keysmith.keyForEnvelope(node, metadata, Keysmith.holdingOf(grant)) +``` + +Getting it wrong derives a plausible-looking key that fails later at the GCM tag, with +nothing to say why. The one case that is always a mistake — a scope key against a piece +written at the root of the dataset — throws immediately instead. + +Two limits follow from one-way derivation, and both surface as decryption failures: a +scope key cannot read pieces outside its scope, and it cannot read pieces at the root. + ## Sharing A grant is a node key wrapped to a recipient's secp256k1 public key — their wallet, or a -session key. Nothing is written on chain, and no piece is rewritten: +session key. Nothing is written on chain, and no piece is rewritten, so as long as the caller has their own copy of DK this can be done completely offline with no auth: ```ts +// Current custodian, sharing out: const grant = await Keysmith.wrapTo(Keysmith.publicKeyOf(theirKey), dk, { v: 1, node: 'dataset', @@ -84,12 +107,14 @@ const grant = await Keysmith.wrapTo(Keysmith.publicKeyOf(theirKey), dk, { payer: ref.payer, clientDataSetId: String(ref.clientDataSetId), }) - +``` +Send the grant through application channels, eg share link, then: +```ts // recipient, elsewhere: const dk = await Keysmith.unwrapWith(myPrivateKey, grant) ``` -Share a **scope** instead to hand over one section of a dataset: +To share a limited **scope** instead of a whole dataset: ```ts const sk = Keysmith.scopeKey(dk, 'invoices') @@ -100,13 +125,15 @@ The recipient reads `invoices` and nothing else, including pieces written after The grant's descriptor is authenticated, so it cannot be relabelled as another dataset or scope. +ℹ️ NOTE: because shared keys are symmetric and deterministic, sharing the key in this way also enables a suitably permissioned delegate to *write* encrypted data to the dataset as well as read. + ## Recovery -With the wallet and the chain, and nothing else: +With the wallet, the chain metadata, and the encrypted bkobs. Nothing else is required, thus there is nothing the user can lose. 1. List the payer's datasets from FWSS — each carries its `clientDataSetId`. 2. Re-sign `DatasetKey` and compare `Keysmith.commitment(secret)` against the dataset's - `foc/kc` metadata. A mismatch is a loud error, never a silently wrong key. + `foc/kc` metadata. 3. Derive each piece key from the metadata in its own envelope. ## API @@ -118,6 +145,7 @@ With the wallet and the chain, and nothing else: | `scopeKey(dk, name)` | The key for one section of it | | `pieceKey(node, salt)` | The key for one piece — hand this to FEE | | `keyForEnvelope(node, metadata, holding?)` | Derive a piece key from whatever node you hold | +| `holdingOf(grant)` | Which level a grant carries, for `keyForEnvelope` | | `pieceMetadata(ref, { salt, scope? })` | What the envelope must record | | `commitment(secret)` / `COMMITMENT_KEY` | Non-secret check value for FWSS metadata | | `wrapTo(publicKey, key, descriptor)` | Wrap a node key for a recipient | @@ -125,25 +153,25 @@ With the wallet and the chain, and nothing else: | `publicKeyOf(privateKey)` | Uncompressed secp256k1 public key | | `newSalt()` / `newClientDataSetId()` | Fresh public identifiers | -## What to know before you rely on it +## Be aware - **A signature is a bearer credential.** Anything that can elicit the `DatasetKey` signature for a dataset can derive that dataset's keys. Scope is the defence: the - message names one dataset, so one careless approval costs one dataset, not the wallet's - whole history. Day-to-day reads never sign, so a prompt is itself an anomaly. + message names one dataset, so one careless approval costs one dataset, not the client's + whole estate. Day-to-day reads never sign, so a prompt is itself an anomaly. - **Determinism is checked twice**, because the whole scheme rests on it: `datasetSecret` signs the same message twice and refuses a signer that disagrees with itself, and the `foc/kc` commitment catches a wrong wallet at recovery time. - **`s` is normalised and `v` is dropped**, so the two malleable forms of a signature yield one key. -- **Sharing cannot be undone.** A grant hands over a key; ending future delivery does not +- **Sharing cannot be undone.** A grant hands over a symmetric key; ending future delivery does not recall it. To genuinely cut someone off, move that content to a new scope or dataset and re-encrypt. - **A scope name travels in the clear** inside the envelope, because the reader needs it to derive. The bytes stay secret; the label does 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. + receive grants this way; they need a signature-derived encryption key, which is not supported in + this package. ## Development diff --git a/packages/keysmith/src/derive.ts b/packages/keysmith/src/derive.ts index c83f142f1..151fdc441 100644 --- a/packages/keysmith/src/derive.ts +++ b/packages/keysmith/src/derive.ts @@ -14,7 +14,14 @@ import { hkdf } from '@noble/hashes/hkdf' import { sha256 } from '@noble/hashes/sha256' import type { Hex } from 'viem' import { bytesToHex, hexToBytes } from 'viem' -import type { DatasetKeyMessage, DatasetRef, Holding, PieceMetadata, TypedDataSigner } from './types.ts' +import type { + DatasetKeyMessage, + DatasetRef, + GrantDescriptor, + Holding, + PieceMetadata, + TypedDataSigner, +} from './types.ts' const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n /** Half the secp256k1 group order; an `s` above this is the malleable form. */ @@ -166,6 +173,30 @@ export function pieceMetadata(ref: DatasetRef, options: { salt: Hex; scope?: str */ export function keyForEnvelope(node: Uint8Array, metadata: PieceMetadata, holding: Holding = 'dataset'): Uint8Array { const scope = metadata['foc/scope'] + if (holding === 'scope' && scope == null) { + throw new Error( + 'This piece is not in a scope, so no scope key opens it. Pieces written at the ' + + 'root of a dataset need the dataset key.' + ) + } const at = holding === 'dataset' && scope != null ? scopeKey(node, scope) : node return pieceKey(at, metadata['foc/salt']) } + +/** + * Which level a grant carries, ready to pass to {@link keyForEnvelope}. + * + * `DK` and `SK` are both 32 bytes of HKDF output, so nothing distinguishes them + * once unwrapped — but the grant that delivered the key says which it is. + * + * @throws If the grant names a node this version does not understand. + */ +export function holdingOf(grant: Pick): Holding { + if (grant.node === 'dataset') { + return 'dataset' + } + if (grant.node.startsWith('scope:')) { + return 'scope' + } + throw new Error(`Unrecognised grant node: ${grant.node}`) +} diff --git a/packages/keysmith/src/index.ts b/packages/keysmith/src/index.ts index d6a82a93d..3a69878e4 100644 --- a/packages/keysmith/src/index.ts +++ b/packages/keysmith/src/index.ts @@ -29,6 +29,7 @@ export { datasetKey, datasetKeyMessage, datasetSecret, + holdingOf, keyForEnvelope, lowSrs, newClientDataSetId, diff --git a/packages/keysmith/test/derive.test.ts b/packages/keysmith/test/derive.test.ts index 6d127d374..2a011d193 100644 --- a/packages/keysmith/test/derive.test.ts +++ b/packages/keysmith/test/derive.test.ts @@ -7,6 +7,7 @@ import { datasetKey, datasetKeyMessage, datasetSecret, + holdingOf, keyForEnvelope, lowSrs, newClientDataSetId, @@ -124,6 +125,37 @@ describe('derivation tree', () => { assert.equal(metadata['foc/scope'], undefined) assert.deepEqual(keyForEnvelope(dk, metadata), pieceKey(dk, salt)) }) + + it('says so when a scope key is used on a piece at the root', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const metadata = pieceMetadata(ref, { salt: newSalt() }) + assert.throws( + () => keyForEnvelope(scopeKey(dk, 'invoices'), metadata, 'scope'), + /not in a scope/, + 'better a clear error than a key that fails later at the AEAD tag' + ) + }) +}) + +describe('holdingOf', () => { + it('reads the level back off a grant', () => { + assert.equal(holdingOf({ node: 'dataset' }), 'dataset') + assert.equal(holdingOf({ node: 'scope:invoices' }), 'scope') + assert.equal(holdingOf({ node: 'scope:with:colons' }), 'scope') + }) + + it('refuses a node it does not understand', () => { + assert.throws(() => holdingOf({ node: 'folder:2026' }), /Unrecognised grant node/) + assert.throws(() => holdingOf({ node: 'piece' }), /Unrecognised grant node/) + }) + + it('round-trips with what a scope grant would carry', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const salt = newSalt() + const metadata = pieceMetadata(ref, { salt, scope: 'invoices' }) + const grant = { node: 'scope:invoices' } + assert.deepEqual(keyForEnvelope(scopeKey(dk, 'invoices'), metadata, holdingOf(grant)), keyForEnvelope(dk, metadata)) + }) }) describe('pieceMetadata', () => { From 7169d23392723ca884d9557b63093c011b20975d Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Fri, 25 Sep 2026 18:17:54 +0100 Subject: [PATCH 3/6] Big README tidy-up --- packages/keysmith/README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md index b08aabe33..68555e7a7 100644 --- a/packages/keysmith/README.md +++ b/packages/keysmith/README.md @@ -78,20 +78,12 @@ await decrypt(blob, pk) ``` `DK` and `SK` are both 32 bytes of HKDF output, so nothing in the envelope says which -one you are holding — you have to tell it. The grant that delivered the key knows, so -let it answer: +one you are holding — you have to tell it. The grant that delivered the key holds this information, so keep the whole grant when receiving a share and then use it in the derivation: ```ts const key = Keysmith.keyForEnvelope(node, metadata, Keysmith.holdingOf(grant)) ``` -Getting it wrong derives a plausible-looking key that fails later at the GCM tag, with -nothing to say why. The one case that is always a mistake — a scope key against a piece -written at the root of the dataset — throws immediately instead. - -Two limits follow from one-way derivation, and both surface as decryption failures: a -scope key cannot read pieces outside its scope, and it cannot read pieces at the root. - ## Sharing A grant is a node key wrapped to a recipient's secp256k1 public key — their wallet, or a From df6f791bd17cea5fb32b19f94941e412ca9a360d Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Fri, 25 Sep 2026 18:18:40 +0100 Subject: [PATCH 4/6] Switch ordering for more understanding --- packages/keysmith/README.md | 46 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md index 68555e7a7..5323a4381 100644 --- a/packages/keysmith/README.md +++ b/packages/keysmith/README.md @@ -61,29 +61,6 @@ const metadata = Keysmith.pieceMetadata(ref, { salt }) // goes in the FEE envelo // metadata: { [Keysmith.COMMITMENT_KEY]: Keysmith.commitment(secret) } ``` -## Reading a piece - -The FEE envelope carries everything a reader needs, so there is no index to keep in sync. -What you pass depends on which key you were given: - -```ts -// the dataset key: walks down into whatever scope the metadata names -const key = Keysmith.keyForEnvelope(dk, metadata) - -// a scope key: already at the scope, so don't walk into it again -const key = Keysmith.keyForEnvelope(sk, metadata, 'scope') - -// a piece key: nothing to derive — hand it straight to FEE -await decrypt(blob, pk) -``` - -`DK` and `SK` are both 32 bytes of HKDF output, so nothing in the envelope says which -one you are holding — you have to tell it. The grant that delivered the key holds this information, so keep the whole grant when receiving a share and then use it in the derivation: - -```ts -const key = Keysmith.keyForEnvelope(node, metadata, Keysmith.holdingOf(grant)) -``` - ## Sharing A grant is a node key wrapped to a recipient's secp256k1 public key — their wallet, or a @@ -119,6 +96,29 @@ scope. ℹ️ NOTE: because shared keys are symmetric and deterministic, sharing the key in this way also enables a suitably permissioned delegate to *write* encrypted data to the dataset as well as read. +## Reading a piece + +The FEE envelope carries everything a reader needs, so there is no index to keep in sync. +What you pass depends on which key you were given: + +```ts +// the dataset key: walks down into whatever scope the metadata names +const key = Keysmith.keyForEnvelope(dk, metadata) + +// a scope key: already at the scope, so don't walk into it again +const key = Keysmith.keyForEnvelope(sk, metadata, 'scope') + +// a piece key: nothing to derive — hand it straight to FEE +await decrypt(blob, pk) +``` + +`DK` and `SK` are both 32 bytes of HKDF output, so nothing in the envelope says which +one you are holding — you have to tell it. The grant that delivered the key holds this information, so keep the whole grant when receiving a share and then use it in the derivation: + +```ts +const key = Keysmith.keyForEnvelope(node, metadata, Keysmith.holdingOf(grant)) +``` + ## Recovery With the wallet, the chain metadata, and the encrypted bkobs. Nothing else is required, thus there is nothing the user can lose. From 17abad621e3c8708ddf8eedd3e1981d82ac59d90 Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Sun, 27 Sep 2026 18:23:03 +0100 Subject: [PATCH 5/6] A bunch of human review tidy-ups and clarifications --- packages/keysmith/README.md | 7 ++- packages/keysmith/src/derive.ts | 44 ++++++++++++-- packages/keysmith/src/index.ts | 16 +----- packages/keysmith/src/types.ts | 83 ++++++++++++--------------- packages/keysmith/src/wrap.ts | 4 +- packages/keysmith/test/derive.test.ts | 46 ++++++++++++++- packages/keysmith/test/wrap.test.ts | 52 +++++++++++++---- 7 files changed, 171 insertions(+), 81 deletions(-) diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md index 5323a4381..a45261092 100644 --- a/packages/keysmith/README.md +++ b/packages/keysmith/README.md @@ -87,9 +87,12 @@ To share a limited **scope** instead of a whole dataset: ```ts const sk = Keysmith.scopeKey(dk, 'invoices') -const grant = await Keysmith.wrapTo(theirPublicKey, sk, { ...descriptor, node: 'scope:invoices' }) +const grant = await Keysmith.wrapTo(theirPublicKey, sk, Keysmith.grantDescriptor(ref, 'scope:invoices')) ``` +Build descriptors with `grantDescriptor()` rather than filling the structure by hand. It is authenticated as part of the grant signature +so consistent canonicalization is important. + The recipient reads `invoices` and nothing else, including pieces written after the grant. The grant's descriptor is authenticated, so it cannot be relabelled as another dataset or scope. @@ -140,6 +143,8 @@ With the wallet, the chain metadata, and the encrypted bkobs. Nothing else is re | `holdingOf(grant)` | Which level a grant carries, for `keyForEnvelope` | | `pieceMetadata(ref, { salt, scope? })` | What the envelope must record | | `commitment(secret)` / `COMMITMENT_KEY` | Non-secret check value for FWSS metadata | +| `grantDescriptor(ref, node)` | Name what a grant unlocks: `'dataset'` or `'scope:'` | +| `grantDescriptor(ref, node)` | Name what a grant unlocks: `'dataset'` or `'scope:'` | | `wrapTo(publicKey, key, descriptor)` | Wrap a node key for a recipient | | `unwrapWith(privateKey, grant)` | Open a grant | | `publicKeyOf(privateKey)` | Uncompressed secp256k1 public key | diff --git a/packages/keysmith/src/derive.ts b/packages/keysmith/src/derive.ts index 151fdc441..75c9e9893 100644 --- a/packages/keysmith/src/derive.ts +++ b/packages/keysmith/src/derive.ts @@ -11,13 +11,14 @@ * @module */ import { hkdf } from '@noble/hashes/hkdf' -import { sha256 } from '@noble/hashes/sha256' +import { sha256 } from '@noble/hashes/sha2' import type { Hex } from 'viem' import { bytesToHex, hexToBytes } from 'viem' import type { DatasetKeyMessage, DatasetRef, GrantDescriptor, + GrantNode, Holding, PieceMetadata, TypedDataSigner, @@ -28,9 +29,17 @@ const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n const HALF_N = N / 2n /** - * Deliberately carries neither `chainId` nor `verifyingContract`: a redeployed - * contract, or a wallet pointed at another network, must not orphan a - * dataset's key. + * Note: Deliberately carries neither `chainId` nor `verifyingContract`: + * a redeployed contract, or a wallet pointed at another network, must + * not orphan a dataset's key. The binding those fields would give is not + * lost — `chainId` and `service` are fields of the message below, where + * they are signed just the same. + * + * **Never add a field here, and never zero-fill one.** The separator is + * hashed over the fields that are present, so `{name, version}` and + * `{name, version, chainId: 0, verifyingContract: 0x00…}` are different + * domains, different signatures, and different keys. Any change orphans + * every key ever derived, with no recovery path! */ export const DOMAIN = { name: 'FOC Encryption', version: '1' } as const @@ -154,17 +163,42 @@ export const scopeKey = (dk: Uint8Array, scope: string): Uint8Array => derive(dk /** The key for one piece. Never reused: FEE requires a fresh key per object. */ export const pieceKey = (node: Uint8Array, salt: Hex): Uint8Array => derive(node, `${INFO.piece}${salt}`) +/** + * Deterministic serialization for IDs. + * Essential because on-chain/off-chain values are used in signatures and + * must not drift or vary in rendering. + */ +const clientDataSetIdHex = (id: bigint): Hex => `0x${id.toString(16)}` + /** What to record in a piece's envelope so that a reader can derive its key. */ export function pieceMetadata(ref: DatasetRef, options: { salt: Hex; scope?: string }): PieceMetadata { return { 'foc/v': 1, - 'foc/cds': `0x${ref.clientDataSetId.toString(16)}`, + 'foc/cds': clientDataSetIdHex(ref.clientDataSetId), 'foc/epoch': ref.epoch ?? 0, ...(options.scope == null ? {} : { 'foc/scope': options.scope }), 'foc/salt': options.salt, } } +/** + * The descriptor naming what a grant unlocks, ready for `wrapTo()`. + * + * Build descriptors with `grantDescriptor()` rather than filling the + * structure by hand. It is authenticated as part of the grant signature + * so consistent canonicalization is important. + */ +export function grantDescriptor(ref: DatasetRef, node: GrantNode): GrantDescriptor { + return { + v: 1, + node, + chainId: ref.chainId, + service: ref.service, + payer: ref.payer, + clientDataSetId: clientDataSetIdHex(ref.clientDataSetId), + } +} + /** * Derive a piece's key from whichever node the caller holds. * diff --git a/packages/keysmith/src/index.ts b/packages/keysmith/src/index.ts index 3a69878e4..aa2552009 100644 --- a/packages/keysmith/src/index.ts +++ b/packages/keysmith/src/index.ts @@ -5,20 +5,6 @@ * stored by this layer, nothing goes on chain but a 16-byte commitment, and a * wallet alone recovers everything. * - * @example - * ```ts - * import * as Keysmith from '@filoz/keysmith' - * - * const ref = { chainId: 314, service: fwss, payer: account.address, clientDataSetId } - * const secret = await Keysmith.datasetSecret(account, ref) - * const dk = Keysmith.datasetKey(secret) - * - * const salt = Keysmith.newSalt() - * const key = Keysmith.pieceKey(dk, salt) // hand to FEE - * const metadata = Keysmith.pieceMetadata(ref, { salt }) // put in the envelope - * const grant = await Keysmith.wrapTo(theirPublicKey, dk, descriptor) // share it - * ``` - * * @module */ export { @@ -29,6 +15,7 @@ export { datasetKey, datasetKeyMessage, datasetSecret, + grantDescriptor, holdingOf, keyForEnvelope, lowSrs, @@ -43,6 +30,7 @@ export type { DatasetRef, Grant, GrantDescriptor, + GrantNode, Holding, PieceMetadata, TypedDataSigner, diff --git a/packages/keysmith/src/types.ts b/packages/keysmith/src/types.ts index 66f70bb03..73e236807 100644 --- a/packages/keysmith/src/types.ts +++ b/packages/keysmith/src/types.ts @@ -1,40 +1,27 @@ import type { Address, Hex } from 'viem' -/** Identifies the dataset a key belongs to. All of it is public. */ +/** Identifies the dataset a key belongs to */ export interface DatasetRef { - chainId: number - /** The FWSS service contract this dataset is created against. */ - service: Address - /** The account that pays for the dataset. */ - payer: Address - /** - * Chosen by the client before the dataset exists on chain, and never reused - * by FWSS for the same payer. Keying on it means the first piece can be - * encrypted before `createDataSet` assigns an id. - */ - clientDataSetId: bigint - /** Reserved for re-keying a dataset in place. Defaults to 0. */ - epoch?: number + chainId: number // eg 314 for Filecoin mainnet + service: Address // FWSS contract address on @chainId@ + payer: Address // Dataset payer, as a proxy for owner + clientDataSetId: bigint // Client-chosen dataset ID + epoch?: number // For key rotation/re-encrypt in place } -/** - * The EIP-712 message a payer signs, once per dataset. - * - * Intersected with an index signature so that viem's generic `signTypedData` - * accepts it, and so any signer shaped like one satisfies {@link TypedDataSigner}. - */ +/** The EIP-712 message a payer signs to start the derivation tree */ export type DatasetKeyMessage = { purpose: string - chainId: bigint - service: Address - payer: Address - clientDataSetId: bigint - epoch: number + chainId: bigint // eg 314 for Filecoin mainnet + service: Address // FWSS contract address on @chainId@ + payer: Address // Dataset payer, as a proxy for owner + clientDataSetId: bigint // Client-chosen dataset ID + epoch: number // For key rotation/re-encrypt in place } & Record /** * Anything that can sign EIP-712 typed data: a viem Account, a WalletClient, or - * a session key. Keysmith never sees a private key. + * a session key. Keysmith itself never sees a private key. */ export interface TypedDataSigner { signTypedData: (args: { @@ -45,39 +32,41 @@ export interface TypedDataSigner { }) => Promise } -/** - * What a piece's envelope records so that a reader can derive its key. - * Keysmith produces it; the envelope carries it; nothing else stores it. - */ +/** FEE envelope entries to enable key derivation/recovery */ export interface PieceMetadata { 'foc/v': number 'foc/cds': Hex - 'foc/epoch': number - 'foc/scope'?: string - 'foc/salt': Hex + 'foc/epoch': number // Key rotation counter + 'foc/scope'?: string // Creates sharable 'subfolders' within a dataset + 'foc/salt': Hex // Because there is such a thing as _too much_ determinism :-) } -/** Names the node a grant unlocks. Authenticated, so it cannot be relabelled. */ +/** What a grant may unlock: the whole dataset, or one scope of it. */ +export type GrantNode = 'dataset' | `scope:${string}` + +/** + * Names the node a grant unlocks. Authenticated, so it cannot be relabelled. + * + * `node` is a plain string rather than {@link GrantNode} because grants arrive + * as JSON from elsewhere and must be validated at runtime, not assumed. Build + * one with `grantDescriptor()` and the narrow type applies. + */ export interface GrantDescriptor { v: 1 - /** `'dataset'`, or `'scope:'`. */ - node: string - chainId: number - service: Address - payer: Address - clientDataSetId: string + node: string // 'dataset', or 'scope:'. + chainId: number // eg 314 for Filecoin mainnet + service: Address // FWSS contract address on @chainId@ + payer: Address // Dataset payer, as a proxy for owner + clientDataSetId: Hex // Client-chosen dataset ID, spelled as in `foc/cds` [key: string]: unknown } /** A node key wrapped to one recipient. Safe to store or send anywhere. */ export interface Grant extends GrantDescriptor { - alg: 'ECDH-ES+A256GCM/secp256k1' - /** Ephemeral public key, uncompressed. */ - epk: Hex - /** AES-GCM nonce. */ - iv: Hex - /** Wrapped key: ciphertext ‖ tag. */ - ct: Hex + alg: 'ECDH-ES+A256GCM/secp256k1' // Agility TBD + epk: Hex // Ephemeral public key, uncompressed + iv: Hex //AES-GCM nonce + ct: Hex // Wrapped key: ciphertext ‖ tag } /** Which node the caller holds when deriving a piece key. */ diff --git a/packages/keysmith/src/wrap.ts b/packages/keysmith/src/wrap.ts index 2603f04a1..f0ea62c16 100644 --- a/packages/keysmith/src/wrap.ts +++ b/packages/keysmith/src/wrap.ts @@ -9,7 +9,7 @@ */ import { secp256k1 } from '@noble/curves/secp256k1' import { hkdf } from '@noble/hashes/hkdf' -import { sha256 } from '@noble/hashes/sha256' +import { sha256 } from '@noble/hashes/sha2' import type { Hex } from 'viem' import { bytesToHex, hexToBytes } from 'viem' import type { Grant, GrantDescriptor } from './types.ts' @@ -28,7 +28,7 @@ export const publicKeyOf = (privateKey: Hex): Hex => bytesToHex(secp256k1.getPub * recipient's private key, so it can be delivered or stored anywhere. */ export async function wrapTo(recipientPublicKey: Hex, key: Uint8Array, descriptor: GrantDescriptor): Promise { - const ephemeral = secp256k1.utils.randomPrivateKey() + const ephemeral = secp256k1.utils.randomSecretKey() const epk = secp256k1.getPublicKey(ephemeral, false) const kek = wrapKek(sharedSecret(ephemeral, hexToBytes(recipientPublicKey)), epk) const iv = new Uint8Array(12) diff --git a/packages/keysmith/test/derive.test.ts b/packages/keysmith/test/derive.test.ts index 2a011d193..211fb84ef 100644 --- a/packages/keysmith/test/derive.test.ts +++ b/packages/keysmith/test/derive.test.ts @@ -1,9 +1,10 @@ import { secp256k1 } from '@noble/curves/secp256k1' import assert from 'assert' -import { bytesToHex, hexToBytes } from 'viem' +import { bytesToHex, hashDomain, hexToBytes } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { commitment, + DOMAIN, datasetKey, datasetKeyMessage, datasetSecret, @@ -179,3 +180,46 @@ describe('identifiers', () => { assert.equal(hexToBytes(newSalt()).length, 16) }) }) + +describe('EIP-712 domain', () => { + const EIP712Domain = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + ] as const + const separator = () => hashDomain({ domain: DOMAIN, types: { EIP712Domain } }) + + it('is pinned — changing it orphans every key ever derived', () => { + assert.deepEqual(DOMAIN, { name: 'FOC Encryption', version: '1' }) + assert.equal(separator(), '0x547bad88c79d4d4f2d88253da28d0b6dd21bb4aa20bbfa20d2f05b55335697b2') + }) + + it('omits chainId and verifyingContract, and absent is not zero', () => { + assert.equal('chainId' in DOMAIN, false) + assert.equal('verifyingContract' in DOMAIN, false) + + const zeroed = hashDomain({ + domain: { ...DOMAIN, chainId: 0n, verifyingContract: '0x0000000000000000000000000000000000000000' }, + types: { + EIP712Domain: [ + ...EIP712Domain, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ] as const, + }, + }) + assert.notEqual(zeroed, separator(), 'zero-filling the fields is a different domain, so a different key') + }) + + it('binds the chain and the service through the message instead', async () => { + const here = datasetKey(await datasetSecret(account, ref)) + const anotherChain = datasetKey(await datasetSecret(account, { ...ref, chainId: 314 })) + const anotherService = datasetKey( + await datasetSecret(account, { + ...ref, + service: '0x00000000000000000000000000000000000000ff', + }) + ) + assert.notDeepEqual(here, anotherChain) + assert.notDeepEqual(here, anotherService) + }) +}) diff --git a/packages/keysmith/test/wrap.test.ts b/packages/keysmith/test/wrap.test.ts index 0a98877c7..0ab867421 100644 --- a/packages/keysmith/test/wrap.test.ts +++ b/packages/keysmith/test/wrap.test.ts @@ -1,6 +1,14 @@ import assert from 'assert' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' -import { datasetKey, datasetSecret, scopeKey } from '../src/derive.ts' +import { + datasetKey, + datasetSecret, + grantDescriptor, + holdingOf, + newSalt, + pieceMetadata, + scopeKey, +} from '../src/derive.ts' import type { DatasetRef, GrantDescriptor } from '../src/types.ts' import { publicKeyOf, unwrapWith, wrapTo } from '../src/wrap.ts' @@ -11,14 +19,7 @@ const ref: DatasetRef = { payer: account.address, clientDataSetId: 42n, } -const descriptor: GrantDescriptor = { - v: 1, - node: 'dataset', - chainId: ref.chainId, - service: ref.service, - payer: ref.payer, - clientDataSetId: '42', -} +const descriptor: GrantDescriptor = grantDescriptor(ref, 'dataset') describe('wrapTo / unwrapWith', () => { it('round-trips a dataset key to the named recipient', async () => { @@ -51,7 +52,7 @@ describe('wrapTo / unwrapWith', () => { const recipient = generatePrivateKey() const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) await assert.rejects(unwrapWith(recipient, { ...grant, node: 'scope:payroll' })) - await assert.rejects(unwrapWith(recipient, { ...grant, clientDataSetId: '43' })) + await assert.rejects(unwrapWith(recipient, { ...grant, clientDataSetId: '0x2b' })) }) it('does not care what order the descriptor was built in', async () => { @@ -70,7 +71,7 @@ describe('wrapTo / unwrapWith', () => { const sk = scopeKey(dk, 'invoices') const recipient = generatePrivateKey() - const grant = await wrapTo(publicKeyOf(recipient), sk, { ...descriptor, node: 'scope:invoices' }) + const grant = await wrapTo(publicKeyOf(recipient), sk, grantDescriptor(ref, 'scope:invoices')) const opened = await unwrapWith(recipient, grant) assert.deepEqual(opened, sk) assert.notDeepEqual(opened, dk) @@ -83,3 +84,32 @@ describe('wrapTo / unwrapWith', () => { await assert.rejects(unwrapWith(recipient, { ...grant, alg: 'RSA-OAEP' } as never), /Unsupported grant algorithm/) }) }) + +describe('grantDescriptor', () => { + it('spells the id exactly as the envelope does', () => { + const descriptor = grantDescriptor(ref, 'dataset') + assert.equal(descriptor.clientDataSetId, '0x2a') + assert.equal(descriptor.clientDataSetId, pieceMetadata(ref, { salt: newSalt() })['foc/cds']) + }) + + it('carries what the descriptor is for, and nothing secret', () => { + assert.deepEqual(grantDescriptor(ref, 'scope:invoices'), { + v: 1, + node: 'scope:invoices', + chainId: ref.chainId, + service: ref.service, + payer: ref.payer, + clientDataSetId: '0x2a', + }) + }) + + it('round-trips through a wrap, and holdingOf reads the level back', async () => { + const dk = datasetKey(await datasetSecret(account, ref)) + const recipient = generatePrivateKey() + const descriptor = grantDescriptor(ref, 'scope:invoices') + const grant = await wrapTo(publicKeyOf(recipient), scopeKey(dk, 'invoices'), descriptor) + + assert.deepEqual(await unwrapWith(recipient, grant), scopeKey(dk, 'invoices')) + assert.equal(holdingOf(grant), 'scope') + }) +}) From 0eb7e5e8dbe9f7a4a9d06a3bcc1e417e5416268b Mon Sep 17 00:00:00 2001 From: JAG-UK Date: Mon, 28 Sep 2026 16:25:29 +0100 Subject: [PATCH 6/6] Claude Fable final pass review. --- packages/keysmith/README.md | 107 +++++++---- packages/keysmith/src/derive.ts | 135 ++++++++----- packages/keysmith/src/index.ts | 20 +- packages/keysmith/src/types.ts | 27 ++- packages/keysmith/src/wrap.ts | 108 ++++++++--- packages/keysmith/test/derive.test.ts | 263 +++++++++++++++++--------- packages/keysmith/test/wrap.test.ts | 149 +++++++++------ 7 files changed, 555 insertions(+), 254 deletions(-) diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md index a45261092..899c2dfd0 100644 --- a/packages/keysmith/README.md +++ b/packages/keysmith/README.md @@ -1,6 +1,6 @@ # @filoz/keysmith -Deterministic key derivation for robust, recoverable, transparent encryption of data on Filecoin Onchain Cloud. +Deterministic key derivation for robust, recoverable, transparent encryption of data on Filecoin Onchain Cloud. One wallet signature per dataset produces every key beneath it. Keysmith stores nothing, needs no key server, and puts no key material on chain — so a user who still has their @@ -20,7 +20,8 @@ your app ──▶ @filoz/keysmith ──key──▶ FEE (envelope) ──bytes pnpm add @filoz/keysmith ``` -Requires `viem` 2.x as a peer dependency. Works in Node.js and browsers. +Requires `viem` 2.x as a peer dependency. Works in Node.js and browsers; in a browser it +needs a secure context (HTTPS or localhost) for WebCrypto. ## The key derivation tree @@ -36,7 +37,7 @@ PK = HKDF(node,"foc/acl/piece/v1"‖salt) one piece ``` Every derivation is one-way: while sharing a scope key allows access to all Pieces under that scope, a Piece key says nothing about its neighbours, its scope, its -dataset, or the wallet. +dataset, or the wallet. ## Writing a piece @@ -50,34 +51,40 @@ const ref = { clientDataSetId: Keysmith.newClientDataSetId(), // or choose your own } -const secret = await Keysmith.datasetSecret(account, ref) // requires payer wallet signature -const dk = Keysmith.datasetKey(secret) +// One wallet signature. The signature itself stays inside the call; you get +// the dataset key and a public commitment, and have nothing else to guard. +const { dk, commitment } = await Keysmith.datasetKeys(account, ref) const salt = Keysmith.newSalt() const key = Keysmith.pieceKey(dk, salt) const metadata = Keysmith.pieceMetadata(ref, { salt }) // goes in the FEE envelope // Write the commitment into the createDataSet call you were making anyway: -// metadata: { [Keysmith.COMMITMENT_KEY]: Keysmith.commitment(secret) } +// metadata: { [Keysmith.COMMITMENT_KEY]: commitment } ``` +The first time a signer is used, `datasetKeys` signs twice and compares, refusing a +signer that does not sign deterministically. That costs one extra wallet prompt, once; +later calls sign once. See `DatasetKeysOptions` to force or skip the check. + ## Sharing -A grant is a node key wrapped to a recipient's secp256k1 public key — their wallet, or a -session key. Nothing is written on chain, and no piece is rewritten, so as long as the caller has their own copy of DK this can be done completely offline with no auth: +A grant is a node key wrapped to a recipient's public key. Nothing is written on chain and +no piece is rewritten: anyone holding `DK` can issue one, offline. + +The recipient needs a secp256k1 **private key in hand** to open it — a session key, or +any service or agent holding a local key. A browser wallet will sign for you but will not +hand over its key, so a MetaMask or Ledger user cannot unwrap a grant with this API; that +needs a signature-derived encryption key, which is not in this package yet. ```ts // Current custodian, sharing out: -const grant = await Keysmith.wrapTo(Keysmith.publicKeyOf(theirKey), dk, { - v: 1, - node: 'dataset', - chainId: ref.chainId, - service: ref.service, - payer: ref.payer, - clientDataSetId: String(ref.clientDataSetId), -}) +const descriptor = Keysmith.grantDescriptor(ref, 'dataset') +const grant = await Keysmith.wrapTo(Keysmith.publicKeyOf(theirKey), dk, descriptor) ``` + Send the grant through application channels, eg share link, then: + ```ts // recipient, elsewhere: const dk = await Keysmith.unwrapWith(myPrivateKey, grant) @@ -90,13 +97,23 @@ const sk = Keysmith.scopeKey(dk, 'invoices') const grant = await Keysmith.wrapTo(theirPublicKey, sk, Keysmith.grantDescriptor(ref, 'scope:invoices')) ``` -Build descriptors with `grantDescriptor()` rather than filling the structure by hand. It is authenticated as part of the grant signature -so consistent canonicalization is important. +Build descriptors with `grantDescriptor()` rather than filling the structure by hand. The +descriptor is the grant's authenticated data, compared byte for byte, so it lowercases +addresses and spells the id exactly as the envelope does. Exactly those six fields are +covered: anything else carried alongside a grant is informational and unauthenticated. The recipient reads `invoices` and nothing else, including pieces written after the grant. -The grant's descriptor is authenticated, so it cannot be relabelled as another dataset or +The descriptor is authenticated, so a grant cannot be relabelled as another dataset or scope. +A grant proves nothing about **who sent it**. Anyone can address one to anyone, with any +key inside; a successful unwrap only shows the descriptor arrived intact. A forged grant +cannot open existing data — the forger does not have `DK` — but a delegate who *writes* +with a key it was handed would be encrypting under a key someone else chose. Before +writing with a key from a grant, open a known piece with it. `publicKeyOf` returns a +key-agreement key derived from the private key, not the signing key itself, so publish +that: one credential, two algorithms, two keys. + ℹ️ NOTE: because shared keys are symmetric and deterministic, sharing the key in this way also enables a suitably permissioned delegate to *write* encrypted data to the dataset as well as read. ## Reading a piece @@ -124,30 +141,48 @@ const key = Keysmith.keyForEnvelope(node, metadata, Keysmith.holdingOf(grant)) ## Recovery -With the wallet, the chain metadata, and the encrypted bkobs. Nothing else is required, thus there is nothing the user can lose. +With the wallet, the chain metadata, and the encrypted blobs. Nothing else is required, thus there is nothing the user can lose. 1. List the payer's datasets from FWSS — each carries its `clientDataSetId`. -2. Re-sign `DatasetKey` and compare `Keysmith.commitment(secret)` against the dataset's - `foc/kc` metadata. +2. Call `datasetKeys` again and compare its `commitment` against the dataset's `foc/kc` + metadata, before decrypting anything. 3. Derive each piece key from the metadata in its own envelope. +## Canonical forms + +Two things are compared byte for byte — HKDF inputs, and a grant's authenticated fields — +so every value has exactly one spelling, produced by the library rather than the caller: + +| Value | Canonical form | +| --- | --- | +| scope name | Unicode NFC; non-empty; no leading or trailing whitespace; case is significant | +| piece salt | lowercase hex | +| `clientDataSetId` | minimal lowercase hex — `0x2a`, never `0x002A` | +| addresses in a grant | lowercase | +| `chainId` and `epoch` in a grant | non-negative integers; a relayed `"314"` is accepted | +| grant `node` | `dataset`, or `scope:` plus a canonical scope name | + +`grantDescriptor()` and `pieceMetadata()` emit these forms, and `wrapTo`, `unwrapWith`, +`scopeKey` and `keyForEnvelope` re-canonicalise whatever they are given, so a hand-built +descriptor or a mangling relay cannot split a grant. A grant also names its `epoch`, so +keys for different re-keyings of one dataset are never confused for each other. + ## API | Function | Purpose | | --- | --- | -| `datasetSecret(signer, ref)` | One signature per dataset; signs twice and compares | -| `datasetKey(secret)` | The key for a whole dataset | +| `datasetKeys(signer, ref, options?)` | One signature per dataset → `{ dk, commitment }` | | `scopeKey(dk, name)` | The key for one section of it | +| `scopeName(name)` | A scope name in canonical form, or a thrown error | | `pieceKey(node, salt)` | The key for one piece — hand this to FEE | | `keyForEnvelope(node, metadata, holding?)` | Derive a piece key from whatever node you hold | | `holdingOf(grant)` | Which level a grant carries, for `keyForEnvelope` | | `pieceMetadata(ref, { salt, scope? })` | What the envelope must record | -| `commitment(secret)` / `COMMITMENT_KEY` | Non-secret check value for FWSS metadata | -| `grantDescriptor(ref, node)` | Name what a grant unlocks: `'dataset'` or `'scope:'` | +| `COMMITMENT_KEY` | The FWSS metadata key the commitment is written under | | `grantDescriptor(ref, node)` | Name what a grant unlocks: `'dataset'` or `'scope:'` | | `wrapTo(publicKey, key, descriptor)` | Wrap a node key for a recipient | | `unwrapWith(privateKey, grant)` | Open a grant | -| `publicKeyOf(privateKey)` | Uncompressed secp256k1 public key | +| `publicKeyOf(privateKey)` | The key-agreement key to publish, derived from a private key | | `newSalt()` / `newClientDataSetId()` | Fresh public identifiers | ## Be aware @@ -156,9 +191,12 @@ With the wallet, the chain metadata, and the encrypted bkobs. Nothing else is re signature for a dataset can derive that dataset's keys. Scope is the defence: the message names one dataset, so one careless approval costs one dataset, not the client's whole estate. Day-to-day reads never sign, so a prompt is itself an anomaly. -- **Determinism is checked twice**, because the whole scheme rests on it: `datasetSecret` - signs the same message twice and refuses a signer that disagrees with itself, and the - `foc/kc` commitment catches a wrong wallet at recovery time. +- **Determinism is checked twice**, because the whole scheme rests on it: on a signer's + first use `datasetKeys` signs the same message twice and refuses a signer that disagrees + with itself, and the `foc/kc` commitment catches a wrong wallet at recovery time. +- **Only a plain ECDSA signature is accepted.** A contract account or smart wallet answers + `signTypedData` with an ABI-encoded blob whose leading bytes are structure, not secret; + deriving from that would mint a guessable key, so it is refused rather than used. - **`s` is normalised and `v` is dropped**, so the two malleable forms of a signature yield one key. - **Sharing cannot be undone.** A grant hands over a symmetric key; ending future delivery does not @@ -166,9 +204,12 @@ With the wallet, the chain metadata, and the encrypted bkobs. Nothing else is re and re-encrypt. - **A scope name travels in the clear** inside the envelope, because the reader needs it to derive. The bytes stay secret; the label does 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 supported in - this package. +- **Receiving a grant needs a private key in hand.** Session keys and services can + unwrap; browser wallets, hardware wallets and contract accounts cannot, because none of + them expose a key to do ECDH with. They need a signature-derived encryption key, which + is not in this package yet. +- **Key material cannot be wiped.** JavaScript offers no way to zeroise a `Uint8Array` + reliably, so treat any process holding `DK` as holding it for its lifetime. ## Development diff --git a/packages/keysmith/src/derive.ts b/packages/keysmith/src/derive.ts index 75c9e9893..dcfae8619 100644 --- a/packages/keysmith/src/derive.ts +++ b/packages/keysmith/src/derive.ts @@ -12,10 +12,12 @@ */ import { hkdf } from '@noble/hashes/hkdf' import { sha256 } from '@noble/hashes/sha2' -import type { Hex } from 'viem' +import type { Address, Hex } from 'viem' import { bytesToHex, hexToBytes } from 'viem' import type { DatasetKeyMessage, + DatasetKeys, + DatasetKeysOptions, DatasetRef, GrantDescriptor, GrantNode, @@ -39,7 +41,7 @@ const HALF_N = N / 2n * hashed over the fields that are present, so `{name, version}` and * `{name, version, chainId: 0, verifyingContract: 0x00…}` are different * domains, different signatures, and different keys. Any change orphans - * every key ever derived, with no recovery path! + * every key ever derived, with no migration path. Pinned by a golden test. */ export const DOMAIN = { name: 'FOC Encryption', version: '1' } as const @@ -90,16 +92,27 @@ export function datasetKeyMessage(ref: DatasetRef): DatasetKeyMessage { } } +/** Signers already shown to sign deterministically, so later calls cost one prompt. */ +const verifiedSigners = new WeakSet() + /** - * Sign for one dataset and return the bytes every key below it derives from. + * Sign for one dataset and derive its key. + * + * The signature is the root secret, and it never leaves this function: the + * caller gets the dataset key and the public commitment, and has nothing else + * to guard. * - * Signs twice and compares: a signer that does not follow RFC 6979 would - * produce a different key on every call, and this catches it before any data - * depends on it. + * On a signer's first use this signs twice and compares, which catches a + * randomising signer before any data depends on it, at the cost of a second + * wallet prompt. Later calls sign once. See {@link DatasetKeysOptions}. * - * @throws If the signer is not deterministic. + * @throws If the signer is not deterministic, or does not produce an ECDSA signature. */ -export async function datasetSecret(signer: TypedDataSigner, ref: DatasetRef): Promise { +export async function datasetKeys( + signer: TypedDataSigner, + ref: DatasetRef, + options: DatasetKeysOptions = {} +): Promise { const args = { domain: DOMAIN, types: DATASET_KEY_TYPES, @@ -107,14 +120,21 @@ export async function datasetSecret(signer: TypedDataSigner, ref: DatasetRef): P message: datasetKeyMessage(ref), } const first = await signer.signTypedData(args) - const second = await signer.signTypedData(args) - if (first !== second) { - throw new Error( - 'Signer is not deterministic (RFC 6979 expected), so it cannot root a dataset key. ' + - 'Signing the same message twice produced different signatures.' - ) + if (options.verifySigner ?? !verifiedSigners.has(signer)) { + const second = await signer.signTypedData(args) + if (first !== second) { + throw new Error( + 'Signer is not deterministic (RFC 6979 expected), so it cannot root a dataset key. ' + + 'Signing the same message twice produced different signatures.' + ) + } + verifiedSigners.add(signer) + } + const secret = lowSrs(first) + return { + dk: derive(secret, INFO.dataset), + commitment: `v1.${bytesToHex(derive(secret, INFO.commitment, 16)).slice(2)}`, } - return lowSrs(first) } /** @@ -123,45 +143,72 @@ export async function datasetSecret(signer: TypedDataSigner, ref: DatasetRef): P * Both `(r, s)` and `(r, n−s)` are valid signatures, so a signer returning the * high form would otherwise derive a different key for the same wallet. The * `v` byte is excluded because wallets report it as 0/1 or 27/28. + * + * Only a plain secp256k1 ECDSA signature is accepted. A contract account or + * smart wallet answers `signTypedData` with an ABI-encoded blob whose leading + * bytes are structure rather than secret; deriving from those would mint a + * key an attacker could enumerate, so anything that is not 64 or 65 bytes with + * `r, s ∈ [1, n−1]` is refused. */ export function lowSrs(signature: Hex): Uint8Array { const raw = hexToBytes(signature) - if (raw.length < 64) { - throw new Error(`Expected a 64- or 65-byte signature, got ${raw.length} bytes`) + if (raw.length !== 64 && raw.length !== 65) { + throw new Error( + `Expected a 64- or 65-byte ECDSA signature, got ${raw.length} bytes. ` + + 'Contract accounts and smart wallets return other encodings and cannot root a dataset key.' + ) } + const r = BigInt(bytesToHex(raw.subarray(0, 32))) const s = BigInt(bytesToHex(raw.subarray(32, 64))) - if (s <= HALF_N) { - return raw.subarray(0, 64) + if (r < 1n || r >= N || s < 1n || s >= N) { + throw new Error('Signature r and s must lie in [1, n−1]; this is not a secp256k1 ECDSA signature.') } + const lowS = s > HALF_N ? N - s : s const out = new Uint8Array(64) out.set(raw.subarray(0, 32), 0) - out.set(hexToBytes(`0x${(N - s).toString(16).padStart(64, '0')}`), 32) + out.set(hexToBytes(`0x${lowS.toString(16).padStart(64, '0')}`), 32) return out } -/** The key for one dataset. Opens every piece in it, and nothing else. */ -export const datasetKey = (secret: Uint8Array): Uint8Array => derive(secret, INFO.dataset) - /** - * A non-secret commitment to the dataset key, for FWSS data-set metadata. - * - * Written inside the `createDataSet` call that happens anyway. On recovery the - * payer re-signs and compares, so a wrong wallet or a randomising signer is a - * loud error rather than a silently wrong key. It reveals nothing: it is a - * one-way function of the signature, and the signature is what an attacker - * would need. + * The key for one section of a dataset. Opens every piece written into that + * scope, and nothing outside it. The name is an HKDF input, never a secret. */ -export const commitment = (secret: Uint8Array): string => - `v1.${bytesToHex(derive(secret, INFO.commitment, 16)).slice(2)}` +export const scopeKey = (dk: Uint8Array, scope: string): Uint8Array => derive(dk, `${INFO.scope}${scopeName(scope)}`) /** - * The key for one section of a dataset. Opens every piece written into that - * scope, and nothing outside it. The name is an HKDF input, never a secret. + * A scope name in the one form it is derived from: Unicode NFC, non-empty, + * with no leading or trailing whitespace. Case is significant — `Invoices` + * and `invoices` are different scopes — so it is left alone rather than folded. + * + * @throws If the name is empty or padded with whitespace. */ -export const scopeKey = (dk: Uint8Array, scope: string): Uint8Array => derive(dk, `${INFO.scope}${scope}`) +export function scopeName(name: string): string { + const normalised = name.normalize('NFC') + if (normalised.length === 0 || normalised.trim() !== normalised) { + throw new Error( + `A scope name must be non-empty with no leading or trailing whitespace, got ${JSON.stringify(name)}` + ) + } + return normalised +} + +/** A grant node in canonical form: `dataset`, or `scope:` plus a canonical scope name. */ +export function canonicalNode(node: string): string { + if (node === 'dataset') { + return 'dataset' + } + if (node.startsWith('scope:')) { + return `scope:${scopeName(node.slice('scope:'.length))}` + } + throw new Error(`Unrecognised grant node: ${node}`) +} /** The key for one piece. Never reused: FEE requires a fresh key per object. */ -export const pieceKey = (node: Uint8Array, salt: Hex): Uint8Array => derive(node, `${INFO.piece}${salt}`) +export const pieceKey = (node: Uint8Array, salt: Hex): Uint8Array => derive(node, `${INFO.piece}${lowerHex(salt)}`) + +/** Salts are bytes, so only case is normalised — leading zeros are part of the value. */ +const lowerHex = (value: Hex): Hex => value.toLowerCase() as Hex /** * Deterministic serialization for IDs. @@ -176,8 +223,8 @@ export function pieceMetadata(ref: DatasetRef, options: { salt: Hex; scope?: str 'foc/v': 1, 'foc/cds': clientDataSetIdHex(ref.clientDataSetId), 'foc/epoch': ref.epoch ?? 0, - ...(options.scope == null ? {} : { 'foc/scope': options.scope }), - 'foc/salt': options.salt, + ...(options.scope == null ? {} : { 'foc/scope': scopeName(options.scope) }), + 'foc/salt': lowerHex(options.salt), } } @@ -185,16 +232,18 @@ export function pieceMetadata(ref: DatasetRef, options: { salt: Hex; scope?: str * The descriptor naming what a grant unlocks, ready for `wrapTo()`. * * Build descriptors with `grantDescriptor()` rather than filling the - * structure by hand. It is authenticated as part of the grant signature - * so consistent canonicalization is important. + * structure by hand: the descriptor is authenticated as the grant's AAD and + * compared byte for byte, so addresses are lowercased and the id is spelled + * exactly as the envelope spells it. */ export function grantDescriptor(ref: DatasetRef, node: GrantNode): GrantDescriptor { return { v: 1, - node, + node: canonicalNode(node) as GrantNode, chainId: ref.chainId, - service: ref.service, - payer: ref.payer, + epoch: ref.epoch ?? 0, + service: ref.service.toLowerCase() as Address, + payer: ref.payer.toLowerCase() as Address, clientDataSetId: clientDataSetIdHex(ref.clientDataSetId), } } diff --git a/packages/keysmith/src/index.ts b/packages/keysmith/src/index.ts index aa2552009..4e3c7c864 100644 --- a/packages/keysmith/src/index.ts +++ b/packages/keysmith/src/index.ts @@ -5,16 +5,27 @@ * stored by this layer, nothing goes on chain but a 16-byte commitment, and a * wallet alone recovers everything. * + * @example + * ```ts + * import * as Keysmith from '@filoz/keysmith' + * + * const ref = { chainId: 314, service: fwss, payer: account.address, clientDataSetId } + * const { dk, commitment } = await Keysmith.datasetKeys(account, ref) + * + * const salt = Keysmith.newSalt() + * const key = Keysmith.pieceKey(dk, salt) // hand to FEE + * const metadata = Keysmith.pieceMetadata(ref, { salt }) // put in the envelope + * const grant = await Keysmith.wrapTo(theirPublicKey, dk, Keysmith.grantDescriptor(ref, 'dataset')) + * ``` + * * @module */ export { COMMITMENT_KEY, - commitment, DATASET_KEY_TYPES, DOMAIN, - datasetKey, datasetKeyMessage, - datasetSecret, + datasetKeys, grantDescriptor, holdingOf, keyForEnvelope, @@ -24,9 +35,12 @@ export { pieceKey, pieceMetadata, scopeKey, + scopeName, } from './derive.ts' export type { DatasetKeyMessage, + DatasetKeys, + DatasetKeysOptions, DatasetRef, Grant, GrantDescriptor, diff --git a/packages/keysmith/src/types.ts b/packages/keysmith/src/types.ts index 73e236807..ee87fc5ee 100644 --- a/packages/keysmith/src/types.ts +++ b/packages/keysmith/src/types.ts @@ -19,6 +19,23 @@ export type DatasetKeyMessage = { epoch: number // For key rotation/re-encrypt in place } & Record +/** What `datasetKeys()` hands back. The signature it came from is never exposed. */ +export interface DatasetKeys { + dk: Uint8Array // The key for the whole dataset. + commitment: string // Non-secret check value for FWSS metadata, under `COMMITMENT_KEY`. +} + +export interface DatasetKeysOptions { + /** + * @verifySigner@ + * Whether to sign twice and compare, which catches a randomising signer + * before any data depends on it. Defaults to once per signer object: the + * first call costs two wallet prompts, later calls one. Pass `true` to check + * on every call, or `false` for a signer you have already vetted. + */ + verifySigner?: boolean +} + /** * Anything that can sign EIP-712 typed data: a viem Account, a WalletClient, or * a session key. Keysmith itself never sees a private key. @@ -45,7 +62,9 @@ export interface PieceMetadata { export type GrantNode = 'dataset' | `scope:${string}` /** - * Names the node a grant unlocks. Authenticated, so it cannot be relabelled. + * Names the node a grant unlocks. Exactly these fields are authenticated, so a + * grant cannot be relabelled; anything else carried alongside a grant is + * informational and unauthenticated. * * `node` is a plain string rather than {@link GrantNode} because grants arrive * as JSON from elsewhere and must be validated at runtime, not assumed. Build @@ -55,10 +74,10 @@ export interface GrantDescriptor { v: 1 node: string // 'dataset', or 'scope:'. chainId: number // eg 314 for Filecoin mainnet - service: Address // FWSS contract address on @chainId@ - payer: Address // Dataset payer, as a proxy for owner + epoch: number // Which re-keying of the dataset this key belongs to + service: Address // FWSS contract address on @chainId@, lowercased + payer: Address // Dataset payer, as a proxy for owner, lowercased clientDataSetId: Hex // Client-chosen dataset ID, spelled as in `foc/cds` - [key: string]: unknown } /** A node key wrapped to one recipient. Safe to store or send anywhere. */ diff --git a/packages/keysmith/src/wrap.ts b/packages/keysmith/src/wrap.ts index f0ea62c16..c93f17791 100644 --- a/packages/keysmith/src/wrap.ts +++ b/packages/keysmith/src/wrap.ts @@ -2,23 +2,49 @@ * Sharing: wrap a node key to a recipient's public key. * * ECDH-ES over secp256k1 plus AES-256-GCM, so a recipient uses the key they - * already have — a wallet, or a Session Key Registry session key — and nothing - * new has to be published, registered or stored. + * already have — a session key, or any service holding a local key — and + * nothing new has to be published, registered or stored. + * + * A grant proves nothing about who made it. Anyone can address one to anyone, + * with any key inside; what the recipient learns on a successful unwrap is + * only that the descriptor was not altered in transit. Before writing with a + * key you were handed, open a known piece with it. * * @module */ +import { mapHashToField } from '@noble/curves/abstract/modular' import { secp256k1 } from '@noble/curves/secp256k1' import { hkdf } from '@noble/hashes/hkdf' import { sha256 } from '@noble/hashes/sha2' import type { Hex } from 'viem' import { bytesToHex, hexToBytes } from 'viem' +import { canonicalNode } from './derive.ts' import type { Grant, GrantDescriptor } from './types.ts' +const ECDH_INFO = 'foc/acl/ecdh/v1' const WRAP_INFO = 'foc/acl/wrap/v1' const ALG = 'ECDH-ES+A256GCM/secp256k1' +const KEY_LENGTH = 32 -/** The public half of a secp256k1 private key, uncompressed. */ -export const publicKeyOf = (privateKey: Hex): Hex => bytesToHex(secp256k1.getPublicKey(hexToBytes(privateKey), false)) +/** + * The key-agreement key derived from a signing key, so that one credential + * never serves two algorithms. HKDF stretches the signing key to 48 bytes, + * and hash-to-scalar (FIPS 186-5 §A.2.1) reduces that to a uniform scalar. + * Deterministic, so the public half can be published once and stays valid. + */ +function ecdhSecretKey(privateKey: Hex): Uint8Array { + const seed = hkdf(sha256, hexToBytes(privateKey), undefined, ECDH_INFO, 48) + return mapHashToField(seed, secp256k1.CURVE.n) +} + +/** + * The public key a sender wraps to, for the holder of a secp256k1 private key. + * + * This is the derived key-agreement key, not the signing key's own public + * point: publish this, not the address key. + */ +export const publicKeyOf = (privateKey: Hex): Hex => + bytesToHex(secp256k1.getPublicKey(ecdhSecretKey(privateKey), false)) /** * Wrap a node key — a dataset key, or a scope key — to a recipient. @@ -28,9 +54,14 @@ export const publicKeyOf = (privateKey: Hex): Hex => bytesToHex(secp256k1.getPub * recipient's private key, so it can be delivered or stored anywhere. */ export async function wrapTo(recipientPublicKey: Hex, key: Uint8Array, descriptor: GrantDescriptor): Promise { + if (key.length !== KEY_LENGTH) { + throw new Error(`Expected a ${KEY_LENGTH}-byte node key, got ${key.length} bytes`) + } + // Accept either encoding, but derive from one, or the two sides would disagree. + const pkR = secp256k1.ProjectivePoint.fromHex(hexToBytes(recipientPublicKey)).toRawBytes(false) const ephemeral = secp256k1.utils.randomSecretKey() const epk = secp256k1.getPublicKey(ephemeral, false) - const kek = wrapKek(sharedSecret(ephemeral, hexToBytes(recipientPublicKey)), epk) + const kek = wrapKek(sharedSecret(ephemeral, pkR), epk, pkR) const iv = new Uint8Array(12) crypto.getRandomValues(iv) const aesKey = await crypto.subtle.importKey('raw', buffer(kek), 'AES-GCM', false, ['encrypt']) @@ -51,46 +82,75 @@ export async function wrapTo(recipientPublicKey: Hex, key: Uint8Array, descripto /** * Open a grant with the recipient's private key. * - * @throws If the grant was not addressed to this key, or its descriptor was altered. + * @throws If the grant was not addressed to this key, its descriptor was + * altered, or it is not a grant this version understands. */ export async function unwrapWith(privateKey: Hex, grant: Grant): Promise { const { alg, epk, iv, ct, ...descriptor } = grant + if (descriptor.v !== 1) { + throw new Error(`Unsupported grant version: ${String(descriptor.v)}`) + } if (alg !== ALG) { throw new Error(`Unsupported grant algorithm: ${String(alg)}`) } + const sk = ecdhSecretKey(privateKey) + const pkR = secp256k1.getPublicKey(sk, false) const epkBytes = hexToBytes(epk) - const kek = wrapKek(sharedSecret(hexToBytes(privateKey), epkBytes), epkBytes) + const kek = wrapKek(sharedSecret(sk, epkBytes), epkBytes, pkR) const aesKey = await crypto.subtle.importKey('raw', buffer(kek), 'AES-GCM', false, ['decrypt']) - const out = await crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: buffer(hexToBytes(iv)), - additionalData: aad(descriptor as GrantDescriptor), - }, - aesKey, - buffer(hexToBytes(ct)) + const out = new Uint8Array( + await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: buffer(hexToBytes(iv)), additionalData: aad(descriptor) }, + aesKey, + buffer(hexToBytes(ct)) + ) ) - return new Uint8Array(out) + if (out.length !== KEY_LENGTH) { + throw new Error(`Grant carried a ${out.length}-byte key; a node key is ${KEY_LENGTH} bytes`) + } + return out } /** X coordinate of the ECDH point, as both halves compute it. */ const sharedSecret = (privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array => secp256k1.getSharedSecret(privateKey, publicKey, true).subarray(1) -function wrapKek(shared: Uint8Array, epk: Uint8Array): Uint8Array { - const ikm = new Uint8Array(shared.length + epk.length) +/** KEK = HKDF(shared ‖ epk ‖ pkR): both public keys bound in, as HPKE does. */ +function wrapKek(shared: Uint8Array, epk: Uint8Array, pkR: Uint8Array): Uint8Array { + const ikm = new Uint8Array(shared.length + epk.length + pkR.length) ikm.set(shared, 0) ikm.set(epk, shared.length) + ikm.set(pkR, shared.length + epk.length) return hkdf(sha256, ikm, undefined, WRAP_INFO, 32) } -/** Key order must not matter, so the descriptor is serialised with sorted keys. */ -function aad(descriptor: GrantDescriptor): ArrayBuffer { - const sorted: Record = {} - for (const key of Object.keys(descriptor).sort()) { - sorted[key] = descriptor[key] +/** + * The authenticated fields, in a fixed order, as a JSON array of primitives. + * + * Addresses and the id are lowercased so that a spelling difference cannot + * split a grant. Exactly these six fields are covered; anything else carried + * alongside a grant is informational and unauthenticated. + */ +function aad(d: GrantDescriptor): ArrayBuffer { + const fields = [ + d.v, + canonicalNode(d.node), + integer(d.chainId, 'chainId'), + integer(d.epoch, 'epoch'), + d.service.toLowerCase(), + d.payer.toLowerCase(), + `0x${BigInt(d.clientDataSetId).toString(16)}`, + ] + return buffer(new TextEncoder().encode(JSON.stringify(fields))) +} + +/** A relay may have rendered a number as a string; accept that, but nothing that is not an integer. */ +function integer(value: unknown, name: string): number { + const n = Number(value) + if (!Number.isSafeInteger(n) || n < 0) { + throw new Error(`Grant ${name} must be a non-negative integer, got ${String(value)}`) } - return buffer(new TextEncoder().encode(JSON.stringify(sorted))) + return n } /** WebCrypto takes ArrayBuffer-backed data; noble and viem return views. */ diff --git a/packages/keysmith/test/derive.test.ts b/packages/keysmith/test/derive.test.ts index 211fb84ef..83bd0860f 100644 --- a/packages/keysmith/test/derive.test.ts +++ b/packages/keysmith/test/derive.test.ts @@ -3,11 +3,10 @@ import assert from 'assert' import { bytesToHex, hashDomain, hexToBytes } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { - commitment, DOMAIN, - datasetKey, datasetKeyMessage, - datasetSecret, + datasetKeys, + grantDescriptor, holdingOf, keyForEnvelope, lowSrs, @@ -16,6 +15,7 @@ import { pieceKey, pieceMetadata, scopeKey, + scopeName, } from '../src/derive.ts' import type { DatasetRef, TypedDataSigner } from '../src/types.ts' @@ -26,6 +26,19 @@ const ref: DatasetRef = { payer: account.address, clientDataSetId: 42n, } +const dkOf = async (signer: TypedDataSigner, r: DatasetRef) => (await datasetKeys(signer, r)).dk + +/** A real signer with a call counter, so prompts can be counted. */ +function counting(signer: TypedDataSigner) { + const wrapper = { + calls: 0, + signTypedData: (args: Parameters[0]) => { + wrapper.calls++ + return signer.signTypedData(args) + }, + } + return wrapper +} describe('datasetKeyMessage', () => { it('names the dataset and defaults the epoch', () => { @@ -37,98 +50,162 @@ describe('datasetKeyMessage', () => { }) }) -describe('datasetSecret', () => { +describe('datasetKeys', () => { it('derives the same key for the same wallet and dataset', async () => { - const once = datasetKey(await datasetSecret(account, ref)) - const twice = datasetKey(await datasetSecret(account, ref)) - assert.deepEqual(once, twice) + assert.deepStrictEqual(await dkOf(account, ref), await dkOf(account, ref)) }) it('derives an unrelated key for another dataset', async () => { - const a = datasetKey(await datasetSecret(account, ref)) - const b = datasetKey(await datasetSecret(account, { ...ref, clientDataSetId: 43n })) - assert.notDeepEqual(a, b) + assert.notDeepStrictEqual(await dkOf(account, ref), await dkOf(account, { ...ref, clientDataSetId: 43n })) }) it('derives an unrelated key for another wallet', async () => { const other = privateKeyToAccount(generatePrivateKey()) - const a = datasetKey(await datasetSecret(account, ref)) - const b = datasetKey(await datasetSecret(other, { ...ref, payer: other.address })) - assert.notDeepEqual(a, b) + assert.notDeepStrictEqual(await dkOf(account, ref), await dkOf(other, { ...ref, payer: other.address })) + }) + + it('returns a commitment that is stable, public and dataset-specific', async () => { + const a = await datasetKeys(account, ref) + const b = await datasetKeys(account, ref) + const other = await datasetKeys(account, { ...ref, clientDataSetId: 43n }) + assert.equal(a.commitment, b.commitment) + assert.notEqual(a.commitment, other.commitment) + assert.match(a.commitment, /^v1\.[0-9a-f]{32}$/) + assert.ok(!a.commitment.includes(bytesToHex(a.dk).slice(2, 34)), 'the commitment must not contain the key') + }) + + it('signs twice on a signer’s first use, and once after that', async () => { + const signer = counting(account) + await datasetKeys(signer, ref) + assert.equal(signer.calls, 2, 'first use: sign, sign again, compare') + await datasetKeys(signer, { ...ref, clientDataSetId: 43n }) + assert.equal(signer.calls, 3, 'the signer is now known to be deterministic') + await datasetKeys(signer, ref, { verifySigner: true }) + assert.equal(signer.calls, 5, 'checking can be forced') + + const vetted = counting(account) + await datasetKeys(vetted, ref, { verifySigner: false }) + assert.equal(vetted.calls, 1, 'or skipped for a signer already vetted') }) it('rejects a randomising signer', async () => { let calls = 0 + const r = bytesToHex(new Uint8Array(32).fill(1)) const flaky: TypedDataSigner = { - signTypedData: async () => `0x${String(++calls).padStart(130, '0')}` as const, + signTypedData: async () => `${r}${(++calls).toString(16).padStart(64, '0')}1b` as const, } - await assert.rejects(datasetSecret(flaky, ref), /not deterministic/) + await assert.rejects(datasetKeys(flaky, ref), /not deterministic/) + }) + + it('refuses a signature that is not plain ECDSA', async () => { + // What an ERC-1271 account might answer: an ABI-encoded blob whose first + // 64 bytes are offsets — structure, not secret. + const abiLike: TypedDataSigner = { + signTypedData: async () => `0x${'20'.padStart(64, '0')}${'41'.padStart(64, '0')}${'ab'.repeat(65)}` as const, + } + await assert.rejects(datasetKeys(abiLike, ref), /64- or 65-byte/) }) }) describe('lowSrs', () => { + const r = bytesToHex(new Uint8Array(32).fill(0xab)) + const asSig = (s: bigint, v = '1b') => `${r}${s.toString(16).padStart(64, '0')}${v}` as `0x${string}` + it('drops v and normalises a high-S signature to the low form', () => { - const r = new Uint8Array(32).fill(0xab) const low = 0x0123456789abcdefn - const high = secp256k1.CURVE.n - low - - const asSig = (s: bigint, v: string) => `${bytesToHex(r)}${s.toString(16).padStart(64, '0')}${v}` as `0x${string}` - const fromLow = lowSrs(asSig(low, '1b')) - const fromHigh = lowSrs(asSig(high, '1c')) + const fromHigh = lowSrs(asSig(secp256k1.CURVE.n - low, '1c')) assert.equal(fromLow.length, 64) - assert.deepEqual(fromLow, fromHigh, 'both malleable forms must yield one key') + assert.deepStrictEqual(fromLow, fromHigh, 'both malleable forms must yield one key') }) - it('rejects a short signature', () => { + it('accepts a 64-byte r‖s with no v', () => { + const sig = asSig(7n, '') + assert.equal(hexToBytes(sig).length, 64) + assert.deepStrictEqual(lowSrs(sig), lowSrs(asSig(7n))) + }) + + it('rejects any other length', () => { assert.throws(() => lowSrs('0xdeadbeef'), /64- or 65-byte/) + assert.throws(() => lowSrs(`${asSig(7n)}00`), /64- or 65-byte/) + assert.throws(() => lowSrs(`0x${'00'.repeat(96)}`), /64- or 65-byte/) + }) + + it('rejects r or s outside [1, n−1]', () => { + assert.throws(() => lowSrs(asSig(0n)), /\[1, n−1\]/) + assert.throws(() => lowSrs(asSig(secp256k1.CURVE.n)), /\[1, n−1\]/) + const zeroR = `0x${'00'.repeat(32)}${7n.toString(16).padStart(64, '0')}1b` as const + assert.throws(() => lowSrs(zeroR), /\[1, n−1\]/) }) }) -describe('commitment', () => { - it('is stable, public and dataset-specific', async () => { - const secret = await datasetSecret(account, ref) - const other = await datasetSecret(account, { ...ref, clientDataSetId: 43n }) - assert.equal(commitment(secret), commitment(secret)) - assert.notEqual(commitment(secret), commitment(other)) - assert.match(commitment(secret), /^v1\.[0-9a-f]{32}$/) +describe('EIP-712 domain', () => { + const EIP712Domain = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + ] as const + const separator = () => hashDomain({ domain: DOMAIN, types: { EIP712Domain } }) + + it('is pinned — changing it orphans every key ever derived', () => { + assert.deepStrictEqual(DOMAIN, { name: 'FOC Encryption', version: '1' }) + assert.equal(separator(), '0x547bad88c79d4d4f2d88253da28d0b6dd21bb4aa20bbfa20d2f05b55335697b2') + }) + + it('omits chainId and verifyingContract, and absent is not zero', () => { + assert.equal('chainId' in DOMAIN, false) + assert.equal('verifyingContract' in DOMAIN, false) + + const zeroed = hashDomain({ + domain: { ...DOMAIN, chainId: 0n, verifyingContract: '0x0000000000000000000000000000000000000000' }, + types: { + EIP712Domain: [ + ...EIP712Domain, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ] as const, + }, + }) + assert.notEqual(zeroed, separator(), 'zero-filling the fields is a different domain, so a different key') }) - it('does not leak the dataset key', async () => { - const secret = await datasetSecret(account, ref) - assert.ok(!commitment(secret).includes(bytesToHex(datasetKey(secret)).slice(2, 34))) + it('binds the chain and the service through the message instead', async () => { + const here = await dkOf(account, ref) + assert.notDeepStrictEqual(here, await dkOf(account, { ...ref, chainId: 314 })) + assert.notDeepStrictEqual( + here, + await dkOf(account, { ...ref, service: '0x00000000000000000000000000000000000000ff' }) + ) }) }) describe('derivation tree', () => { it('separates scopes, and pieces within a scope', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + const dk = await dkOf(account, ref) const invoices = scopeKey(dk, 'invoices') const payroll = scopeKey(dk, 'payroll') - assert.notDeepEqual(invoices, payroll) + assert.notDeepStrictEqual(invoices, payroll) const salt = newSalt() - assert.notDeepEqual(pieceKey(invoices, salt), pieceKey(payroll, salt)) - assert.notDeepEqual(pieceKey(dk, salt), pieceKey(dk, newSalt())) + assert.notDeepStrictEqual(pieceKey(invoices, salt), pieceKey(payroll, salt)) + assert.notDeepStrictEqual(pieceKey(dk, salt), pieceKey(dk, newSalt())) }) it('gives a dataset holder and a scope holder the same piece key', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) - const salt = newSalt() - const metadata = pieceMetadata(ref, { salt, scope: 'invoices' }) - assert.deepEqual(keyForEnvelope(dk, metadata), keyForEnvelope(scopeKey(dk, 'invoices'), metadata, 'scope')) + const dk = await dkOf(account, ref) + const metadata = pieceMetadata(ref, { salt: newSalt(), scope: 'invoices' }) + assert.deepStrictEqual(keyForEnvelope(dk, metadata), keyForEnvelope(scopeKey(dk, 'invoices'), metadata, 'scope')) }) it('keeps an unscoped piece out of any scope', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + const dk = await dkOf(account, ref) const salt = newSalt() const metadata = pieceMetadata(ref, { salt }) assert.equal(metadata['foc/scope'], undefined) - assert.deepEqual(keyForEnvelope(dk, metadata), pieceKey(dk, salt)) + assert.deepStrictEqual(keyForEnvelope(dk, metadata), pieceKey(dk, salt)) }) it('says so when a scope key is used on a piece at the root', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + const dk = await dkOf(account, ref) const metadata = pieceMetadata(ref, { salt: newSalt() }) assert.throws( () => keyForEnvelope(scopeKey(dk, 'invoices'), metadata, 'scope'), @@ -151,19 +228,19 @@ describe('holdingOf', () => { }) it('round-trips with what a scope grant would carry', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) - const salt = newSalt() - const metadata = pieceMetadata(ref, { salt, scope: 'invoices' }) - const grant = { node: 'scope:invoices' } - assert.deepEqual(keyForEnvelope(scopeKey(dk, 'invoices'), metadata, holdingOf(grant)), keyForEnvelope(dk, metadata)) + const dk = await dkOf(account, ref) + const metadata = pieceMetadata(ref, { salt: newSalt(), scope: 'invoices' }) + assert.deepStrictEqual( + keyForEnvelope(scopeKey(dk, 'invoices'), metadata, holdingOf({ node: 'scope:invoices' })), + keyForEnvelope(dk, metadata) + ) }) }) describe('pieceMetadata', () => { it('records what a reader needs and nothing secret', () => { const salt = newSalt() - const metadata = pieceMetadata(ref, { salt, scope: 'invoices' }) - assert.deepEqual(metadata, { + assert.deepStrictEqual(pieceMetadata(ref, { salt, scope: 'invoices' }), { 'foc/v': 1, 'foc/cds': '0x2a', 'foc/epoch': 0, @@ -173,6 +250,33 @@ describe('pieceMetadata', () => { }) }) +describe('grantDescriptor', () => { + it('spells the id exactly as the envelope does', () => { + const descriptor = grantDescriptor(ref, 'dataset') + assert.equal(descriptor.clientDataSetId, '0x2a') + assert.equal(descriptor.clientDataSetId, pieceMetadata(ref, { salt: newSalt() })['foc/cds']) + }) + + it('lowercases addresses, whatever spelling it was given', () => { + const checksummed = grantDescriptor({ ...ref, service: '0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3' }, 'dataset') + const lower = grantDescriptor({ ...ref, service: '0xfcddd1e5bc2658fb7483b8e2fa72d8368756f5a3' }, 'dataset') + assert.deepStrictEqual(checksummed, lower) + assert.equal(checksummed.payer, account.address.toLowerCase()) + }) + + it('carries what the descriptor is for, and nothing secret', () => { + assert.deepStrictEqual(grantDescriptor(ref, 'scope:invoices'), { + v: 1, + node: 'scope:invoices', + chainId: ref.chainId, + epoch: 0, + service: ref.service.toLowerCase(), + payer: account.address.toLowerCase(), + clientDataSetId: '0x2a', + }) + }) +}) + describe('identifiers', () => { it('mints distinct salts and client data set ids', () => { assert.notEqual(newSalt(), newSalt()) @@ -181,45 +285,30 @@ describe('identifiers', () => { }) }) -describe('EIP-712 domain', () => { - const EIP712Domain = [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - ] as const - const separator = () => hashDomain({ domain: DOMAIN, types: { EIP712Domain } }) - - it('is pinned — changing it orphans every key ever derived', () => { - assert.deepEqual(DOMAIN, { name: 'FOC Encryption', version: '1' }) - assert.equal(separator(), '0x547bad88c79d4d4f2d88253da28d0b6dd21bb4aa20bbfa20d2f05b55335697b2') +describe('canonical forms', () => { + it('normalises scope names to NFC, keeps case, and rejects padding', async () => { + const dk = await dkOf(account, ref) + assert.deepStrictEqual(scopeKey(dk, 'caf\u00e9'), scopeKey(dk, 'cafe\u0301'), 'NFC and NFD are one scope') + assert.notDeepStrictEqual(scopeKey(dk, 'Invoices'), scopeKey(dk, 'invoices'), 'case is significant') + assert.equal(scopeName('cafe\u0301'), 'caf\u00e9') + assert.throws(() => scopeKey(dk, ''), /non-empty/) + assert.throws(() => scopeKey(dk, ' invoices'), /whitespace/) + assert.throws(() => pieceMetadata(ref, { salt: newSalt(), scope: 'invoices ' }), /whitespace/) }) - it('omits chainId and verifyingContract, and absent is not zero', () => { - assert.equal('chainId' in DOMAIN, false) - assert.equal('verifyingContract' in DOMAIN, false) - - const zeroed = hashDomain({ - domain: { ...DOMAIN, chainId: 0n, verifyingContract: '0x0000000000000000000000000000000000000000' }, - types: { - EIP712Domain: [ - ...EIP712Domain, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ] as const, - }, - }) - assert.notEqual(zeroed, separator(), 'zero-filling the fields is a different domain, so a different key') + it('lowercases the salt wherever it is used, and keeps its leading zeros', async () => { + const dk = await dkOf(account, ref) + const upper = '0x00CDEF0123456789ABCDEF0123456789' as const + assert.deepStrictEqual(pieceKey(dk, upper), pieceKey(dk, '0x00cdef0123456789abcdef0123456789')) + assert.notDeepStrictEqual(pieceKey(dk, upper), pieceKey(dk, '0xcdef0123456789abcdef0123456789')) + assert.equal(pieceMetadata(ref, { salt: upper })['foc/salt'], '0x00cdef0123456789abcdef0123456789') }) - it('binds the chain and the service through the message instead', async () => { - const here = datasetKey(await datasetSecret(account, ref)) - const anotherChain = datasetKey(await datasetSecret(account, { ...ref, chainId: 314 })) - const anotherService = datasetKey( - await datasetSecret(account, { - ...ref, - service: '0x00000000000000000000000000000000000000ff', - }) - ) - assert.notDeepEqual(here, anotherChain) - assert.notDeepEqual(here, anotherService) + it('carries the epoch and a canonical node in the descriptor', () => { + const d = grantDescriptor({ ...ref, epoch: 3 }, 'scope:cafe\u0301') + assert.equal(d.epoch, 3) + assert.equal(d.node, 'scope:caf\u00e9') + assert.equal(grantDescriptor(ref, 'dataset').epoch, 0) + assert.throws(() => grantDescriptor(ref, 'scope:'), /non-empty/) }) }) diff --git a/packages/keysmith/test/wrap.test.ts b/packages/keysmith/test/wrap.test.ts index 0ab867421..6f84d5822 100644 --- a/packages/keysmith/test/wrap.test.ts +++ b/packages/keysmith/test/wrap.test.ts @@ -1,15 +1,9 @@ +import { secp256k1 } from '@noble/curves/secp256k1' import assert from 'assert' +import { bytesToHex, hexToBytes } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' -import { - datasetKey, - datasetSecret, - grantDescriptor, - holdingOf, - newSalt, - pieceMetadata, - scopeKey, -} from '../src/derive.ts' -import type { DatasetRef, GrantDescriptor } from '../src/types.ts' +import { datasetKeys, grantDescriptor, holdingOf, scopeKey } from '../src/derive.ts' +import type { DatasetRef, Grant, GrantDescriptor } from '../src/types.ts' import { publicKeyOf, unwrapWith, wrapTo } from '../src/wrap.ts' const account = privateKeyToAccount(generatePrivateKey()) @@ -19,97 +13,132 @@ const ref: DatasetRef = { payer: account.address, clientDataSetId: 42n, } -const descriptor: GrantDescriptor = grantDescriptor(ref, 'dataset') +const descriptor = grantDescriptor(ref, 'dataset') +const dkOf = async () => (await datasetKeys(account, ref)).dk + +describe('publicKeyOf', () => { + it('is a derived key-agreement key, not the signing key', () => { + const k = generatePrivateKey() + const signing = bytesToHex(secp256k1.getPublicKey(hexToBytes(k), false)) + assert.notEqual(publicKeyOf(k), signing, 'one credential, two algorithms, two keys') + assert.equal(publicKeyOf(k), publicKeyOf(k), 'but stable, so it can be published once') + assert.equal(hexToBytes(publicKeyOf(k)).length, 65) + }) +}) describe('wrapTo / unwrapWith', () => { it('round-trips a dataset key to the named recipient', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + const dk = await dkOf() const recipient = generatePrivateKey() - const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) assert.equal(grant.alg, 'ECDH-ES+A256GCM/secp256k1') assert.equal(grant.node, 'dataset') - assert.deepEqual(await unwrapWith(recipient, grant), dk) + assert.deepStrictEqual(await unwrapWith(recipient, grant), dk) }) it('survives JSON delivery', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + const dk = await dkOf() const recipient = generatePrivateKey() - const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) - const delivered = JSON.parse(JSON.stringify(grant)) - assert.deepEqual(await unwrapWith(recipient, delivered), dk) + assert.deepStrictEqual(await unwrapWith(recipient, JSON.parse(JSON.stringify(grant))), dk) + }) + + it('accepts a compressed recipient key and derives the same wrap', async () => { + const dk = await dkOf() + const recipient = generatePrivateKey() + const compressed = bytesToHex( + secp256k1.ProjectivePoint.fromHex(hexToBytes(publicKeyOf(recipient))).toRawBytes(true) + ) + const grant = await wrapTo(compressed, dk, descriptor) + assert.deepStrictEqual(await unwrapWith(recipient, grant), dk) }) it('tells a stranger nothing', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) - const grant = await wrapTo(publicKeyOf(generatePrivateKey()), dk, descriptor) + const grant = await wrapTo(publicKeyOf(generatePrivateKey()), await dkOf(), descriptor) await assert.rejects(unwrapWith(generatePrivateKey(), grant)) }) - it('refuses a grant relabelled as another node', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + it('refuses a grant relabelled as another node or dataset', async () => { const recipient = generatePrivateKey() - const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + const grant = await wrapTo(publicKeyOf(recipient), await dkOf(), descriptor) await assert.rejects(unwrapWith(recipient, { ...grant, node: 'scope:payroll' })) await assert.rejects(unwrapWith(recipient, { ...grant, clientDataSetId: '0x2b' })) + await assert.rejects(unwrapWith(recipient, { ...grant, chainId: 314 })) }) - it('does not care what order the descriptor was built in', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + it('does not care how the addresses were spelled', async () => { + const dk = await dkOf() const recipient = generatePrivateKey() - const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) - const reordered = { - ...grant, - ...(Object.fromEntries(Object.entries(descriptor).reverse()) as GrantDescriptor), + const byHand: GrantDescriptor = { + ...descriptor, + service: '0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3', + payer: account.address, + clientDataSetId: '0x2A', } - assert.deepEqual(await unwrapWith(recipient, reordered), dk) + const grant = await wrapTo(publicKeyOf(recipient), dk, byHand) + assert.deepStrictEqual(await unwrapWith(recipient, grant), dk) + assert.deepStrictEqual( + await unwrapWith(recipient, { ...grant, ...descriptor }), + dk, + 'checksummed and lowercase are one grant' + ) }) - it('carries a scope key just as well', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) - const sk = scopeKey(dk, 'invoices') + it('treats fields outside the descriptor as informational', async () => { + const dk = await dkOf() const recipient = generatePrivateKey() + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + const carried: Grant = { ...grant, ...{ dataSetId: '7' } } + assert.deepStrictEqual(await unwrapWith(recipient, carried), dk, 'not authenticated, so not checked') + }) + it('carries a scope key just as well, and holdingOf reads it back', async () => { + const dk = await dkOf() + const sk = scopeKey(dk, 'invoices') + const recipient = generatePrivateKey() const grant = await wrapTo(publicKeyOf(recipient), sk, grantDescriptor(ref, 'scope:invoices')) const opened = await unwrapWith(recipient, grant) - assert.deepEqual(opened, sk) - assert.notDeepEqual(opened, dk) + assert.deepStrictEqual(opened, sk) + assert.notDeepStrictEqual(opened, dk) + assert.equal(holdingOf(grant), 'scope') }) - it('rejects an unknown algorithm', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + it('rejects a grant it does not understand', async () => { const recipient = generatePrivateKey() - const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + const grant = await wrapTo(publicKeyOf(recipient), await dkOf(), descriptor) await assert.rejects(unwrapWith(recipient, { ...grant, alg: 'RSA-OAEP' } as never), /Unsupported grant algorithm/) + await assert.rejects(unwrapWith(recipient, { ...grant, v: 2 } as never), /Unsupported grant version/) }) -}) -describe('grantDescriptor', () => { - it('spells the id exactly as the envelope does', () => { - const descriptor = grantDescriptor(ref, 'dataset') - assert.equal(descriptor.clientDataSetId, '0x2a') - assert.equal(descriptor.clientDataSetId, pieceMetadata(ref, { salt: newSalt() })['foc/cds']) + it('only wraps a 32-byte node key', async () => { + await assert.rejects(wrapTo(publicKeyOf(generatePrivateKey()), new Uint8Array(16), descriptor), /32-byte node key/) }) +}) - it('carries what the descriptor is for, and nothing secret', () => { - assert.deepEqual(grantDescriptor(ref, 'scope:invoices'), { - v: 1, - node: 'scope:invoices', - chainId: ref.chainId, - service: ref.service, - payer: ref.payer, - clientDataSetId: '0x2a', - }) +describe('canonical AAD', () => { + it('opens a grant whose relay mangled the spelling of every field', async () => { + const dk = await dkOf() + const recipient = generatePrivateKey() + const sk = scopeKey(dk, 'caf\u00e9') + const grant = await wrapTo(publicKeyOf(recipient), sk, grantDescriptor(ref, 'scope:caf\u00e9')) + const mangled = { + ...grant, + node: 'scope:cafe\u0301', + chainId: '314159', + epoch: '0', + clientDataSetId: '0x002A', + service: grant.service.toUpperCase().replace('0X', '0x'), + } + assert.deepStrictEqual(await unwrapWith(recipient, mangled as never), sk) }) - it('round-trips through a wrap, and holdingOf reads the level back', async () => { - const dk = datasetKey(await datasetSecret(account, ref)) + it('binds the epoch, and refuses a grant without one', async () => { + const dk = await dkOf() const recipient = generatePrivateKey() - const descriptor = grantDescriptor(ref, 'scope:invoices') - const grant = await wrapTo(publicKeyOf(recipient), scopeKey(dk, 'invoices'), descriptor) - - assert.deepEqual(await unwrapWith(recipient, grant), scopeKey(dk, 'invoices')) - assert.equal(holdingOf(grant), 'scope') + const grant = await wrapTo(publicKeyOf(recipient), dk, grantDescriptor(ref, 'dataset')) + await assert.rejects(unwrapWith(recipient, { ...grant, epoch: 1 })) + const { epoch: _dropped, ...withoutEpoch } = grant + await assert.rejects(unwrapWith(recipient, withoutEpoch as never), /epoch/) + await assert.rejects(unwrapWith(recipient, { ...grant, chainId: 1.5 }), /chainId/) }) })