diff --git a/packages/keysmith/README.md b/packages/keysmith/README.md new file mode 100644 index 00000000..899c2dfd --- /dev/null +++ b/packages/keysmith/README.md @@ -0,0 +1,219 @@ +# @filoz/keysmith + +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. + +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. + +```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; in a browser it +needs a secure context (HTTPS or localhost) for WebCrypto. + +## 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}) +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 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 + +```ts +import * as Keysmith from '@filoz/keysmith' + +const ref = { + chainId: 314, // mainnet + service: FWSS_ADDRESS, + payer: account.address, + clientDataSetId: Keysmith.newClientDataSetId(), // or choose your own +} + +// 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]: 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 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 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) +``` + +To share a limited **scope** instead of a whole dataset: + +```ts +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. 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 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 + +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 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. 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 | +| --- | --- | +| `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_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)` | The key-agreement key to publish, derived from a private key | +| `newSalt()` / `newClientDataSetId()` | Fresh public identifiers | + +## 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 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: 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 + 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. +- **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 + +```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 00000000..2fe794b7 --- /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 00000000..dcfae861 --- /dev/null +++ b/packages/keysmith/src/derive.ts @@ -0,0 +1,285 @@ +/** + * 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/sha2' +import type { Address, Hex } from 'viem' +import { bytesToHex, hexToBytes } from 'viem' +import type { + DatasetKeyMessage, + DatasetKeys, + DatasetKeysOptions, + DatasetRef, + GrantDescriptor, + GrantNode, + 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 + +/** + * 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 migration path. Pinned by a golden test. + */ +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, + } +} + +/** Signers already shown to sign deterministically, so later calls cost one prompt. */ +const verifiedSigners = new WeakSet() + +/** + * 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. + * + * 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, or does not produce an ECDSA signature. + */ +export async function datasetKeys( + signer: TypedDataSigner, + ref: DatasetRef, + options: DatasetKeysOptions = {} +): Promise { + const args = { + domain: DOMAIN, + types: DATASET_KEY_TYPES, + primaryType: 'DatasetKey' as const, + message: datasetKeyMessage(ref), + } + const first = await signer.signTypedData(args) + 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)}`, + } +} + +/** + * `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. + * + * 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 && 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 (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${lowS.toString(16).padStart(64, '0')}`), 32) + return out +} + +/** + * 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}${scopeName(scope)}`) + +/** + * 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 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}${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. + * 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': clientDataSetIdHex(ref.clientDataSetId), + 'foc/epoch': ref.epoch ?? 0, + ...(options.scope == null ? {} : { 'foc/scope': scopeName(options.scope) }), + 'foc/salt': lowerHex(options.salt), + } +} + +/** + * The descriptor naming what a grant unlocks, ready for `wrapTo()`. + * + * Build descriptors with `grantDescriptor()` rather than filling the + * 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: canonicalNode(node) as GrantNode, + chainId: ref.chainId, + epoch: ref.epoch ?? 0, + service: ref.service.toLowerCase() as Address, + payer: ref.payer.toLowerCase() as Address, + clientDataSetId: clientDataSetIdHex(ref.clientDataSetId), + } +} + +/** + * 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'] + 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 new file mode 100644 index 00000000..4e3c7c86 --- /dev/null +++ b/packages/keysmith/src/index.ts @@ -0,0 +1,52 @@ +/** + * 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 { 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, + DATASET_KEY_TYPES, + DOMAIN, + datasetKeyMessage, + datasetKeys, + grantDescriptor, + holdingOf, + keyForEnvelope, + lowSrs, + newClientDataSetId, + newSalt, + pieceKey, + pieceMetadata, + scopeKey, + scopeName, +} from './derive.ts' +export type { + DatasetKeyMessage, + DatasetKeys, + DatasetKeysOptions, + DatasetRef, + Grant, + GrantDescriptor, + GrantNode, + 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 00000000..ee87fc5e --- /dev/null +++ b/packages/keysmith/src/types.ts @@ -0,0 +1,92 @@ +import type { Address, Hex } from 'viem' + +/** Identifies the dataset a key belongs to */ +export interface DatasetRef { + 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 to start the derivation tree */ +export type DatasetKeyMessage = { + purpose: string + 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 + +/** 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. + */ +export interface TypedDataSigner { + signTypedData: (args: { + domain: { readonly name: string; readonly version: string } + types: Record + primaryType: 'DatasetKey' + message: DatasetKeyMessage + }) => Promise +} + +/** FEE envelope entries to enable key derivation/recovery */ +export interface PieceMetadata { + 'foc/v': number + 'foc/cds': 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 :-) +} + +/** 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. 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 + * one with `grantDescriptor()` and the narrow type applies. + */ +export interface GrantDescriptor { + v: 1 + node: string // 'dataset', or 'scope:'. + chainId: number // eg 314 for Filecoin mainnet + 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` +} + +/** A node key wrapped to one recipient. Safe to store or send anywhere. */ +export interface Grant extends GrantDescriptor { + 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. */ +export type Holding = 'dataset' | 'scope' diff --git a/packages/keysmith/src/wrap.ts b/packages/keysmith/src/wrap.ts new file mode 100644 index 00000000..c93f1779 --- /dev/null +++ b/packages/keysmith/src/wrap.ts @@ -0,0 +1,158 @@ +/** + * 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 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 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. + * + * 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 { + 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, pkR), epk, pkR) + 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, 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(sk, epkBytes), epkBytes, pkR) + const aesKey = await crypto.subtle.importKey('raw', buffer(kek), 'AES-GCM', false, ['decrypt']) + const out = new Uint8Array( + await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: buffer(hexToBytes(iv)), additionalData: aad(descriptor) }, + aesKey, + buffer(hexToBytes(ct)) + ) + ) + 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) + +/** 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) +} + +/** + * 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 n +} + +/** 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 00000000..83bd0860 --- /dev/null +++ b/packages/keysmith/test/derive.test.ts @@ -0,0 +1,314 @@ +import { secp256k1 } from '@noble/curves/secp256k1' +import assert from 'assert' +import { bytesToHex, hashDomain, hexToBytes } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { + DOMAIN, + datasetKeyMessage, + datasetKeys, + grantDescriptor, + holdingOf, + keyForEnvelope, + lowSrs, + newClientDataSetId, + newSalt, + pieceKey, + pieceMetadata, + scopeKey, + scopeName, +} 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, +} +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', () => { + 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('datasetKeys', () => { + it('derives the same key for the same wallet and dataset', async () => { + assert.deepStrictEqual(await dkOf(account, ref), await dkOf(account, ref)) + }) + + it('derives an unrelated key for another dataset', async () => { + 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()) + 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 () => `${r}${(++calls).toString(16).padStart(64, '0')}1b` as const, + } + 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 low = 0x0123456789abcdefn + const fromLow = lowSrs(asSig(low, '1b')) + const fromHigh = lowSrs(asSig(secp256k1.CURVE.n - low, '1c')) + assert.equal(fromLow.length, 64) + assert.deepStrictEqual(fromLow, fromHigh, 'both malleable forms must yield one key') + }) + + 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('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('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 = await dkOf(account, ref) + const invoices = scopeKey(dk, 'invoices') + const payroll = scopeKey(dk, 'payroll') + assert.notDeepStrictEqual(invoices, payroll) + + const salt = 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 = 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 = await dkOf(account, ref) + const salt = newSalt() + const metadata = pieceMetadata(ref, { salt }) + assert.equal(metadata['foc/scope'], undefined) + 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 = await dkOf(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 = 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() + assert.deepStrictEqual(pieceMetadata(ref, { salt, scope: 'invoices' }), { + 'foc/v': 1, + 'foc/cds': '0x2a', + 'foc/epoch': 0, + 'foc/scope': 'invoices', + 'foc/salt': salt, + }) + }) +}) + +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()) + assert.notEqual(newClientDataSetId(), newClientDataSetId()) + assert.equal(hexToBytes(newSalt()).length, 16) + }) +}) + +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('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('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 new file mode 100644 index 00000000..6f84d582 --- /dev/null +++ b/packages/keysmith/test/wrap.test.ts @@ -0,0 +1,144 @@ +import { secp256k1 } from '@noble/curves/secp256k1' +import assert from 'assert' +import { bytesToHex, hexToBytes } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +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()) +const ref: DatasetRef = { + chainId: 314159, + service: '0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3', + payer: account.address, + clientDataSetId: 42n, +} +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 = 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.deepStrictEqual(await unwrapWith(recipient, grant), dk) + }) + + it('survives JSON delivery', async () => { + const dk = await dkOf() + const recipient = generatePrivateKey() + const grant = await wrapTo(publicKeyOf(recipient), dk, descriptor) + 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 grant = await wrapTo(publicKeyOf(generatePrivateKey()), await dkOf(), descriptor) + await assert.rejects(unwrapWith(generatePrivateKey(), grant)) + }) + + it('refuses a grant relabelled as another node or dataset', async () => { + const recipient = generatePrivateKey() + 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 how the addresses were spelled', async () => { + const dk = await dkOf() + const recipient = generatePrivateKey() + const byHand: GrantDescriptor = { + ...descriptor, + service: '0xfcDDd1E5BC2658fB7483B8e2fa72d8368756F5A3', + payer: account.address, + clientDataSetId: '0x2A', + } + 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('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.deepStrictEqual(opened, sk) + assert.notDeepStrictEqual(opened, dk) + assert.equal(holdingOf(grant), 'scope') + }) + + it('rejects a grant it does not understand', async () => { + const recipient = generatePrivateKey() + 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/) + }) + + it('only wraps a 32-byte node key', async () => { + await assert.rejects(wrapTo(publicKeyOf(generatePrivateKey()), new Uint8Array(16), descriptor), /32-byte node key/) + }) +}) + +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('binds the epoch, and refuses a grant without one', async () => { + const dk = await dkOf() + const recipient = generatePrivateKey() + 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/) + }) +}) diff --git a/packages/keysmith/tsconfig.json b/packages/keysmith/tsconfig.json new file mode 100644 index 00000000..73c1e4a8 --- /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 ba71ee3d..5427134c 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: