npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-core

Usage

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/094821

4. 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 as YYYYMMDD, 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> — returns source.text if set, otherwise reads source.filePath.
  • writeEncryptedMessage(filePath: string, message: string, options?: { maxBytes?: number }): Promise<void> — writes message to filePath with 0600 permissions; throws if empty or over maxBytes (default 256KB).
  • validateMessageSize(message: string, maxBytes?: number): void — the empty/too-large check writeEncryptedMessage uses 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 default maxBytes value.
  • 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 shared threadId.
  • 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.
  • verifyCodes uses 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. See apps/web/lib/rate-limit.ts + apps/web/proxy.ts in 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 generateEncryptionKeyPair must 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).