chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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('');
}