Files
archy/docker/dojobay/server/domains.ts
T

161 lines
8.4 KiB
TypeScript

// =============================================================================
// Verified operator domains.
//
// One domain per operator, bound to their BIP47 payment code and proven in both
// directions, so neither half alone is enough:
//
// the domain asserts the code a TXT record at _dojobay.<domain> naming the
// payment code; publishing it needs control of
// the domain
// the code asserts the domain a wallet-signed statement naming the domain;
// producing it needs the PayNym's notification
// key
//
// The signature is permanent and the TXT record is the revocable half. Remove
// the record and the next sweep fails; after a grace period the badge drops,
// while the claim is kept so restoring the record restores the badge without
// re-signing. A domain that changes hands therefore stops being claimable by
// its old owner without anyone having to notice.
//
// The signed statement deliberately omits this instance's onion, so a proof is
// portable: a bootstrap import or peer sync carries it intact.
//
// A verified badge attests to CONTROL of a domain, not to trustworthiness: a
// lookalike domain verifies exactly as easily as a real one. Hence admin
// revocation, and punycode display for anything non-ASCII.
// =============================================================================
import { claimText, verifySignedUrlClaim } from "./crypto.ts";
import { txtRecordAgreed } from "./dns.ts";
import type { TxtAgreement } from "./dns.ts";
import type { DomainClaim, ProbeCfg } from "../types.js";
type LookupCfg = Partial<ProbeCfg>;
/** A normalised domain, or the reason the input could not be one. */
export type NormalisedDomain =
| { ok: true; domain: string; punycode: boolean; error?: undefined }
| { ok: false; error: string; domain?: undefined; punycode?: undefined };
export interface ClaimVerification {
ok: boolean;
stage?: "signature" | "dns";
error?: string;
hint?: string | null;
inconclusive?: boolean;
agreed?: number;
answered?: number;
}
export const TXT_PREFIX = "_dojobay";
export const CLAIM_VERSION = "dojobay-domain-v1";
export const RECHECK_MS = +(process.env.DOMAIN_RECHECK_HOURS || 24) * 3600 * 1000;
/** A claim awaiting its first successful lookup is retried far more often: the
* operator has just published a TXT record and is waiting for propagation. */
export const PENDING_RECHECK_MS = +(process.env.DOMAIN_PENDING_RECHECK_MINUTES || 5) * 60 * 1000;
export const GRACE_DAYS = +(process.env.DOMAIN_GRACE_DAYS || 7);
// Accept "example.com", "example.com/", "https://example.com" or a full URL and
// reduce it to the bare ASCII host. Rejects anything that cannot be a public
// domain an operator could publish a TXT record on.
export function normaliseDomain(input: unknown): NormalisedDomain {
let raw = String(input || "").trim().toLowerCase();
if (!raw) return { ok: false, error: "enter a domain" };
if (raw.includes(" ")) return { ok: false, error: "a domain cannot contain spaces" };
if (!/^[a-z][a-z0-9+.-]*:\/\//.test(raw)) raw = "https://" + raw;
let u: URL;
try { u = new URL(raw); } catch { return { ok: false, error: "that is not a valid domain" }; }
if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false, error: "use a plain domain, not a " + u.protocol.replace(":", "") + " URL" };
if (u.username || u.password) return { ok: false, error: "a domain cannot contain a username or password" };
if (u.port) return { ok: false, error: "leave the port off: verification uses DNS, not a web server" };
const host = u.hostname; // WHATWG URL gives punycode for IDN
if (host.endsWith(".onion")) return { ok: false, error: "an onion address cannot be verified by DNS; this field is for a clearnet domain you own" };
if (host === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.startsWith("[")) {
return { ok: false, error: "use a domain name, not an IP address" };
}
if (!/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) {
return { ok: false, error: "that is not a valid domain name" };
}
if (host.split(".").length < 2) return { ok: false, error: "include the full domain, for example example.com" };
return { ok: true, domain: host, punycode: /[^\x00-\x7F]/.test(String(input)) || host.includes("xn--") };
}
export const txtName = (domain: string): string => `${TXT_PREFIX}.${domain}`;
/** The Host/Name field in a DNS panel is relative to the zone, so most control
* panels want just this label. Handing over the fully-qualified name instead is
* the classic way to end up with _dojobay.example.com.example.com. */
export const txtHost = (): string => TXT_PREFIX;
export const txtValue = (paymentCode: string): string => `${CLAIM_VERSION} pm=${paymentCode}`;
export const signingText = (domain: string, paymentCode: string): string => claimText(`https://${domain}`, paymentCode);
// Does a TXT record claim this payment code? Tolerant of extra whitespace and
// of the record being wrapped in quotes by a DNS UI, strict about the code.
export function txtMatches(record: unknown, paymentCode: string): boolean {
const r = String(record || "").trim().replace(/^"|"$/g, "").replace(/\s+/g, " ");
if (!r.startsWith(CLAIM_VERSION)) return false;
const m = r.match(/\bpm=(PM8T[1-9A-HJ-NP-Za-km-z]+)/);
return !!m && m[1] === paymentCode;
}
// Full verification: the signature first (cheap, local, and the operator's most
// likely mistake), then DNS (slow, over Tor).
export async function verifyClaim(
{ domain, paymentCode, signed }: { domain: string; paymentCode: string; signed: string },
cfg: LookupCfg = {},
): Promise<ClaimVerification> {
const sig = verifySignedUrlClaim({ signed, expectedUrl: `https://${domain}`, paymentCode });
if (!sig.ok) return { ok: false, stage: "signature", error: sig.error };
const dns = await txtRecordAgreed(txtName(domain), (r) => txtMatches(r, paymentCode), cfg);
if (!dns.ok) {
return { ok: false, stage: "dns", inconclusive: !!dns.inconclusive, error: dns.error,
hint: dns.inconclusive ? null : `publish a TXT record at ${txtName(domain)} containing: ${txtValue(paymentCode)}` };
}
return { ok: true, agreed: dns.agreed, answered: dns.answered };
}
// DNS-only re-check for the periodic sweep: the signature is immutable once
// accepted, so there is nothing to re-verify locally.
export async function recheckClaim(claim: DomainClaim, cfg: LookupCfg = {}): Promise<TxtAgreement> {
return txtRecordAgreed(txtName(claim.domain), (r) => txtMatches(r, claim.paymentCode), cfg);
}
// Fold a re-check result into a claim, applying the grace period. Pure, so the
// policy is testable without any network.
export function applyRecheck(claim: DomainClaim, result: TxtAgreement, now: number = Date.now()): DomainClaim {
const next: DomainClaim = { ...claim, last_check: new Date(now).toISOString() };
if (result.inconclusive) {
// Could not tell. Change nothing except the timestamp, and say so.
next.last_result = "inconclusive: " + (result.error || "no detail");
return next;
}
if (result.ok) {
next.verified = true;
next.verified_at = next.verified_at || new Date(now).toISOString();
next.fail_since = null;
next.last_result = "ok";
return next;
}
next.last_result = result.error || "no matching TXT record";
next.fail_since = claim.fail_since || new Date(now).toISOString();
const failingMs = now - Date.parse(next.fail_since);
if (failingMs >= GRACE_DAYS * 86400 * 1000) next.verified = false;
return next;
}
export const isDue = (claim: DomainClaim, now: number = Date.now()): boolean => {
if (!claim.last_check) return true;
const since = now - Date.parse(claim.last_check);
return since >= (claim.verified ? RECHECK_MS : PENDING_RECHECK_MS);
};
// A URL is publishable as a card link only if it sits on the operator's verified
// domain (the domain itself or a subdomain of it). This is what stops a card
// carrying an unverifiable social profile while keeping "link to my own site".
export function urlOnDomain(url: unknown, domain: string | null | undefined): boolean {
if (!url || !domain) return false;
let u: URL;
try { u = new URL(String(url)); } catch { return false; }
if (u.protocol !== "https:" && u.protocol !== "http:") return false;
const h = u.hostname.toLowerCase();
return h === domain || h.endsWith("." + domain);
}