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

  1. Generate the key with newChannelKey() (randomBytes(32).toString('hex')) so it is exactly 64 hex chars
  2. Strip whitespace and any 0x prefix from the key before use; verify length with /^[0-9a-f]{64}$/i
  3. 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

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


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)