~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
198 lines · 7.4 KB Raw History
// @pdsjs/core/email - Account email address and its verification codes
//
// An address is only the account's once a code sent to it comes back, so the
// PDS never records an address it has no evidence anyone can read. The code
// itself is never stored: only an HMAC of it, the same way app passwords keep
// a hash rather than the credential.

import { isAppPasswordScope, SCOPE_ACCESS } from './app-password.js';
import { hmacSha256 } from './crypto.js';
import { ScopePermissions } from './scope.js';

/** How long a verification code stays usable. */
export const EMAIL_CODE_TTL_MS = 15 * 60 * 1000;

/** The wait between sending one code and being allowed to send another. */
export const EMAIL_RESEND_COOLDOWN_MS = 30 * 1000;

// Deliberately loose: the code exchange is what proves an address, not this.
// It exists to catch a typo before a message is sent, nothing more.
const EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;

/**
 * @param {string} address
 * @returns {boolean}
 */
export function isValidEmail(address) {
  return typeof address === 'string' && EMAIL_REGEX.test(address);
}

/**
 * Generate a six-digit verification code.
 * @returns {string}
 */
export function generateEmailCode() {
  // Rejection-free: 6 digits from a 32-bit draw is biased by ~2^-22, which
  // does not matter for a code that expires in fifteen minutes.
  const [n] = crypto.getRandomValues(new Uint32Array(1));
  return String(n % 1000000).padStart(6, '0');
}

/**
 * HMAC a verification code, binding it to the address it was sent to so a
 * code minted for one address cannot confirm another.
 * @param {string} address
 * @param {string} code
 * @param {string} secret
 * @returns {Promise<string>}
 */
export function hashEmailCode(address, code, secret) {
  return hmacSha256(`email-code:${address.toLowerCase()}:${code}`, secret);
}

/**
 * Whether a session at this scope may read the account's own email address.
 * The account password sees it; app passwords never do; an OAuth session must
 * hold the `transition:email` (or granular `account:email`) scope. This mirrors
 * the reference PDS, where full repository access does not carry the email.
 * @param {string|null|undefined} scope
 * @returns {boolean}
 */
export function sessionSeesEmail(scope) {
  if (isAppPasswordScope(scope)) return false;
  if (scope === SCOPE_ACCESS) return true;
  return new ScopePermissions(scope || '').hasEmailAccess();
}

/**
 * The account's email state, normalized for a server that has never had one.
 * @param {import('./ports.js').ActorStoragePort} actorStorage
 * @returns {Promise<import('./ports.js').EmailState>}
 */
export async function readEmailState(actorStorage) {
  if (!actorStorage.getEmail) {
    return { address: null, verified: false, pending: null };
  }
  const state = await actorStorage.getEmail();
  return state || { address: null, verified: false, pending: null };
}

/**
 * The email fields to fold into a session response (`getSession`,
 * `createSession`, `refreshSession`). Empty unless the session's scope may see
 * the address and one is actually set.
 * @param {import('./ports.js').ActorStoragePort} actorStorage
 * @param {string|null|undefined} scope
 * @returns {Promise<{email?: string, emailConfirmed?: boolean}>}
 */
export async function emailSessionFields(actorStorage, scope) {
  if (!sessionSeesEmail(scope)) return {};
  const state = await readEmailState(actorStorage);
  if (!state.address) return {};
  return { email: state.address, emailConfirmed: Boolean(state.verified) };
}

/**
 * @param {string} value
 * @returns {string}
 */
function escapeHtml(value) {
  return value.replace(
    /[&<>]/g,
    (char) =>
      /** @type {Record<string, string>} */ ({
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
      })[char],
  );
}

/**
 * The HTML half of a confirmation message: a centered card that leads with the
 * code. Every rule is inlined and the layout is a single table, since mail
 * clients strip stylesheets and disagree about everything else. Colors read on
 * a light background, which is what a forced dark theme darkens most safely.
 * @param {{ hostname: string, heading: string, intro: string, code: string, notes: string[] }} params
 * @returns {string}
 */
function renderEmailHtml({ hostname, heading, intro, code, notes }) {
  const host = escapeHtml(hostname);
  const noteHtml = notes
    .map(
      (note) =>
        `<p style="margin:0 0 8px;font-size:12px;line-height:1.6;color:#8a8a93;">${escapeHtml(note)}</p>`,
    )
    .join('');
  return [
    '<!doctype html>',
    '<html>',
    '<body style="margin:0;background:#f4f4f7;padding:24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;">',
    '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;">',
    '<tr><td>',
    '<div style="background:#ffffff;border:1px solid #ececf1;border-radius:16px;padding:32px;">',
    `<div style="font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#6b6b76;">${host}</div>`,
    `<h1 style="margin:8px 0 16px;font-size:20px;font-weight:800;color:#18181b;">${escapeHtml(heading)}</h1>`,
    `<p style="margin:0 0 20px;font-size:14px;line-height:1.6;color:#3f3f46;">${escapeHtml(intro)}</p>`,
    '<div style="text-align:center;background:#f4f4ff;border:1px solid #e0e0ff;border-radius:12px;padding:18px;margin:0 0 20px;">',
    `<div style="font-family:ui-monospace,'SFMono-Regular',Menlo,monospace;font-size:32px;font-weight:700;letter-spacing:0.3em;padding-left:0.3em;color:#4f46e5;">${escapeHtml(code)}</div>`,
    '</div>',
    noteHtml,
    '</div>',
    `<p style="text-align:center;margin:16px 0 0;font-size:11px;color:#a1a1aa;">Sent by ${host}</p>`,
    '</td></tr>',
    '</table>',
    '</body>',
    '</html>',
  ].join('\n');
}

/**
 * The message sent to a new address, as a plain-text body and an HTML one.
 * @param {{ code: string, hostname: string }} params
 * @returns {{subject: string, text: string, html: string}}
 */
export function renderVerificationEmail({ code, hostname }) {
  const intro = `Your confirmation code for ${hostname} is:`;
  const notes = [
    'Enter it on the account page to finish adding this address. The code expires in 15 minutes.',
    'If you did not ask for this, nothing has changed and you can ignore this message.',
  ];
  return {
    subject: `Confirm your email for ${hostname}`,
    text: [intro, '', `    ${code}`, '', ...notes].join('\n'),
    html: renderEmailHtml({
      hostname,
      heading: 'Confirm your email',
      intro,
      code,
      notes,
    }),
  };
}

/**
 * The message sent before signing a PLC operation, in both plain text and HTML.
 * @param {{ code: string, hostname: string }} params
 * @returns {{subject: string, text: string, html: string}}
 */
export function renderPlcOperationEmail({ code, hostname }) {
  const intro =
    `Something asked ${hostname} to sign a change to your identity: ` +
    'the keys or the server your DID points at. Your confirmation code is:';
  const notes = [
    'The code expires in 15 minutes.',
    'If this was not you, do not share the code. Nothing changes without it, and you should change your account password.',
  ];
  return {
    subject: `Confirm an identity change on ${hostname}`,
    text: [intro, '', `    ${code}`, '', ...notes].join('\n'),
    html: renderEmailHtml({
      hostname,
      heading: 'Confirm an identity change',
      intro,
      code,
      notes,
    }),
  };
}