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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
441 lines · 12.3 KB Raw History
/**
 * Authenticating space reads made with a credential.
 *
 * Each read asks for a MAC with `Atproto-Space-Want-Mac`, carrying a fresh
 * P-256 key share. The repo host derives the same secret from the share and
 * the author's `#atproto_kem` key, DHKEM(P-256, HKDF-SHA256) as RFC 9180
 * §4.1 specifies, and returns the MAC of the response in `Atproto-Space-Mac`.
 * A record read is good when the MAC covers its uri and cid and its value
 * encodes to that cid.
 *
 * Self-contained on WebCrypto, like space-signature.js, because this package
 * installs as a global CLI and keeps its dependency list empty.
 */

import { base58btc, compressedP256 } from './space-signature.js';

export const RECORD_MAC_TYPE = 'atproto-space-record';
export const WANT_MAC_HEADER = 'Atproto-Space-Want-Mac';
export const MAC_HEADER = 'Atproto-Space-Mac';
const MAC_VERSION = 1;

const P256_P =
  0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
const P256_B =
  0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const B32 = 'abcdefghijklmnopqrstuvwxyz234567';

const encoder = new TextEncoder();

/** @param {...Uint8Array} parts */
function concat(...parts) {
  let size = 0;
  for (const part of parts) size += part.length;
  const out = new Uint8Array(size);
  let offset = 0;
  for (const part of parts) {
    out.set(part, offset);
    offset += part.length;
  }
  return out;
}

/** @param {number} n */
function uint16be(n) {
  return new Uint8Array([(n >> 8) & 0xff, n & 0xff]);
}

// "HPKE-v1" || "KEM" || 0x0010, the suite id of DHKEM(P-256, HKDF-SHA256).
const KEM_PREFIX = concat(
  encoder.encode('HPKE-v1'),
  encoder.encode('KEM'),
  uint16be(0x0010),
);

/**
 * @param {Uint8Array} key
 * @param {Uint8Array} data
 * @returns {Promise<Uint8Array>}
 */
async function hmacSha256(key, data) {
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    /** @type {BufferSource} */ (key),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  return new Uint8Array(
    await crypto.subtle.sign(
      'HMAC',
      cryptoKey,
      /** @type {BufferSource} */ (data),
    ),
  );
}

/**
 * @param {string} text
 * @returns {Uint8Array}
 */
function base58Decode(text) {
  const bytes = [0];
  for (const char of text) {
    const value = B58.indexOf(char);
    if (value === -1) throw new Error('invalid base58');
    let carry = value;
    for (let i = 0; i < bytes.length; i++) {
      carry += bytes[i] * 58;
      bytes[i] = carry & 0xff;
      carry >>= 8;
    }
    while (carry) {
      bytes.push(carry & 0xff);
      carry >>= 8;
    }
  }
  for (const char of text) {
    if (char !== '1') break;
    bytes.push(0);
  }
  return new Uint8Array(bytes.reverse());
}

/** @param {bigint} n */
function mod(n) {
  const r = n % P256_P;
  return r < 0n ? r + P256_P : r;
}

/**
 * @param {bigint} base
 * @param {bigint} exp
 */
function modPow(base, exp) {
  let result = 1n;
  let b = mod(base);
  let e = exp;
  while (e > 0n) {
    if (e & 1n) result = mod(result * b);
    b = mod(b * b);
    e >>= 1n;
  }
  return result;
}

/**
 * The uncompressed point (0x04 || x || y) a P-256 did:key names.
 * @param {string} didKey
 * @returns {Uint8Array}
 */
export function p256PointFromDidKey(didKey) {
  if (!didKey.startsWith('did:key:z')) throw new Error('invalid KEM key');
  const bytes = base58Decode(didKey.slice('did:key:z'.length));
  if (
    bytes.length !== 35 ||
    bytes[0] !== 0x80 ||
    bytes[1] !== 0x24 ||
    (bytes[2] !== 0x02 && bytes[2] !== 0x03)
  ) {
    throw new Error('KEM key must be a P-256 did:key');
  }
  let x = 0n;
  for (const b of bytes.subarray(3)) x = (x << 8n) | BigInt(b);
  if (x >= P256_P) throw new Error('invalid KEM key');
  // y² = x³ - 3x + b, and p ≡ 3 (mod 4), so y = rhs^((p + 1) / 4).
  const rhs = mod(x ** 3n - 3n * x + P256_B);
  let y = modPow(rhs, (P256_P + 1n) / 4n);
  if (mod(y * y) !== rhs) throw new Error('invalid KEM key');
  if (Number(y & 1n) !== (bytes[2] & 1)) y = P256_P - y;
  const out = new Uint8Array(65);
  out[0] = 0x04;
  out.set(bytes.subarray(3), 1);
  for (let i = 64; i >= 33; i--) {
    out[i] = Number(y & 0xffn);
    y >>= 8n;
  }
  return out;
}

/**
 * A fresh key share against the author's KEM key, and the secret it shares
 * with them. One per request.
 * @param {string} authorKemKey - the author's `#atproto_kem` did:key
 * @returns {Promise<{keyShare: string, sharedSecret: Uint8Array}>}
 */
export async function encapsulate(authorKemKey) {
  const authorPoint = p256PointFromDidKey(authorKemKey);
  const authorKey = await crypto.subtle.importKey(
    'raw',
    /** @type {BufferSource} */ (authorPoint),
    { name: 'ECDH', namedCurve: 'P-256' },
    false,
    [],
  );
  const ephemeral = /** @type {CryptoKeyPair} */ (
    await crypto.subtle.generateKey(
      { name: 'ECDH', namedCurve: 'P-256' },
      false,
      ['deriveBits'],
    )
  );
  const syncerPoint = new Uint8Array(
    await crypto.subtle.exportKey('raw', ephemeral.publicKey),
  );
  const dh = new Uint8Array(
    await crypto.subtle.deriveBits(
      { name: 'ECDH', public: authorKey },
      ephemeral.privateKey,
      256,
    ),
  );
  // HKDF-SHA256: extract under an empty salt (HashLen zero bytes), then one
  // expand block, since the secret is exactly one hash long.
  const prk = await hmacSha256(
    new Uint8Array(32),
    concat(KEM_PREFIX, encoder.encode('eae_prk'), dh),
  );
  const info = concat(
    uint16be(32),
    KEM_PREFIX,
    encoder.encode('shared_secret'),
    syncerPoint,
    authorPoint,
  );
  const sharedSecret = await hmacSha256(prk, concat(info, new Uint8Array([1])));
  return {
    keyShare: `did:key:z${base58btc(new Uint8Array([0x80, 0x24, ...compressedP256(syncerPoint)]))}`,
    sharedSecret,
  };
}

/**
 * `type="atproto-space-record", ver=1, key_share="did:key:zDn..."`
 * @param {string} type
 * @param {string} keyShare
 * @returns {string}
 */
export function formatWantMacHeader(type, keyShare) {
  return `type="${type}", ver=${MAC_VERSION}, key_share="${keyShare}"`;
}

/**
 * The MAC an `Atproto-Space-Mac` header carries, checked for its type and
 * version. Its members are a quoted string, an integer and a byte sequence,
 * none of which holds a comma.
 * @param {string|null} value
 * @param {string} type
 * @returns {Uint8Array}
 */
export function parseMacHeader(value, type) {
  if (!value) throw new Error('response carries no MAC');
  /** @type {Map<string, string>} */
  const fields = new Map();
  for (const member of value.split(',')) {
    const match = /^\s*([a-z_][a-z0-9_.*-]*)=(.*?)\s*$/.exec(member);
    if (!match) throw new Error('malformed MAC header');
    fields.set(match[1], match[2]);
  }
  if (fields.get('type') !== `"${type}"`) {
    throw new Error(`MAC header is not of type ${type}`);
  }
  if (fields.get('ver') !== String(MAC_VERSION)) {
    throw new Error('unsupported MAC version');
  }
  const mac = /^:([A-Za-z0-9+/]*={0,2}):$/.exec(fields.get('mac') ?? '');
  if (!mac) throw new Error('malformed MAC');
  return Uint8Array.from(atob(mac[1]), (c) => c.charCodeAt(0));
}

/**
 * The binary form of a base32 CID string.
 * @param {string} cid
 * @returns {Uint8Array}
 */
function cidBytes(cid) {
  if (!cid.startsWith('b')) throw new Error('unsupported CID encoding');
  const out = [];
  let bits = 0;
  let value = 0;
  for (const char of cid.slice(1)) {
    const index = B32.indexOf(char);
    if (index === -1) throw new Error('invalid CID');
    value = (value << 5) | index;
    bits += 5;
    if (bits >= 8) {
      out.push((value >> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return new Uint8Array(out);
}

/**
 * @param {Uint8Array} bytes
 * @returns {string}
 */
function base32(bytes) {
  let out = 'b';
  let bits = 0;
  let value = 0;
  for (const byte of bytes) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      out += B32[(value >> (bits - 5)) & 31];
      bits -= 5;
    }
  }
  if (bits > 0) out += B32[(value << (5 - bits)) & 31];
  return out;
}

/**
 * @param {number} major
 * @param {number} n
 * @returns {number[]}
 */
function cborHead(major, n) {
  const m = major << 5;
  if (n < 24) return [m | n];
  if (n < 0x100) return [m | 24, n];
  if (n < 0x10000) return [m | 25, n >> 8, n & 0xff];
  if (n < 0x100000000) {
    return [
      m | 26,
      (n >>> 24) & 0xff,
      (n >> 16) & 0xff,
      (n >> 8) & 0xff,
      n & 0xff,
    ];
  }
  const hi = Math.floor(n / 0x100000000);
  const lo = n % 0x100000000;
  return [
    m | 27,
    (hi >>> 24) & 0xff,
    (hi >> 16) & 0xff,
    (hi >> 8) & 0xff,
    hi & 0xff,
    (lo >>> 24) & 0xff,
    (lo >> 16) & 0xff,
    (lo >> 8) & 0xff,
    lo & 0xff,
  ];
}

/**
 * DAG-CBOR encoding of a record as JSON carries it, with `$link` and `$bytes`
 * objects standing for links and byte strings.
 * @param {unknown} value
 * @param {number[]} out
 */
function encodeDagCbor(value, out) {
  if (value === null) {
    out.push(0xf6);
  } else if (value === true || value === false) {
    out.push(value ? 0xf5 : 0xf4);
  } else if (typeof value === 'number') {
    if (!Number.isSafeInteger(value)) {
      throw new Error('a record holds a number that is not an integer');
    }
    if (value >= 0) out.push(...cborHead(0, value));
    else out.push(...cborHead(1, -1 - value));
  } else if (typeof value === 'string') {
    const bytes = encoder.encode(value);
    out.push(...cborHead(3, bytes.length), ...bytes);
  } else if (Array.isArray(value)) {
    out.push(...cborHead(4, value.length));
    for (const item of value) encodeDagCbor(item, out);
  } else if (typeof value === 'object') {
    const obj = /** @type {Record<string, unknown>} */ (value);
    const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
    if (
      keys.length === 1 &&
      keys[0] === '$link' &&
      typeof obj.$link === 'string'
    ) {
      const bytes = concat(new Uint8Array([0]), cidBytes(obj.$link));
      out.push(0xd8, 42, ...cborHead(2, bytes.length), ...bytes);
      return;
    }
    if (
      keys.length === 1 &&
      keys[0] === '$bytes' &&
      typeof obj.$bytes === 'string'
    ) {
      const b64 = obj.$bytes.replace(/-/g, '+').replace(/_/g, '/');
      const bytes = Uint8Array.from(
        atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, '=')),
        (c) => c.charCodeAt(0),
      );
      out.push(...cborHead(2, bytes.length), ...bytes);
      return;
    }
    // Keys in DAG-CBOR order: shorter first, then bytewise.
    const encoded = keys.map((k) => ({ k, bytes: encoder.encode(k) }));
    encoded.sort((a, b) => {
      if (a.bytes.length !== b.bytes.length)
        return a.bytes.length - b.bytes.length;
      for (let i = 0; i < a.bytes.length; i++) {
        if (a.bytes[i] !== b.bytes[i]) return a.bytes[i] - b.bytes[i];
      }
      return 0;
    });
    out.push(...cborHead(5, encoded.length));
    for (const { k, bytes } of encoded) {
      out.push(...cborHead(3, bytes.length), ...bytes);
      encodeDagCbor(obj[k], out);
    }
  } else {
    throw new Error(`a record holds an unencodable ${typeof value}`);
  }
}

/**
 * The CID (v1, dag-cbor, sha-256) of a record value.
 * @param {unknown} value
 * @returns {Promise<string>}
 */
export async function recordCid(value) {
  /** @type {number[]} */
  const out = [];
  encodeDagCbor(value, out);
  const digest = new Uint8Array(
    await crypto.subtle.digest('SHA-256', new Uint8Array(out)),
  );
  return base32(concat(new Uint8Array([0x01, 0x71, 0x12, 0x20]), digest));
}

/**
 * Whether a getRecord response is the record that was asked for, under a MAC
 * from the author's key: the MAC covers its uri and cid, and its value
 * encodes to that cid.
 * @param {{uri: string, cid: string, value: unknown}} record
 * @param {{uri: string, sharedSecret: Uint8Array, mac: Uint8Array}} params
 * @returns {Promise<boolean>}
 */
export async function verifyRecord(record, params) {
  if (record.uri !== params.uri) return false;
  try {
    const uri = encoder.encode(record.uri);
    const expected = await hmacSha256(
      params.sharedSecret,
      concat(
        encoder.encode(`${RECORD_MAC_TYPE}-v${MAC_VERSION}`),
        uint16be(uri.length),
        uri,
        cidBytes(record.cid),
      ),
    );
    if (expected.length !== params.mac.length) return false;
    let diff = 0;
    for (let i = 0; i < expected.length; i++)
      diff |= expected[i] ^ params.mac[i];
    if (diff !== 0) return false;
    return (await recordCid(record.value)) === record.cid;
  } catch {
    return false;
  }
}