ruvnet/ruflo · error
channel key must be 32 bytes (64 hex)
Error message
channel key must be 32 bytes (64 hex)
What it means
privateChannelId derives a private channel id (prv:<16-hex>) by hashing a 32-byte channel key supplied as 64 hex characters. It throws when Buffer.from(keyHex,'hex') does not yield exactly 32 bytes — i.e. the key string is not valid 64-char hex, or represents a different byte length. This guards NIP-44 encryption, which requires a 256-bit channel key.
Solutions
- Generate the key with newChannelKey() (randomBytes(32).toString('hex')) so it is exactly 64 hex chars
- Strip whitespace and any 0x prefix from the key before use; verify length with /^[0-9a-f]{64}$/i
- If the key is base64 or another encoding, convert to 64-char lowercase hex first
Example fix
// before
privateChannelId('0x' + key.slice(0, 32)); // throws: not 32 bytes
// after
const key = newChannelKey(); // 64 hex chars
if (!/^[0-9a-f]{64}$/i.test(key)) throw new Error('bad channel key');
privateChannelId(key.trim().replace(/^0x/, '')); Defensive patterns
Strategy: validation
Validate before calling
function isValidChannelKey(keyHex: string): boolean {
return /^[0-9a-f]{64}$/i.test(String(keyHex).trim().replace(/^0x/, ''));
} Try / catch
try {
const id = privateChannelId(keyHex);
} catch {
if (!isValidChannelKey(keyHex)) {
keyHex = newChannelKey(); // generate a proper 32-byte key
}
const id = privateChannelId(keyHex.trim().replace(/^0x/, ''));
} Prevention
- Generate keys only via newChannelKey()
- Never truncate, prefix (0x), or re-encode keys when storing/sharing them
- Distinguish channel keys from Nostr keys and other hex secrets in your config naming
- Validate with /^[0-9a-f]{64}$/i at config load time, before any crypto call
When it happens
Trigger: Creating or joining a private channel with a key that is not 64 hex chars (e.g. 32-hex half key, base64-encoded key, key with 0x prefix or whitespace); passing a randomBytes(16) key; truncated or hand-trimmed key strings.
Common situations: Generating the key with the wrong byte length (16 or 64 bytes instead of 32); storing the key in config with quotes/whitespace/0x prefix; confusing the channel key with a Nostr private key or another hex secret; copying only part of the key from a shared secret.
Understand the failure class
Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.
Related errors
- pubkey must be 64 hex
- channel name must match [a-z0-9][a-z0-9._-]
- Invalid CLAUDE_FLOW_ENCRYPTION_KEY: expected 32-byte key as…
- node-identity.json is malformed
- actualUsd must be a non-negative finite number
AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-09-15).
Data as JSON: /api/errors/e42c56330e8b8ca8.
Report an issue: GitHub.
Appendix: source
Thrown at v3/@claude-flow/cli/src/mcp-tools/x-federation-channels.ts:59
const degraded = () => ({ degraded: true, reason: 'nostr-tools not installed', hint: 'npm i nostr-tools (secp256k1 + NIP-44 are not in node:crypto)' });
/** Locally cached channel keys, 0600. Losing this file loses the channels in it — by design. */
export type ChannelStore = Record<string, { key: string; name?: string; grantedBy?: string; at: string }>;
export function readStore(file = STORE_FILE()): ChannelStore {
if (!existsSync(file)) return {};
try { return JSON.parse(readFileSync(file, 'utf8')) as ChannelStore; } catch { return {}; }
}
export function writeStore(store: ChannelStore, file = STORE_FILE()): void {
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
}
export function publicChannelId(name: string): string {
if (!CHANNEL_NAME_RE.test(String(name))) throw new Error('channel name must match [a-z0-9][a-z0-9._-]{0,63}');
return `pub:${name}`;
}
export function privateChannelId(keyHex: string): string {
const k = Buffer.from(keyHex, 'hex');
if (k.length !== 32) throw new Error('channel key must be 32 bytes (64 hex)');
return `prv:${createHash('sha256').update(k).digest('hex').slice(0, 16)}`;
}
export function newChannelKey(): string { return randomBytes(32).toString('hex'); }
export function isPrivateChannel(id: string): boolean { return String(id).startsWith('prv:'); }
async function relayCall<T>(relayWs: string, sk: Uint8Array, nt: Nt, fn: (ws: WsLike) => Promise<T>): Promise<T> {
const { default: WebSocket } = await import('ws');
const ws = new WebSocket(relayWs, { perMessageDeflate: false }) as unknown as WsLike;
await new Promise<void>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('relay auth timeout')), 15000);
ws.on('message', (d: Buffer) => {
const m = JSON.parse(d.toString());
if (m[0] === 'AUTH' && typeof m[1] === 'string') {
ws.send(JSON.stringify(['AUTH', nt.finalizeEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [['relay', relayWs], ['challenge', m[1]]], content: '' }, sk)]));
} else if (m[0] === 'OK') { clearTimeout(t); m[2] ? resolve() : reject(new Error(`relay refused auth: ${m[3] || 'not a member'}`)); }
});
ws.on('error', (e: Error) => { clearTimeout(t); reject(e); });
});View on GitHub (pinned to 2602b642d9)