@invisra/rendezvous-core
v0.1.1
Published
HOTP/TOTP-gated secret retrieval primitives for building a Rendezvous-style protected endpoint in any Node.js/TypeScript app.
Downloads
101
Readme
@invisra/rendezvous-core
HOTP/TOTP-gated secret retrieval primitives, extracted so any Node.js/TypeScript app — Next.js or otherwise — can build a "Rendezvous"-style protected endpoint: a route that only reveals a pre-encrypted message when the caller presents a valid daily HOTP code plus two rotating 60-second TOTP codes.
This package has no framework assumptions and doesn't read environment variables, touch the filesystem in surprising ways, or assume a particular routing layer — every function takes its inputs explicitly, so you decide where secrets and messages come from. The OTP/message-storage pieces use only node:crypto; the optional encryption helpers (see below) depend on age-encryption, a TypeScript implementation of the age file encryption format.
See apps/web in this repository for a full Next.js reference implementation using this package.
Install
Within this monorepo, apps/web depends on it via the npm workspace. To use it from another project, publish this package (npm publish from packages/core after npm run build) and install it normally:
npm install @invisra/rendezvous-coreUsage
1. Generate secrets
import { generateBase32Secret } from "@invisra/rendezvous-core";
const hotpSecret = generateBase32Secret();
const totp1Secret = generateBase32Secret();
const totp2Secret = generateBase32Secret();
// Store these somewhere your app can read them back (env vars, a secret
// manager, etc.) — this package never persists them for you.2. Verify codes and serve the message (e.g. a Next.js route handler)
// app/api/v1/[hotp]/[totp1]/[totp2]/route.ts
import { verifyCodes, readEncryptedMessage } from "@invisra/rendezvous-core";
const secrets = {
hotpSecret: process.env.HOTP_SECRET!,
totp1Secret: process.env.TOTP1_SECRET!,
totp2Secret: process.env.TOTP2_SECRET!,
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ hotp: string; totp1: string; totp2: string }> },
) {
const { hotp, totp1, totp2 } = await params;
if (!verifyCodes(secrets, hotp, totp1, totp2)) {
return new Response("Not found", { status: 404 });
}
const message = await readEncryptedMessage({ text: process.env.MESSAGE_TEXT });
return new Response(message, { status: 200 });
}3. Generate a valid URL as the authorized client
import { generateSecretUrl } from "@invisra/rendezvous-core";
const url = generateSecretUrl(secrets, "https://your-site.example");
// https://your-site.example/442726/653117/0948214. One-way encryption (optional)
For a dead-drop where the deployment owner is the only one who can read messages: generate a keypair once, keep the private key, and publish the public key so senders can encrypt to it before posting.
import { generateEncryptionKeyPair, encryptMessage, decryptMessage } from "@invisra/rendezvous-core";
// Once, at setup:
const { publicKey, privateKey } = await generateEncryptionKeyPair();
// Keep privateKey yourself. Serve publicKey to anyone with valid OTP codes
// (see apps/web's and apps/fastapi's /key endpoint for a working example).
// A sender, before POSTing:
const ciphertext = await encryptMessage(publicKey, "the actual secret");
// The owner, after GETting:
const plaintext = await decryptMessage(privateKey, ciphertext);encryptMessage returns ASCII-armored text (safe to pass straight into writeEncryptedMessage/readEncryptedMessage alongside everything else in this package). This is genuinely asymmetric: a sender who only has publicKey cannot decrypt anything, including messages they wrote themselves.
5. Per-thread secrets via ECDH (optional)
For a multi-user "admin creates a thread per person" model instead of one shared secret for the whole deployment: two parties each generate an identity keypair, exchange public keys once (out-of-band — a join code bundles this, see encodeJoinCode/decodeJoinCode), and each independently derives the same HOTP/TOTP secrets for a given thread ID without either secret ever crossing the network.
import { generateIdentityKeyPair, deriveThreadSecrets, generateRandomId } from "@invisra/rendezvous-core";
// Each party, once:
const mine = generateIdentityKeyPair();
// Exchange `mine.publicKey` with the other party (any channel — it's not secret).
// The thread creator:
const threadId = generateRandomId();
const secrets = deriveThreadSecrets(mine, theirPublicKey, threadId);
// The other party, independently, arrives at the identical secrets:
const sameSecrets = deriveThreadSecrets(theirs, mine.publicKey, threadId);
// sameSecrets deep-equals secrets — ECDH(a_priv, b_pub) === ECDH(b_priv, a_pub),
// then HKDF-derived per thread (salted with threadId) and per secret type.This identity keypair is X25519 raw key material (base64url via JWK), deliberately distinct from generateEncryptionKeyPair's age-format keypair above — one is for key agreement (ECDH), the other for message content encryption. A party typically needs both: the identity keypair to derive matching OTP secrets, and an age keypair so the other side can encrypt messages to them. See apps/cli's admin/identity commands for a full worked example (thread creation, join codes, replies).
API
generateBase32Secret(byteLength = 20): string— cryptographically random base32 secret suitable for HOTP/TOTP.generateHotp(secret: string, counter: number): string— RFC 4226 HOTP code for an arbitrary counter.generateTotp(secret: string, timestampMs = Date.now()): string— HOTP over a 60-second time-step counter.todayCounter(): number— current UTC date asYYYYMMDD, the counter this package uses for the HOTP code.generateSecretUrl(secrets: RendezvousSecrets, baseUrl: string): string— builds{baseUrl}/{hotp}/{totp1}/{totp2}for the current moment.verifyCodes(secrets: RendezvousSecrets, hotp: string, totp1: string, totp2: string): boolean— constant-time verification of all three codes.readEncryptedMessage(source: MessageSource): Promise<string>— returnssource.textif set, otherwise readssource.filePath.writeEncryptedMessage(filePath: string, message: string, options?: { maxBytes?: number }): Promise<void>— writesmessagetofilePathwith0600permissions; throws if empty or overmaxBytes(default 256KB).validateMessageSize(message: string, maxBytes?: number): void— the empty/too-large checkwriteEncryptedMessageuses internally, exported for callers storing messages somewhere other than a file (e.g. the thread routes' SQLite-backed message history) so every write path enforces the same cap.DEFAULT_MAX_MESSAGE_BYTES: number— the 256KB defaultmaxBytesvalue.generateEncryptionKeyPair(): Promise<EncryptionKeyPair>— generates a new X25519 age identity/recipient pair.encryptMessage(publicKey: string, plaintext: string): Promise<string>— encrypts to an age recipient, returning ASCII-armored ciphertext.decryptMessage(privateKey: string, armoredCiphertext: string): Promise<string>— decrypts ASCII-armored ciphertext with an age identity; throws if the key doesn't match.generateIdentityKeyPair(): IdentityKeyPair— generates a new X25519 keypair for ECDH thread-secret derivation (distinct from the age keypair above).generateRandomId(length = 32): string— cryptographically random alphanumeric ID, suitable for a thread ID or admin ID.deriveThreadSecrets(ownKeyPair: IdentityKeyPair, counterpartyPublicKey: string, threadId: string): RendezvousSecrets— derives per-thread HOTP/TOTP secrets via ECDH + HKDF; both parties get identical output from their own keypair + the other's public key + the sharedthreadId.encodeJoinCode(code: JoinCode): string/decodeJoinCode(value: string): JoinCode— packs/unpacks the values a thread creator hands the other party out-of-band (base64url JSON; throws on decode if malformed).
interface RendezvousSecrets {
hotpSecret: string;
totp1Secret: string;
totp2Secret: string;
}
interface MessageSource {
text?: string;
filePath?: string;
}
interface EncryptionKeyPair {
publicKey: string;
privateKey: string;
}
interface IdentityKeyPair {
publicKey: string;
privateKey: string;
}
interface JoinCode {
baseUrl: string;
threadId: string;
adminId: string;
adminThreadPublicKey: string;
adminMessagePublicKey: string;
}Security notes
- The HOTP code is valid for the entire UTC day (its counter is the date, not an incrementing, consumed value) — it functions as a same-day pass rather than a single-use code. Only the two TOTP codes rotate every 60 seconds.
verifyCodesuses a constant-time comparison, but nothing in this package rate-limits verification attempts — add that at your routing/proxy layer if you're exposing this publicly. Seeapps/web/lib/rate-limit.ts+apps/web/proxy.tsin this repo for a working example (per-IP sliding window, applied before code verification).- This package never terminates TLS. If codes and messages travel over plain HTTP, they're visible to anyone on the network path — always deploy behind HTTPS.
- The private key from
generateEncryptionKeyPairmust never be served over the network or given to senders — only the public key is meant to be published. This package doesn't enforce that; it's on you to keep the two separate (e.g. only the private key's environment variable stays off the deployed server, or off any endpoint handler entirely).
