@tetrac/p2p-sdk
v0.2.0
Published
Reusable, transport-agnostic peer-to-peer end-to-end encrypted messaging SDK for Next.js / React frontends. NaCl Box (X25519-XSalsa20-Poly1305) crypto with a pluggable transport interface and ready-made React hooks.
Maintainers
Readme
@tetrac/p2p-sdk
Transport-agnostic peer-to-peer end-to-end encrypted messaging for Next.js / React frontends.
NaCl Box (X25519-XSalsa20-Poly1305 — the same authenticated public-key crypto family as Signal), a small pluggable transport interface so you bring your own backend (Solana, WebSocket, REST, …), and ready-made React hooks.
v0.1.0 — audited baseline. This release ports the crypto from a security-audited implementation as-is. It is sound for confidentiality against eavesdroppers but does not yet provide forward secrecy or key rotation. Read Security & known limitations before shipping.
Install
npm install @tetrac/p2p-sdk
# react + react-dom are optional peers, only needed for @tetrac/p2p-sdk/reactHow it works
your wallet ──signMessage──▶ deriveEncryptionKeypair ──▶ X25519 keypair (in memory only)
│
P2PChatClient ── encrypt/decrypt + key exchange ─────────────┤
▼
Transport (you implement) ── moves opaque strings ──▶ Solana / WebSocket / REST / …- The SDK owns all crypto and the wire format (
PUBKEY:/ENC:/PLAIN:). - A
Transportonly moves opaquecontentstrings — it never sees plaintext or keys. chatIdis just astring; you decide how to derive it (e.g. from two wallet addresses).
Quick start (core, framework-neutral)
import { deriveEncryptionKeypair, P2PChatClient, InMemoryTransport } from "@tetrac/p2p-sdk";
// 1. Derive the encryption keypair from a wallet signature (deterministic, no persistence).
const address = wallet.publicKey.toBase58();
const keypair = await deriveEncryptionKeypair(address, wallet.signMessage);
// 2. Build a client over your transport (InMemoryTransport shown; see below for a real one).
const transport = new InMemoryTransport(address);
const client = new P2PChatClient({ transport, identity: { address, keypair } });
// 3. Open the chat (publishes your public key), send, and read.
const chatId = "alice-bob";
await client.ensureChat(chatId, peerAddress);
await client.send(chatId, "gm 🔒");
const messages = await client.getMessages(chatId);
// each: { sender, decrypted, isMe, isEncrypted, isKeyExchange, isPlaintext, ... }Quick start (React / Next.js)
"use client";
import { useEncryptionKeys, useE2EChat } from "@tetrac/p2p-sdk/react";
function Chat({ transport, chatId, wallet, peer }) {
const { identity, ready } = useEncryptionKeys({
address: wallet.publicKey?.toBase58(),
signMessage: wallet.signMessage,
});
const { messages, send, peerKeyFound, loading } = useE2EChat({
transport,
identity,
chatId,
peer,
pollIntervalMs: 30_000, // default
});
if (!ready) return <p>Unlock your wallet to start an encrypted chat.</p>;
return (
<div>
{!peerKeyFound && <p>⚠️ Peer hasn't shared a key yet — messages send as plaintext.</p>}
{messages.map((m) => (
<p key={m.messageIndex}>{m.isMe ? "You" : "Them"}: {m.decrypted ?? "🔒 unable to decrypt"}</p>
))}
<button onClick={() => send("hello")} disabled={loading}>Send</button>
</div>
);
}The Transport interface
Implement this to plug in any backend:
interface Transport {
sendMessage(args: { chatId: string; messageIndex: number; content: string }): Promise<{ id: string }>;
getMessages(chatId: string): Promise<RawMessage[]>; // ascending by messageIndex
createChat?(args: { chatId: string; peer: string }): Promise<{ id: string }>; // optional
getNextMessageIndex?(chatId: string): Promise<number>; // optional; default max(idx)+1
}InMemoryTransportships with the package for tests, demos, and prototyping.- A reference Solana adapter (wrapping an Anchor program) is in
examples/solana-transport.ts. It is documentation only and is not part of the published package — the SDK has zero Solana/Anchor dependencies.
API surface
Core (@tetrac/p2p-sdk):
| Export | Purpose |
|---|---|
| deriveEncryptionKeypair(address, signMessage, context?) | Wallet signature → deterministic X25519 keypair |
| encryptMessage / decryptMessage | NaCl Box encrypt → ENC:… / decrypt (null on tamper) |
| P2PChatClient | Key exchange + send + fetch/decrypt orchestration |
| InMemoryTransport | In-memory Transport for tests/demos |
| formatPubkeyMessage, parsePubkeyMessage, isEncryptedMessage, formatPlaintextMessage, … | Wire-format helpers |
| toBase64 / fromBase64 | Universal base64 (browser + Node) |
| Types: Transport, RawMessage, P2PIdentity, DecodedMessage, BoxKeyPair, SignMessage | |
React (@tetrac/p2p-sdk/react):
| Export | Purpose |
|---|---|
| useEncryptionKeys({ address, signMessage }) | Derive + hold the keypair / identity client-side |
| useE2EChat({ transport, identity, chatId, … }) | Managed chat: auto key-publish, 30s polling, send |
Security & known limitations
The crypto is sound: NaCl Box gives X25519 key agreement, XSalsa20 encryption, and a Poly1305 MAC
verified on every decrypt (tampering → null). Nonces are 24 random bytes. Secret keys are derived
on demand and held only in memory. An eavesdropper with full transport access and both public keys
cannot read messages (verified by test/eve-spy.test.ts).
This v0.1.0 baseline inherits these known weaknesses from the audit (PRD-p2p-audit.md), deferred
to a hardening release:
| ID | Severity | Limitation |
|---|---|---|
| W-1 | P0 | No forward secrecy. One static keypair per wallet; a key leak decrypts all past/future messages. |
| W-2 | P0 | No key rotation. Same keypair for the life of the wallet. |
| W-3 | P1 | Plaintext fallback. Messages sent before the peer's key is known go out as readable PLAIN:. Disable with allowPlaintextFallback: false. |
| W-4 | P1 | No memory zeroing. Secret keys are not wiped on unmount/lock. |
| W-5 | P2 | SHA-256 KDF, not HKDF — no domain separation/salt. |
| W-6 | P2 | Peer-key cache has no TTL (cleared only on new P2PChatClient). |
Roadmap (v0.2 hardening): per-chat ephemeral session keys for PFS (H-1), memory zeroing (H-2), HKDF (H-3), cache TTL (H-4), nonce counters / replay detection (H-5), per-message ratcheting (H-6).
If your threat model requires forward secrecy today, do not rely on this version.
Transport trust boundary (key-exchange / MITM)
Key exchange trusts your transport. getPeerKey adopts the most recent PUBKEY: from a sender
other than you. If the transport does not guarantee that only the two intended participants can
post (a 2-party on-chain PDA does; a permissive relay/REST backend may not), a third party could
publish a PUBKEY: and be adopted as the peer — a man-in-the-middle.
Mitigation — pin the expected peer so only that sender's key is trusted:
new P2PChatClient({ transport, identity, peerAddress: peerWalletAddress });
// React: useE2EChat({ transport, identity, chatId, peer: peerWalletAddress })Pinning is strongly recommended unless your transport already enforces participants. Verified by the
pins the peer key to the expected sender test in test/client.test.ts.
Local development with a Next.js app
Consume the SDK from a sibling app before publishing:
// app/package.json
{ "dependencies": { "@tetrac/p2p-sdk": "file:../tetrac-p2p-sdk" } }Build the SDK (npm run build, or npm run dev to watch), then npm install in the app.
Turbopack note (Next.js 16+): a
file:dependency may be symlinked outside the app's filesystem root, surfacing as a misleadingModule not found: Can't resolve '@tetrac/p2p-sdk/react'orSymlink … points out of the filesystem root. Fix by settingturbopack.root/outputFileTracingRootto the common parent dir, or prefer a pnpm/npm workspace overfile:.
Scripts
npm run build # tsup → dist/ (ESM + CJS + d.ts)
npm run dev # tsup --watch
npm run typecheck # tsc --noEmit
npm test # vitest runLicense
MIT © Tetrac
