Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/pwa/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,13 @@ SLACK_WEBHOOK_URL=
# it never invents an address for someone to paste into their DNS settings.
# MOSHPIT_DNS_RESOLVERS=dns1.pit.moshcode.sh=203.0.113.7,dns2.pit.moshcode.sh=203.0.113.8
# MOSHPIT_DOH_URL=https://dns.pit.moshcode.sh/dns-query

# Guard addresses: the forwarding address a name publishes instead of its
# holder's own — see docs/contact-and-guard-addresses.md for the DNS half.
#
# Unset, contacts are still recorded and simply never published, which is the
# right behaviour in development: no key means no alias, and no alias means no
# address on a page. The domain must be one this Forward Email account holds,
# with MX pointed at them, or every mint fails.
FORWARDEMAIL_API_KEY=
MOSHPIT_GUARD_DOMAIN=moshcode.sh
21 changes: 21 additions & 0 deletions apps/pwa/src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ export const config = {
apiKey: process.env.RESEND_API_KEY || "",
from: process.env.RESEND_FROM || "moshcode <notify@moshcoding.com>",
},
// The mail host behind a name's guard address: `<token>@moshcode.sh` forwards
// to whatever the holder reads, so the real address is never published.
//
// The domain is separate from `origin` and `pitOrigin` on purpose. Those two
// are where the registry answers HTTP; this is where it answers mail, and the
// two need not be the same host -- moving the pit to another origin must not
// silently invalidate every contact address already printed on a page.
forwardEmail: {
apiKey: process.env.FORWARDEMAIL_API_KEY || "",
apiBase: (process.env.FORWARDEMAIL_API_BASE || "https://api.forwardemail.net").replace(/\/+$/, ""),
domain: (process.env.MOSHPIT_GUARD_DOMAIN || "moshcode.sh").trim().toLowerCase(),
},
push: {
vapidPublic: process.env.VAPID_PUBLIC || "",
vapidPrivate: process.env.VAPID_PRIVATE || "",
Expand All @@ -89,6 +101,15 @@ export const config = {
scope: process.env.COINPAY_OAUTH_SCOPE || "openid profile",
},
},
/**
* Whether a guard address can be minted right now.
*
* False is a working state, not a broken one: contacts are still recorded,
* they are simply not published until there is a mail host to forward them.
*/
get guardMailEnabled() {
return Boolean(this.forwardEmail.apiKey && this.forwardEmail.domain);
},
get coinpayLoginEnabled() {
return Boolean(this.coinpay.oauth.authorizeUrl && this.coinpay.oauth.clientId);
},
Expand Down
142 changes: 142 additions & 0 deletions apps/pwa/src/lib/forwardemail.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// The mail host behind a guard address.
//
// A guard address only works if something actually receives mail at
// `<token>@moshcode.sh` and forwards it on. That is Forward Email, which is
// already where profullstack.com's mail lives, and which exposes alias
// management over a plain REST API -- so an alias can be minted the moment a
// holder opts in rather than by hand.
//
// This module knows nothing about names, endings or the database. It creates,
// disables and destroys aliases at a domain, and reports what the host said.
// src/moshpit.mjs decides when to call it and records the outcome.
//
// Every function is safe to call when the API key is missing: it returns a
// `skipped` result rather than throwing, and the caller leaves the contact in
// `pending`, which publishes nothing. That is what development looks like, and
// it is also what production looks like for the window between this shipping
// and the key being set -- in both cases a contact is recorded and simply not
// advertised yet.
import { config } from "../config.mjs";

/** Forward Email authenticates with the API key as the basic-auth username and no password. */
const authHeader = (apiKey) => `Basic ${Buffer.from(`${apiKey}:`).toString("base64")}`;

/**
* Ten seconds, then give up.
*
* An opt-in happens inside a form post the holder is waiting on, so this cannot
* hang the request. Losing the race is not losing the work: the contact row is
* already written as `pending` before the host is called, and the reconcile
* path picks it up.
*/
const TIMEOUT_MS = 10_000;

const enabled = () => Boolean(config.forwardEmail.apiKey && config.forwardEmail.domain);

/**
* One call to the host, with the failure modes flattened into a result.
*
* Network errors, timeouts and HTTP errors all come back the same shape,
* because the caller does the same thing with all three -- record why, publish
* nothing, allow a retry. An exception here would abort a form post that has
* already successfully saved the holder's address.
*/
async function call(method, path, body) {
if (!enabled()) return { ok: false, skipped: true, error: "mail host not configured" };
const url = `${config.forwardEmail.apiBase}${path}`;
try {
const res = await fetch(url, {
method,
headers: {
authorization: authHeader(config.forwardEmail.apiKey),
"content-type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
if (!res.ok) {
// Their errors carry a `message`; fall back to the status so a holder is
// never shown an empty reason for something that plainly did not work.
const message = json?.message || json?.error || `mail host returned ${res.status}`;
return { ok: false, status: res.status, error: String(message).slice(0, 300) };
}
return { ok: true, data: json };
} catch (e) {
const reason = e?.name === "TimeoutError" ? "mail host timed out" : e?.message || "mail host unreachable";
return { ok: false, error: String(reason).slice(0, 300) };
}
}

/**
* Mint `<token>@domain` forwarding to `recipient`.
*
* `has_recipient_verification` is the load-bearing flag and it is deliberately
* always on. Without it, anyone could type a stranger's address into the
* contact field and have the registry forward mail to a person who never asked
* for any -- a spam relay wearing our domain. With it, the recipient gets one
* confirmation link and nothing flows until they click it, so publishing a
* guard address requires consent from the address itself and not just from
* whoever filled in the form.
*
* `error_code_if_disabled` is 550 rather than the default 250: when an alias is
* disabled, a sender should be told the mail did not arrive. Quietly accepting
* and discarding it is the wrong answer for an address published as a way to
* reach somebody.
*/
export async function createGuardAlias({ token, recipient, description = "", isEnabled = true }) {
const res = await call("POST", `/v1/domains/${encodeURIComponent(config.forwardEmail.domain)}/aliases`, {
name: token,
recipients: [recipient],
description,
// Passed rather than hardcoded true. A holder whose first save is `public`
// or `none` still gets the alias minted — the address is their identity and
// has to exist before the day they switch it on — but it must not be
// forwarding mail they have not asked it to forward.
is_enabled: Boolean(isEnabled),
has_recipient_verification: true,
error_code_if_disabled: 550,
labels: ["moshpit-guard"],
});
if (!res.ok) return res;
const id = res.data?.id || res.data?._id || null;
// An alias we cannot address later is only half created: without the id there
// is no way to repoint or revoke it, and revocation is the part that matters.
if (!id) return { ok: false, error: "mail host created the alias without returning an id" };
return { ok: true, id };
}

/** Repoint an existing alias, or switch it on and off, without changing its address. */
export async function updateGuardAlias({ id, recipient, isEnabled }) {
const body = {};
if (recipient !== undefined) body.recipients = [recipient];
if (isEnabled !== undefined) body.is_enabled = Boolean(isEnabled);
const res = await call(
"PUT",
`/v1/domains/${encodeURIComponent(config.forwardEmail.domain)}/aliases/${encodeURIComponent(id)}`,
body,
);
return res.ok ? { ok: true, id } : res;
}

/**
* Destroy the alias.
*
* Used when a contact is removed and when a name changes hands. A 404 counts as
* success: the goal is "this address forwards to nobody", and an alias the host
* has already lost is in that state. Treating it as a failure would leave the
* row stuck, retrying forever against something that does not exist.
*/
export async function deleteGuardAlias({ id }) {
const res = await call(
"DELETE",
`/v1/domains/${encodeURIComponent(config.forwardEmail.domain)}/aliases/${encodeURIComponent(id)}`,
);
if (!res.ok && res.status === 404) return { ok: true, id };
return res.ok ? { ok: true, id } : res;
}

/** Whether guard addresses can be minted at all right now. */
export const guardMailConfigured = enabled;
169 changes: 169 additions & 0 deletions apps/pwa/src/lib/moshpit-contact.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Reaching the holder of a name without learning who they are.
//
// Every registry has this problem and most solve it badly. Publishing the
// holder's real address -- which is what /api/moshpit/tlds did for years, for
// every ending, to anyone who asked -- turns a namespace into a mailing list
// and gives the holder no say in it. Publishing nothing at all is the other
// failure: a name that resolves to a broken server, or one somebody wants to
// buy, has nobody to tell.
//
// A guard address is the way between. The registry publishes
// `k7m2xqbn3f@moshcode.sh`, which forwards to whatever address the holder
// actually reads. The real one is never in a response, a page, or the log. The
// holder can turn it off, and the token survives being turned off, because the
// published address ends up in other people's address books and on pages we do
// not control.
//
// Deliberately free of any database or network import, for the same reason
// moshpit-name and moshpit-twin are: these are the rules, and the rules have to
// be checkable without a libSQL connection or an API key. src/moshpit.mjs owns
// storage and src/lib/forwardemail.mjs owns the mail host.
import { randomBytes } from "node:crypto";

import { normalizeDomain } from "./moshpit-twin.mjs";

/**
* What a contact may be showing, in the order of how much it gives away.
*
* `none` is a state rather than the absence of one: see the migration -- a
* contact taken down and put back up has to come back at the same address.
*/
export const CONTACT_VISIBILITY = ["none", "guard", "public"];

export const DEFAULT_VISIBILITY = "guard";

/**
* The alphabet a guard token is drawn from: digits and consonants, minus the
* pairs that get misread.
*
* No `0`/`o`, no `1`/`l`/`i`, because these are read off a page and typed into
* a mail client by hand. And no vowels at all, which is doing more work than it
* looks: it means a token can never spell a word, so a minted address can never
* collide with a mailbox a person holds at the same domain. `support`,
* `abuse`, `notify` and every other role address are unreachable from here by
* construction, rather than by a reserved list somebody has to remember to keep
* up to date.
*/
const GUARD_ALPHABET = "23456789bcdfghjkmnpqrstvwxz";

/**
* Ten characters, ~47 bits.
*
* The token is not a secret -- it is printed on a public page -- so this is not
* sized against an attacker who wants to guess one. It is sized against someone
* enumerating the whole space to harvest forwarding addresses, which 47 bits
* makes pointless, and against collision, which the UNIQUE constraint catches
* anyway.
*/
const TOKEN_LENGTH = 10;

const GUARD_TOKEN = new RegExp(`^[${GUARD_ALPHABET}]{${TOKEN_LENGTH}}$`);

/**
* A fresh guard token.
*
* Rejection sampling rather than `% alphabet.length`: 256 is not a multiple of
* 27, so modulo would make the first thirteen characters of the alphabet
* measurably likelier than the rest. It costs nothing to do properly here and
* the bias would be permanent in every address ever minted.
*/
export function mintGuardToken() {
const limit = 256 - (256 % GUARD_ALPHABET.length);
let out = "";
while (out.length < TOKEN_LENGTH) {
for (const byte of randomBytes(TOKEN_LENGTH)) {
if (byte >= limit) continue;
out += GUARD_ALPHABET[byte % GUARD_ALPHABET.length];
if (out.length === TOKEN_LENGTH) break;
}
}
return out;
}

/** Whether a string is shaped like a token this registry minted. */
export const isGuardToken = (value) => GUARD_TOKEN.test(String(value ?? ""));

/**
* Normalise a contact address, or null when it could never be one.
*
* Forgiving about what arrives -- the field is typed by hand and people paste
* `Anthony <a@b.c>` out of a mail client -- and strict about exactly two
* things: one `@`, and a domain that could exist. The domain half reuses
* normalizeDomain rather than a second regex, so a contact address and a
* clearnet twin agree on what a hostname is.
*
* No attempt at deciding whether the mailbox is real. That is not knowable from
* here, and the mail host answers it properly: an alias is created with
* recipient verification, so the address has to confirm before anything is
* forwarded to it.
*/
export function normalizeContactEmail(input) {
const raw = String(input ?? "").trim()
.replace(/^[^<]*<([^>]*)>.*$/, "$1") // a pasted "Name <addr>"
.replace(/^mailto:/i, "")
.trim();
if (!raw || raw.length > 254) return null;

const at = raw.lastIndexOf("@");
if (at <= 0 || at === raw.length - 1) return null;

const local = raw.slice(0, at);
// Lowercased whole. The local part is case-sensitive per RFC 5321 and case
// insensitive at every mail host anybody actually uses; folding it keeps one
// address from being stored twice and is what the mail host will do regardless.
const domain = normalizeDomain(raw.slice(at + 1));
if (!domain) return null;
if (local.length > 64) return null;
// No spaces, no angle brackets, no comma -- the characters that mean a header
// was pasted rather than an address. Quoted local parts are legal and refused:
// they are vanishingly rare and every one seen here so far has been a paste
// that went wrong.
if (!/^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/i.test(local)) return null;
if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) return null;

return `${local.toLowerCase()}@${domain}`;
}

/** `k7m2xqbn3f` + `moshcode.sh` -> `k7m2xqbn3f@moshcode.sh`. */
export function guardAddress(token, domain) {
const host = normalizeDomain(domain);
if (!isGuardToken(token) || !host) return null;
return `${token}@${host}`;
}

/**
* What a visitor is allowed to see, or null when the answer is nothing.
*
* The single place that decision is made. Every caller -- the name page, the
* ending page, the resolve API -- goes through here rather than reading
* `visibility` and deciding for itself, because there are two independent
* reasons to publish nothing and a caller that checks only one of them leaks.
*
* The second reason is the one easy to miss: a `guard` contact whose alias is
* not `live` yet has an address that does not exist. Publishing it would hand
* out a bouncing address, which is worse than publishing none, so the alias
* status gates it and not just the holder's choice.
*/
export function publishedContact(row, guardDomain) {
if (!row) return null;
if (row.visibility === "public") {
const address = normalizeContactEmail(row.email);
return address ? { kind: "public", address } : null;
}
if (row.visibility !== "guard") return null;
if (row.alias_status !== "live") return null;
const address = guardAddress(row.guard_token, guardDomain);
return address ? { kind: "guard", address } : null;
}

/**
* Whether a visibility string is one this registry understands.
*
* Returns the value rather than a boolean so callers read
* `normalizeVisibility(x) ?? fail()` and cannot accidentally write an
* unvalidated string into a CHECK-constrained column.
*/
export const normalizeVisibility = (input) => {
const value = String(input ?? "").trim().toLowerCase();
return CONTACT_VISIBILITY.includes(value) ? value : null;
};
Loading
Loading