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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
419 lines · 12.8 KB Raw History
// @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;
}