chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/crypto - Cryptographic utilities for AT Protocol
// P-256 signing, encoding, JWK thumbprints
// P-256 curve order N (for low-S signature normalization)
const P256_N = BigInt(
'0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
);
const P256_N_DIV_2 = P256_N / 2n;
// Multicodec prefix for P-256 public keys (0x1200 varint-encoded)
export const P256_MULTICODEC = new Uint8Array([0x80, 0x24]);
// Multicodec prefix for secp256k1 public keys (0xe7 varint-encoded)
export const SECP256K1_MULTICODEC = new Uint8Array([0xe7, 0x01]);
/**
* Encode a private key for storage, tagging its curve. P-256 keys are stored
* as the bare 32-byte scalar (the historical format); secp256k1 keys carry
* their multicodec prefix so a stored key describes its own curve.
* @param {Uint8Array} privateKey - 32-byte scalar
* @param {'p256'|'secp256k1'} [curve]
* @returns {Uint8Array}
*/
export function encodeStoredKey(privateKey, curve = 'p256') {
if (curve === 'p256') return privateKey;
const tagged = new Uint8Array(SECP256K1_MULTICODEC.length + 32);
tagged.set(SECP256K1_MULTICODEC);
tagged.set(privateKey, SECP256K1_MULTICODEC.length);
return tagged;
}
/**
* Decode a stored private key into its curve and 32-byte scalar. Bare 32
* bytes are P-256 (every key stored before curves were tagged).
* @param {Uint8Array} stored
* @returns {{curve: 'p256'|'secp256k1', privateKey: Uint8Array}}
*/
export function decodeStoredKey(stored) {
if (stored.length === 32) return { curve: 'p256', privateKey: stored };
if (
stored.length === 34 &&
stored[0] === SECP256K1_MULTICODEC[0] &&
stored[1] === SECP256K1_MULTICODEC[1]
) {
return { curve: 'secp256k1', privateKey: stored.slice(2) };
}
if (
stored.length === 34 &&
stored[0] === P256_MULTICODEC[0] &&
stored[1] === P256_MULTICODEC[1]
) {
return { curve: 'p256', privateKey: stored.slice(2) };
}
throw new Error(`Unrecognized stored key format (${stored.length} bytes)`);
}
/**
* Convert bytes to hexadecimal string
* @param {Uint8Array} bytes - Bytes to convert
* @returns {string} Hex string
*/
export function bytesToHex(bytes) {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert hexadecimal string to bytes
* @param {string} hex - Hex string
* @returns {Uint8Array} Decoded bytes
*/
export function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
/**
* @param {Uint8Array} bytes
* @returns {bigint}
*/
function bytesToBigInt(bytes) {
let result = 0n;
for (const byte of bytes) {
result = (result << 8n) | BigInt(byte);
}
return result;
}
/**
* @param {bigint} n
* @param {number} length
* @returns {Uint8Array}
*/
function bigIntToBytes(n, length) {
const bytes = new Uint8Array(length);
for (let i = length - 1; i >= 0; i--) {
bytes[i] = Number(n & 0xffn);
n >>= 8n;
}
return bytes;
}
/**
* Encode bytes as base32lower string
* @param {Uint8Array} bytes - Bytes to encode
* @returns {string} Base32lower-encoded string
*/
export function base32Encode(bytes) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
let result = '';
let bits = 0;
let value = 0;
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
result += alphabet[(value >> bits) & 31];
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 31];
}
return result;
}
/**
* Decode base32lower string to bytes
* @param {string} str - Base32lower-encoded string
* @returns {Uint8Array} Decoded bytes
*/
export function base32Decode(str) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
let bits = 0;
let value = 0;
const output = [];
for (const char of str) {
const idx = alphabet.indexOf(char);
if (idx === -1) continue;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
output.push((value >> bits) & 0xff);
}
}
return new Uint8Array(output);
}
/**
* Encode bytes as base64url string (no padding)
* @param {Uint8Array} bytes - Bytes to encode
* @returns {string} Base64url-encoded string
*/
export function base64UrlEncode(bytes) {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
/**
* Decode base64url string to bytes
* @param {string} str - Base64url-encoded string
* @returns {Uint8Array} Decoded bytes
*/
export function base64UrlDecode(str) {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
const padded = pad ? base64 + '='.repeat(4 - pad) : base64;
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Generate a new P-256 key pair
* @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key
*/
export async function generateKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
// Export private key as raw bytes
const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
const privateBytes = base64UrlDecode(/** @type {string} */ (privateJwk.d));
// Export public key as compressed point
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicBytes = new Uint8Array(publicRaw);
const compressed = compressPublicKey(publicBytes);
return { privateKey: privateBytes, publicKey: compressed };
}
/**
* Import a raw P-256 private key for signing
* @param {Uint8Array} privateKeyBytes - 32-byte raw private key
* @returns {Promise<CryptoKey>} Web Crypto key handle
*/
export async function importPrivateKey(privateKeyBytes) {
// Validate private key length (P-256 requires exactly 32 bytes)
if (
!(privateKeyBytes instanceof Uint8Array) ||
privateKeyBytes.length !== 32
) {
throw new Error(
`Invalid private key: expected 32 bytes, got ${privateKeyBytes?.length ?? 'non-Uint8Array'}`,
);
}
// PKCS#8 wrapper for raw P-256 private key
const pkcs8Prefix = new Uint8Array([
0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
]);
const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32);
pkcs8.set(pkcs8Prefix);
pkcs8.set(privateKeyBytes, pkcs8Prefix.length);
return crypto.subtle.importKey(
'pkcs8',
/** @type {BufferSource} */ (pkcs8),
{ name: 'ECDSA', namedCurve: 'P-256' },
true, // extractable - needed to export public key for JWKS
['sign'],
);
}
/**
* The account signing key: either a WebCrypto P-256 key handle, or an
* injected-signer handle for curves WebCrypto cannot sign (secp256k1).
* The object form carries its own `sign` so core never needs a secp256k1
* dependency — the implementation is injected by the deployment, mirroring
* the SignatureVerifierPort on the verification side.
* @typedef {CryptoKey | {curve: 'secp256k1', sign: (data: Uint8Array) => Promise<Uint8Array>, publicKey: () => Promise<Uint8Array>}} SigningKey
*/
/**
* Curve of a signing key. WebCrypto handles are always P-256 here; injected
* signer handles declare their curve.
* @param {SigningKey} key
* @returns {'p256'|'secp256k1'}
*/
export function signingKeyCurve(key) {
return 'curve' in key && key.curve === 'secp256k1' ? 'secp256k1' : 'p256';
}
/**
* Sign data with the account signing key, returning a low-S normalized
* 64-byte (r || s) signature. P-256 keys sign with WebCrypto; injected
* signer handles sign with their own implementation (which must also
* produce low-S compact signatures).
* @param {SigningKey} privateKey - Signing key
* @param {Uint8Array} data - Data to sign
* @returns {Promise<Uint8Array>} 64-byte signature (r || s)
*/
export async function sign(privateKey, data) {
if ('curve' in privateKey && typeof privateKey.sign === 'function') {
return privateKey.sign(data);
}
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
/** @type {CryptoKey} */ (privateKey),
/** @type {BufferSource} */ (data),
);
const sig = new Uint8Array(signature);
const r = sig.slice(0, 32);
const s = sig.slice(32, 64);
const sBigInt = bytesToBigInt(s);
// Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent
// signature malleability (two valid signatures for same message)
if (sBigInt > P256_N_DIV_2) {
const newS = P256_N - sBigInt;
const newSBytes = bigIntToBytes(newS, 32);
const normalized = new Uint8Array(64);
normalized.set(r, 0);
normalized.set(newSBytes, 32);
return normalized;
}
return sig;
}
/**
* Compress public key (SEC1 format)
* @param {Uint8Array} uncompressed - 65-byte uncompressed public key (0x04 + x + y)
* @returns {Uint8Array} 33-byte compressed public key
*/
export function compressPublicKey(uncompressed) {
// uncompressed is 65 bytes: 0x04 + x(32) + y(32)
// compressed is 33 bytes: prefix(02 or 03) + x(32)
const x = uncompressed.slice(1, 33);
const y = uncompressed.slice(33, 65);
const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03;
const compressed = new Uint8Array(33);
compressed[0] = prefix;
compressed.set(x, 1);
return compressed;
}
/**
* Derive the curve and compressed public key for a stored private key.
* @param {Uint8Array} storedKeyBytes - As stored: bare P-256 scalar or curve-tagged
* @param {import('./ports.js').Secp256k1SignerPort|null} [k256Signer]
* @returns {Promise<{curve: 'p256'|'secp256k1', publicKey: Uint8Array}>}
*/
export async function derivePublicKey(storedKeyBytes, k256Signer = null) {
const { curve, privateKey } = decodeStoredKey(storedKeyBytes);
if (curve === 'secp256k1') {
if (!k256Signer) {
throw new Error('Stored key is secp256k1 but no k256Signer was injected');
}
return { curve, publicKey: k256Signer.getPublicKey(privateKey) };
}
const signingKey = await importPrivateKey(privateKey);
const jwk = await crypto.subtle.exportKey('jwk', signingKey);
const x = base64UrlDecode(/** @type {string} */ (jwk.x));
const y = base64UrlDecode(/** @type {string} */ (jwk.y));
const uncompressed = new Uint8Array(65);
uncompressed[0] = 0x04;
uncompressed.set(x, 1);
uncompressed.set(y, 33);
return { curve, publicKey: compressPublicKey(uncompressed) };
}
/**
* Compute JWK thumbprint (SHA-256) per RFC 7638.
* Creates a canonical JSON representation of EC key required members
* and returns the base64url-encoded SHA-256 hash.
* @param {{ kty: string, crv: string, x: string, y: string }} jwk - The EC public key in JWK format
* @returns {Promise<string>} The base64url-encoded thumbprint
*/
export async function computeJwkThumbprint(jwk) {
// RFC 7638: members must be in lexicographic order
const thumbprintInput = JSON.stringify({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
});
const hash = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(thumbprintInput),
);
return base64UrlEncode(new Uint8Array(hash));
}
/**
* HMAC-SHA256 a string with a secret.
* @param {string} data - Data to sign
* @param {string} secret - Secret key
* @returns {Promise<string>} Base64url-encoded MAC
*/
export async function hmacSha256(data, secret) {
const key = await crypto.subtle.importKey(
'raw',
/** @type {BufferSource} */ (new TextEncoder().encode(secret)),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const mac = await crypto.subtle.sign(
'HMAC',
key,
/** @type {BufferSource} */ (new TextEncoder().encode(data)),
);
return base64UrlEncode(new Uint8Array(mac));
}
/**
* Timing-safe string comparison using constant-time comparison.
* Compares hashes of strings to prevent timing attacks.
* @param {string} a - First string to compare
* @param {string} b - Second string to compare
* @returns {Promise<boolean>} True if strings are equal
*/
export async function timingSafeEqual(a, b) {
const encoder = new TextEncoder();
const aBytes = encoder.encode(a);
const bBytes = encoder.encode(b);
// Hash both to ensure constant-time comparison regardless of length
const [aHash, bHash] = await Promise.all([
crypto.subtle.digest('SHA-256', aBytes),
crypto.subtle.digest('SHA-256', bBytes),
]);
const aArr = new Uint8Array(aHash);
const bArr = new Uint8Array(bHash);
// Constant-time comparison
let result = 0;
for (let i = 0; i < aArr.length; i++) {
result |= aArr[i] ^ bArr[i];
}
return result === 0;
}