ruvnet/ruflo · error
pubkey must be 64 hex
Error message
pubkey must be 64 hex
What it means
The grant-key handler validates the member's Nostr pubkey against /^[0-9a-f]{64}$/i before deriving the NIP-44 conversation key; Nostr pubkeys are 32-byte x-only keys serialized as 64 hex characters. It throws when the pubkey is malformed, the wrong length, or in another encoding (bech32/nprofile, base64).
Solutions
- Use the member's raw 64-char hex pubkey (x-only, no prefix); convert npub1... to hex with a bech32 decoder (e.g. nip19 decode) first
- Trim whitespace and strip 0x prefixes; verify with /^[0-9a-f]{64}$/i before calling
- If only an npub is available, decode it: nip19.npubDecode(npub) -> hex pubkey
Example fix
// before
grant({ channel: 'prv:1a2b...', pubkey: 'npub1sg6plzptd64u6a8aa...' }); // throws
// after
const hex = nip19.npubDecode('npub1sg6plzptd64u6a8aa...'); // 64 hex chars
grant({ channel: 'prv:1a2b...', pubkey: hex }); Defensive patterns
Strategy: validation
Validate before calling
function isValidNostrPubkey(pk: string): boolean {
return /^[0-9a-f]{64}$/i.test(String(pk).trim());
} Type guard
function isHexPubkey(x: unknown): x is string {
return typeof x === 'string' && /^[0-9a-f]{64}$/i.test(x);
} Try / catch
try {
await grantKey({ channel, pubkey });
} catch (e) {
if (e.message === 'pubkey must be 64 hex') {
const hex = npubToHex(pubkey); // decode bech32 npub if needed
if (isHexPubkey(hex)) return grantKey({ channel, pubkey: hex.trim() });
}
throw e;
} Prevention
- Keep member pubkeys as raw 64-char hex everywhere; decode npub/nprofile at the boundary
- Trim whitespace on copy-paste; reject 0x prefixes
- Validate pubkeys when adding members to your roster, not at grant time
- Use a nip19 decoder utility to convert npub1... addresses to hex
When it happens
Trigger: Calling the grant-key tool with a pubkey that is shorter/longer than 64 hex chars, contains non-hex characters, has an npub/nprofile prefix, includes whitespace, or is empty.
Common situations: Pasting an npub1... bech32 address instead of the raw hex pubkey; truncating the pubkey during copy-paste; uppercase-hex is fine (i flag) but adding a 0x prefix is not; confusing the member pubkey with the local identity key.
Understand the failure class
Background: "invalid id" errors: invalid identifier format — why libraries reject IDs before lookup, and how to fix them — this error's family across 37 libraries.
Related errors
- channel key must be 32 bytes (64 hex)
- channel name must match [a-z0-9][a-z0-9._-]
- Invalid CLAUDE_FLOW_ENCRYPTION_KEY: expected 32-byte key as…
- actualUsd must be a non-negative finite number
- Agent config must include id, name, and type
AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-09-15).
Data as JSON: /api/errors/3e29a392b39af444.
Report an issue: GitHub.
Appendix: source
Thrown at v3/@claude-flow/cli/src/mcp-tools/x-federation-channels.ts:135
if (visibility === 'public') return { channel: publicChannelId(name), visibility, note: 'Any relay member can read this channel.' };
const key = newChannelKey(); const channel = privateChannelId(key);
const store = readStore(); store[channel] = { key, name, at: new Date().toISOString() }; writeStore(store);
return { channel, visibility, name, keyStoredAt: STORE_FILE(),
note: 'The key never leaves this machine. Grant others with x_federation_channel_grant. There is no recovery if the key file is lost, and no revocation — removing someone means rotating to a new channel.' };
},
},
{
name: 'x_federation_channel_grant',
description: "Grant a member access to a private channel by sealing its key to their pubkey with NIP-44 (ECDH), published as a ChannelGrant event only they can open. Use when adding a participant to an existing private channel. Publishing the raw key into a channel or a chat is wrong: it is a bearer secret, and anyone who sees it can read every past and future message, because there is no revocation.",
inputSchema: { type: 'object', properties: {
channel: { type: 'string', description: 'Private channel id (prv:<16 hex>) you hold the key for.' },
pubkey: { type: 'string', description: "The member's 64-hex Nostr pubkey." },
relayWs: { type: 'string', description: 'Relay URL; takes precedence over RUFLO_X_RELAY_WS (default wss://relay.ruv.io).' },
}, required: ['channel', 'pubkey'] },
handler: async (input) => {
const i = input as { channel: string; pubkey: string; relayWs?: string };
if (!isPrivateChannel(i.channel)) throw new Error('only private channels have keys to grant');
if (!/^[0-9a-f]{64}$/i.test(i.pubkey)) throw new Error('pubkey must be 64 hex');
const t = await loadTools(); if (!t) return degraded();
const entry = readStore()[i.channel];
if (!entry) throw new Error(`no key held for ${i.channel} — create it or accept a grant first`);
const { sk, pubkey } = loadOrCreateKey(t.nt as never, KEY_FILE());
const conv = t.nip44.v2.utils.getConversationKey(sk, i.pubkey);
const sealed = t.nip44.v2.encrypt(entry.key, conv);
const relay = RELAY_WS(i.relayWs);
const eventId = await relayCall(relay, sk, t.nt, (ws) => publishEvent(ws, t.nt, sk,
[['t', 'ruflo-swarm'], ['k', 'ChannelGrant'], ['c', i.channel], ['p', i.pubkey]],
JSON.stringify({ type: 'ChannelGrant', channel: i.channel, sealed, ts: new Date().toISOString() })));
return { ok: true, channel: i.channel, grantedTo: i.pubkey, grantedBy: pubkey, eventId,
note: 'Only that pubkey can open the seal. Grants are not revocable — rotate the channel to remove someone.' };
},
},
{
name: 'x_federation_channel_accept',
description: 'Accept private-channel grants addressed to your key: finds ChannelGrant events tagged to your pubkey, opens each with your own secret key, and caches the channel keys locally. Use when someone tells you they granted you a channel. Asking them to send you the key directly is wrong because it exposes a bearer secret in a channel you do not control.',
inputSchema: { type: 'object', properties: {View on GitHub (pinned to 2602b642d9)