// 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} 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(''); }