wax-seal
v0.1.0
Published
Quick-deploy, easy-enough to be secure-enough on-site user-to-user inbox messaging. Sealed envelopes, forward secrecy, bilateral encryption. Zero external dependencies.
Maintainers
Readme
wax-seal
Quick-deploy, easy-enough to be secure-enough on-site user-to-user inbox messaging. Sealed envelopes with forward secrecy, bilateral encryption, Ed25519 signatures, and ephemeral key boxing. Zero external dependencies — built entirely on node:crypto.
What this does
Encrypt messages so that all metadata (subject, reason, tags, mentions, signature) is sealed inside the ciphertext. The database stores zero plaintext content or routing metadata beyond sender/recipient IDs and timestamps.
- Sealed envelopes — all message metadata encrypted, not just content
- Bilateral encryption — separate ciphertext for sender and recipient (both can decrypt their copy)
- Ed25519 signatures — content authenticity verification
- Forward secrecy — inbox key wrapping with on-read stripping
- Ephemeral key boxing — re-encrypt PII in API responses to client session keys
- Message padding — 256-byte block padding with random fill to reduce length leakage
Install
npm install wax-sealRequires Node.js >= 18.
Quick start
import {
init,
generateX25519Pair,
generateEd25519Pair,
encryptMessage,
decryptMessage,
verifyEnvelopeSignature,
} from 'wax-seal';
// If using server-managed keys (deriveServerKEK), configure the PII salt:
// init({ piiSalt: process.env.OOB_SALT_PII });
// Generate key pairs
const recipient = generateX25519Pair();
const sender = generateX25519Pair();
const signingKey = generateEd25519Pair();
// Encrypt (sealed envelope — all fields encrypted)
const encrypted = await encryptMessage(
{ content: 'Hello', subject: 'Greeting', tags: ['intro'] },
recipient.publicKey,
sender.publicKey, // optional: enables sender copy
signingKey.privateKey, // optional: Ed25519 signature
);
// Decrypt
const envelope = await decryptMessage(
encrypted.ciphertext_recipient,
encrypted.ephemeral_pub_recipient,
recipient.privateKey,
);
// => { type: 'message', content: 'Hello', subject: 'Greeting', ... }
// Verify signature
const valid = verifyEnvelopeSignature(envelope, signingKey.publicKey);
// => trueForward secrecy (inbox keys)
Wrap recipient ciphertext in an outer ECDH layer at send time. On first read, strip the outer layer and delete the inbox key — past messages become undecryptable even if the long-term key is later compromised.
import {
generateX25519Pair,
wrapWithInboxKey,
unwrapInboxKey,
encryptMessage,
decryptMessage,
} from 'wax-seal';
const inbox = generateX25519Pair();
const enc = await encryptMessage({ content: 'Secret' }, recipientPub);
// At send time: wrap
const wrapped = await wrapWithInboxKey(enc.ciphertext_recipient, inbox.publicKey);
// At read time: unwrap, then decrypt normally
const inner = await unwrapInboxKey(wrapped, inbox.privateKey);
const msg = await decryptMessage(inner, enc.ephemeral_pub_recipient, recipientPriv);Ephemeral key boxing (API response encryption)
Re-encrypt PII fields to a client's ephemeral session key before sending API responses. CDN/TLS terminators see only encrypted blobs.
import { generateEphemeralPair, boxResponse, unboxPii } from 'wax-seal';
// Client generates ephemeral key at login (stored in sessionStorage)
const client = generateEphemeralPair();
// Server boxes PII in response
const response = boxResponse(client.publicKey,
{ id: 42, ts: Date.now() }, // routing (plaintext)
{ email: '[email protected]' }, // PII (encrypted)
);
// Client unboxes
const pii = unboxPii(response.pii_box, client.privateKey);
// => { email: '[email protected]' }API reference
Configuration
init(config)— provide salt values (hex strings or Buffers). Required only forderiveServerKEK/generateUserKeys.
Key generation
generateX25519Pair()— returns{ publicKey, privateKey }as 64-char hexgenerateEd25519Pair()— returns{ publicKey, privateKey }as 64-char hexgenerateUserKeys(password?)— full key pair generation with KEK encryption (password-based or server-managed)
Message encryption
encryptMessage(payload, recipientPub, senderPub?, signingPriv?)— sealed envelope encryptiondecryptMessage(ciphertext, ephemeralPub, privateKey)— returns decrypted envelopeverifyEnvelopeSignature(envelope, signerPub)— returnstrue/false/null
Forward secrecy
wrapWithInboxKey(ciphertext, inboxPub)— outer ECDH wrappingunwrapInboxKey(wrapped, inboxPriv)— strip outer layerencryptInboxPrivateKey(inboxPriv, userPriv, inboxPub)— wrap inbox private key for DB storagedecryptInboxPrivateKey(blob, userPriv, inboxPub)— unwrap from DB
Ephemeral boxing
boxPii(object, clientPub)/unboxPii(blob, clientPriv)— box/unbox arbitrary PIIboxResponse(clientPub, routing, pii)— single-item API responseboxListResponse(clientPub, items)— list API response with indexed PII map
Low-level
x25519ECDH(privHex, pubHex)— raw ECDH shared secreted25519Sign(dataHex, privHex)/ed25519Verify(dataHex, sigHex, pubHex)— raw signingderiveKEK(password, salt)/encryptPrivateKey(hex, kek)/decryptPrivateKey(blob, kek)— KEK operations
Security model
- All envelope fields sealed — subject, reason, tags, mentions, signature encrypted inside ciphertext
- Fresh ephemeral X25519 keypair per message — compromise of long-term keys does not decrypt past messages
- ChaCha20-Poly1305 with HKDF-SHA256 key derivation
- 256-byte block padding with random fill — reduces length-based traffic analysis
- Backward-compatible decryption of AES-256-GCM (version byte
0x01) and legacy plaintext formats
Tests
npm test28 tests using node:test — no test framework dependency.
License
AGPL-3.0-or-later with additional terms. See LICENSE.
For closed-source or proprietary use, see LICENSE-COMMERCIAL.md.
