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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
109 lines · 3.7 KB Raw History
// App passwords (com.atproto.server.createAppPassword and friends).
//
// An app password is a generated credential a user hands to a third-party
// client instead of their account password. Sessions opened with one carry a
// restricted scope, so a leaked app password cannot mint further credentials
// or reach privileged account state.

import { base32Encode } from './crypto.js';

/**
 * Schema for app password storage. Defined once and imported by every adapter,
 * so the table cannot drift between SQLite and Durable Objects.
 *
 * The hash is indexed because verification looks a password up by hash rather
 * than iterating candidates — see hashAppPassword on why that is possible.
 */
export const APP_PASSWORD_SCHEMA_SQL = `
  CREATE TABLE IF NOT EXISTS app_passwords (
    did TEXT NOT NULL,
    name TEXT NOT NULL,
    hash TEXT NOT NULL,
    privileged INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL,
    PRIMARY KEY (did, name)
  );
  CREATE INDEX IF NOT EXISTS app_passwords_did_hash_idx ON app_passwords(did, hash);
`;

/** Session scope for a normal password or OAuth login. */
export const SCOPE_ACCESS = 'com.atproto.access';
/** Session scope for an app-password login. */
export const SCOPE_APP_PASS = 'com.atproto.appPass';
/** Session scope for an app-password login marked privileged at creation. */
export const SCOPE_APP_PASS_PRIVILEGED = 'com.atproto.appPassPrivileged';

/**
 * True when a scope came from an app password, privileged or not.
 * @param {string|null|undefined} scope
 * @returns {boolean}
 */
export function isAppPasswordScope(scope) {
  return scope === SCOPE_APP_PASS || scope === SCOPE_APP_PASS_PRIVILEGED;
}

/**
 * Generate an app password in atproto's `xxxx-xxxx-xxxx-xxxx` shape.
 *
 * Sixteen base32 characters is 80 bits of entropy, which is what actually
 * makes these safe — they are never user-chosen, so they cannot be guessed
 * or reused from elsewhere.
 * @returns {string}
 */
export function generateAppPassword() {
  // base32Encode yields 8 characters per 5 bytes; 10 bytes gives the 16 needed.
  const bytes = crypto.getRandomValues(new Uint8Array(10));
  const str = base32Encode(bytes).slice(0, 16);
  return [
    str.slice(0, 4),
    str.slice(4, 8),
    str.slice(8, 12),
    str.slice(12, 16),
  ].join('-');
}

/**
 * Hash an app password for storage and lookup.
 *
 * PBKDF2 rather than the reference implementation's scrypt: scrypt exists in
 * neither WebCrypto nor Workers, and @pdsjs/core has to run on both. The salt
 * is derived from the DID rather than random, which is what lets verification
 * be an indexed equality lookup instead of a scan-and-compare over every
 * password on the account. That costs the usual benefit of per-record salts —
 * two accounts sharing a password still hash differently, but two identical
 * passwords on the same account do not — which is acceptable here because the
 * values are 80-bit random strings, not human-chosen secrets.
 * @param {string} did - Account DID, used as the salt source
 * @param {string} password - The app password
 * @returns {Promise<string>} Hex-encoded derived key
 */
export async function hashAppPassword(did, password) {
  const encoder = new TextEncoder();
  const salt = await crypto.subtle.digest(
    'SHA-256',
    /** @type {BufferSource} */ (encoder.encode(did)),
  );

  const key = await crypto.subtle.importKey(
    'raw',
    /** @type {BufferSource} */ (encoder.encode(password)),
    'PBKDF2',
    false,
    ['deriveBits'],
  );

  const bits = await crypto.subtle.deriveBits(
    {
      name: 'PBKDF2',
      salt: salt.slice(0, 16),
      iterations: 100_000,
      hash: 'SHA-256',
    },
    key,
    256,
  );

  return [...new Uint8Array(bits)]
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}