Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions .claude/rules/chain-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,26 +418,29 @@ The `{namespace}` is the chain identifier used in the registry (e.g., `'eip155'`
Each chain needs a `login/wallet-sign.ts` that derives a deterministic login key:

```ts
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/sdk'
import { createProtectedKey } from '@train-protocol/auth'

export const deriveKeyFrom{Chain}Wallet = async (
/* chain-specific wallet/provider */
): Promise<Buffer> => {
): Promise<CryptoKey> => {
// 1. Sign a fixed message: "I am using TRAIN"
// Use the chain's native signing mechanism
const signature = /* sign the message */
const signature = /* sign the message — get as Uint8Array */

// 2. Derive key material from signature
const inputMaterial = Buffer.from(/* signature bytes */)
const identitySalt = Buffer.from(IDENTITY_SALT, 'utf8')
return Buffer.from(deriveKeyMaterial(inputMaterial, identitySalt))
// 2. Import signature as non-extractable CryptoKey
const key = await createProtectedKey(signature)
signature.fill(0) // zero raw bytes
return key
}
```

Rules:
- Always use `"I am using TRAIN"` as the message content
- Always use `IDENTITY_SALT` and `deriveKeyMaterial` from the base SDK
- Always use `createProtectedKey` from `@train-protocol/auth` to import — do NOT run intermediate HKDF in JS memory
- Return `CryptoKey` (non-extractable), never raw `Uint8Array`
- Zero signature bytes after import where practical
- Define a minimal wallet/provider interface (don't import the full chain SDK for the type)
- See `.claude/rules/secret-derivation.md` for full security invariants

---

Expand Down Expand Up @@ -473,8 +476,8 @@ import type {
EventDerivedData,
} from '@train-protocol/sdk'

// Key derivation
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/sdk'
// Key derivation (for wallet-sign implementations)
import { createProtectedKey } from '@train-protocol/auth'
```

---
Expand Down
63 changes: 63 additions & 0 deletions .claude/rules/secret-derivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Secret Derivation & Hashlock Rules

Rules for working with the secret derivation system. Violations can lock user funds permanently.

---

## 1. NEVER Change the Derivation Scheme

The secret derivation chain determines the hashlock used to lock funds on-chain:

```
raw key bytes → createProtectedKey() → CryptoKey (non-extractable)
CryptoKey + nonce → deriveSecretFromCryptoKey() → secret → SHA256 → hashlock
```

**If the derivation output changes, existing on-chain locks become unredeemable.**

Do NOT:
- Change the HKDF parameters (hash algorithm, info string, output length) in `deriveSecretFromCryptoKey`
- Change the salt derivation from the timelock nonce (the `timelockToSalt` logic)
- Add, remove, or reorder HKDF steps in the derivation chain
- Change `secretToHashlock` (SHA-256 of the secret)

If a derivation scheme change is intentional (e.g. protocol upgrade), it must be:
1. Explicitly approved and documented
2. Versioned so old secrets can still be derived for existing locks
3. Coordinated with solver infrastructure

## 2. Master Key Must Stay Non-Extractable

The master key (`derivedKey` in `secretDerivationStore`) is a **non-extractable `CryptoKey`**. This is a security invariant.

Do NOT:
- Store raw key bytes (`Uint8Array`) in the store, context, or any persistent storage
- Export or extract the CryptoKey to bytes (e.g. via `crypto.subtle.exportKey`)
- Pass `extractable: true` to `importKey` for master keys
- Log, serialize, or transmit the master key

The only permitted operation on the master key is `crypto.subtle.deriveBits()`.

## 3. Per-Swap Secrets Are Short-Lived

The per-swap secret (output of `deriveSecretFromCryptoKey`) is a `Uint8Array` — it must be readable to compute the hashlock and send to the solver API. This is acceptable because:
- It's a single-use, per-swap value (not the master key)
- It's only in memory briefly during swap creation and secret reveal

Do NOT persist per-swap secrets to storage or global state.

## 4. CryptoKey Storage in IndexedDB

`SecureStorage.storeCryptoKey()` stores the `CryptoKey` directly in IndexedDB via structured cloning. The browser preserves the non-extractable flag across sessions.

Do NOT replace this with encrypt-then-store of raw bytes — that defeats the purpose.

## 5. Wallet-Sign Implementations

Each chain's `wallet-sign.ts` must:
1. Get signature bytes from the wallet
2. Call `createProtectedKey(signatureBytes)` to import as non-extractable CryptoKey
3. Zero the signature bytes after import (`signatureBytes.fill(0)`) where practical
4. Return the `CryptoKey`

Do NOT run intermediate HKDF in JS memory before importing — the raw bytes go straight into Web Crypto.
23 changes: 23 additions & 0 deletions packages/auth/src/key-derivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,30 @@ export const IDENTITY_SALT = 'train-identity-v1';
const HKDF_INFO = new TextEncoder().encode('train-signature-key-derivation');
const KEY_LENGTH = 32;

/**
* @deprecated Use `createProtectedKey` instead — it keeps key material out of JS memory.
*/
export const deriveKeyMaterial = (
ikm: Uint8Array,
salt: Uint8Array
): Uint8Array => hkdf(sha256, ikm, salt, HKDF_INFO, KEY_LENGTH);

/**
* Import raw key bytes as a non-extractable HKDF CryptoKey.
* The raw bytes are copied into an ArrayBuffer for Web Crypto;
* the caller should zero the original Uint8Array after this call.
* The returned CryptoKey can only be used for HKDF deriveBits — it cannot be exported or read.
*/
export const createProtectedKey = async (
rawKey: Uint8Array,
): Promise<CryptoKey> => {
const buf = new ArrayBuffer(rawKey.byteLength);
new Uint8Array(buf).set(rawKey);
return crypto.subtle.importKey(
'raw',
buf,
{ name: 'HKDF' },
false, // non-extractable
['deriveBits'],
);
};
14 changes: 7 additions & 7 deletions packages/auth/src/passkey.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { deriveKeyMaterial, IDENTITY_SALT } from './key-derivation'
import { createProtectedKey, IDENTITY_SALT } from './key-derivation'
import type { PasskeyCredentialStorage } from './storage'
import { base64URLStringToBuffer, bufferToBase64URLString } from './utils'

Expand Down Expand Up @@ -63,7 +63,7 @@ export const checkPrfSupport = async (): Promise<PrfSupportResult> => {

export interface RegisterPasskeyResult {
credentialId: string;
key?: Uint8Array;
key?: CryptoKey;
}

export const registerPasskey = async (
Expand Down Expand Up @@ -119,8 +119,8 @@ export const registerPasskey = async (

if (prfFirst) {
const ikm = new Uint8Array(prfFirst);
const identitySalt = new TextEncoder().encode(IDENTITY_SALT);
const key = new Uint8Array(deriveKeyMaterial(ikm, identitySalt));
const key = await createProtectedKey(ikm);
ikm.fill(0);
return { credentialId, key };
}

Expand All @@ -130,7 +130,7 @@ export const registerPasskey = async (
export const deriveKeyWithPasskey = async (
options?: { createIfMissing?: boolean },
storage?: PasskeyCredentialStorage
): Promise<{ key: Uint8Array; credentialId: string }> => {
): Promise<{ key: CryptoKey; credentialId: string }> => {
const createIfMissing = options?.createIfMissing !== false;

if (typeof window === 'undefined') throw new Error('Passkey auth must run in a browser');
Expand Down Expand Up @@ -164,8 +164,8 @@ export const deriveKeyWithPasskey = async (
if (!prfFirst) throw new Error('Passkey PRF extension not available in this browser/authenticator');

const ikm = new Uint8Array(prfFirst);
const identitySalt = new TextEncoder().encode(IDENTITY_SALT);
const key = new Uint8Array(deriveKeyMaterial(ikm, identitySalt));
const key = await createProtectedKey(ikm);
ikm.fill(0);

return { key, credentialId };
};
10 changes: 5 additions & 5 deletions packages/auth/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ type WalletSignConfigFor<N extends string> = N extends keyof WalletSignConfigMap
? WalletSignConfigMap[N]
: Record<string, unknown>

type WalletSignFactory = (config: any) => Promise<Uint8Array>
type WalletSignFactory = (config: any) => Promise<CryptoKey>

export class TrainAuth {
private walletSignRegistry = new Map<string, WalletSignFactory>()

registerWalletSign<N extends string>(
providerName: N,
factory: (config: WalletSignConfigFor<N>) => Promise<Uint8Array>,
factory: (config: WalletSignConfigFor<N>) => Promise<CryptoKey>,
): void {
this.walletSignRegistry.set(providerName, factory)
}

deriveKeyFromWallet<N extends string>(
providerName: N,
config: WalletSignConfigFor<N>,
): Promise<Uint8Array> {
): Promise<CryptoKey> {
const factory = this.walletSignRegistry.get(providerName)
if (!factory) {
throw new Error(
Expand All @@ -44,15 +44,15 @@ export const defaultTrainAuth = new TrainAuth()

export function registerWalletSign<N extends string>(
providerName: N,
factory: (config: WalletSignConfigFor<N>) => Promise<Uint8Array>,
factory: (config: WalletSignConfigFor<N>) => Promise<CryptoKey>,
): void {
return defaultTrainAuth.registerWalletSign(providerName, factory)
}

export function deriveKeyFromWallet<N extends string>(
providerName: N,
config: WalletSignConfigFor<N>,
): Promise<Uint8Array> {
): Promise<CryptoKey> {
return defaultTrainAuth.deriveKeyFromWallet(providerName, config)
}

Expand Down
7 changes: 3 additions & 4 deletions packages/blockchains/aztec/src/login/wallet-sign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth'
import { createProtectedKey } from '@train-protocol/auth'

/**
* Minimal interface for the Aztec wallet needed by the login flow.
Expand All @@ -17,7 +17,7 @@ export interface AztecWalletLike {
export const deriveKeyFromAztecWallet = async (
wallet: AztecWalletLike,
address: string,
): Promise<Uint8Array> => {
): Promise<CryptoKey> => {
if (!wallet) {
throw new Error('Aztec wallet not connected')
}
Expand All @@ -42,6 +42,5 @@ export const deriveKeyFromAztecWallet = async (
// Serialize the witness into bytes for key derivation
const witnessBuffer = authWitness.toBuffer()

const identitySalt = new TextEncoder().encode(IDENTITY_SALT)
return new Uint8Array(deriveKeyMaterial(witnessBuffer, identitySalt))
return createProtectedKey(witnessBuffer)
}
10 changes: 5 additions & 5 deletions packages/blockchains/evm/src/login/wallet-sign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth';
import { createProtectedKey } from '@train-protocol/auth';

export interface Eip1193Provider {
request(args: { method: string; params: unknown[] }): Promise<unknown>
Expand Down Expand Up @@ -38,7 +38,7 @@ export const deriveKeyFromEvmSignature = async (
provider: Eip1193Provider,
address: `0x${string}`,
options?: { sandbox?: boolean; currentChainId?: number }
): Promise<Uint8Array> => {
): Promise<CryptoKey> => {
const isSandbox = options?.sandbox ?? false;
const signingChainId = isSandbox ? 11155111 : 1;
const signingChainHex = isSandbox ? '0xAA36A7' : '0x1';
Expand Down Expand Up @@ -67,7 +67,7 @@ export const deriveKeyFromEvmSignature = async (

const signatureHex = signature.startsWith('0x') ? signature.slice(2) : signature;
const inputMaterial = hexToUint8Array(signatureHex);
const identitySalt = new TextEncoder().encode(IDENTITY_SALT);

return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt));
const key = await createProtectedKey(inputMaterial);
inputMaterial.fill(0);
return key;
};
7 changes: 3 additions & 4 deletions packages/blockchains/solana/src/login/wallet-sign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth'
import { createProtectedKey } from '@train-protocol/auth'

/**
* Minimal interface for the Solana wallet needed by the login flow.
Expand All @@ -15,14 +15,13 @@ export interface SolanaWalletLike {
*/
export const deriveKeyFromSolanaWallet = async (
wallet: SolanaWalletLike,
): Promise<Uint8Array> => {
): Promise<CryptoKey> => {
if (!wallet?.signMessage) {
throw new Error('Solana wallet does not support message signing')
}

const message = new TextEncoder().encode('I am using TRAIN')
const signature = await wallet.signMessage(message)

const identitySalt = new TextEncoder().encode(IDENTITY_SALT)
return new Uint8Array(deriveKeyMaterial(signature, identitySalt))
return createProtectedKey(signature)
}
7 changes: 3 additions & 4 deletions packages/blockchains/starknet/src/login/wallet-sign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth'
import { createProtectedKey } from '@train-protocol/auth'

/**
* Minimal interface for a Starknet account needed by the login flow.
Expand All @@ -18,7 +18,7 @@ export const deriveKeyFromStarknetWallet = async (
account: StarknetAccountLike,
_address: string,
options?: { chainId?: string },
): Promise<Uint8Array> => {
): Promise<CryptoKey> => {
if (!account) {
throw new Error('Starknet wallet not connected')
}
Expand Down Expand Up @@ -58,6 +58,5 @@ export const deriveKeyFromStarknetWallet = async (
})

const inputMaterial = new Uint8Array(sigBytes)
const identitySalt = new TextEncoder().encode(IDENTITY_SALT)
return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt))
return createProtectedKey(inputMaterial)
}
8 changes: 3 additions & 5 deletions packages/blockchains/tron/src/login/wallet-sign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth'
import { createProtectedKey } from '@train-protocol/auth'

/**
* Minimal interface for a Tron wallet needed by the login flow.
Expand All @@ -16,7 +16,7 @@ export interface TronWalletLike {
*/
export const deriveKeyFromTronWallet = async (
wallet: TronWalletLike,
): Promise<Uint8Array> => {
): Promise<CryptoKey> => {
if (!wallet) {
throw new Error('Tron wallet not connected')
}
Expand All @@ -28,7 +28,5 @@ export const deriveKeyFromTronWallet = async (
for (let i = 0; i < sigHex.length; i += 2) {
inputMaterial[i / 2] = parseInt(sigHex.substring(i, i + 2), 16)
}
const identitySalt = new TextEncoder().encode(IDENTITY_SALT)

return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt))
return createProtectedKey(inputMaterial)
}
4 changes: 2 additions & 2 deletions packages/react/src/hooks/useCreateSwap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from 'react'
import {
deriveSecretFromTimelock,
deriveSecretFromCryptoKey,
secretToHashlock,
bytesToHex,
formatUnits,
Expand Down Expand Up @@ -55,7 +55,7 @@ export function useCreateSwap(): UseCreateSwapResult {
}

const nonce = Date.now()
const secretBytes = deriveSecretFromTimelock(derivedKey, nonce)
const secretBytes = await deriveSecretFromCryptoKey(derivedKey, nonce)
const secret = bytesToHex(Array.from(secretBytes))
const hashlock = secretToHashlock(secret)

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/hooks/usePasskeyLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
import type { PrfSupportResult, PasskeyCredentialStorage } from '@train-protocol/auth'

export interface UsePasskeyLoginResult {
login: () => Promise<{ key: Uint8Array; credentialId: string }>
register: (displayName?: string) => Promise<{ credentialId: string; key?: Uint8Array }>
login: () => Promise<{ key: CryptoKey; credentialId: string }>
register: (displayName?: string) => Promise<{ credentialId: string; key?: CryptoKey }>
isSupported: boolean | null
prfDetails: PrfSupportResult | null
checkSupport: () => Promise<PrfSupportResult>
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/hooks/useRevealSecret.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from 'react'
import {
deriveSecretFromTimelock,
deriveSecretFromCryptoKey,
bytesToHex,
} from '@train-protocol/sdk'
import type { UserLockDetails } from '@train-protocol/sdk'
Expand Down Expand Up @@ -106,7 +106,7 @@ export function useRevealSecret(): UseRevealSecretResult {
}

try {
const secretBytes = deriveSecretFromTimelock(derivedKey, nonce)
const secretBytes = await deriveSecretFromCryptoKey(derivedKey, nonce)
const secret = bytesToHex(Array.from(secretBytes))

await apiClient.revealSecret(swap.hashlock, secret, swap.destinationSolverAddress)
Expand Down
Loading