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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
324 lines · 8.3 KB Raw History
// @pdsjs/spaces/mac - the MACs that authenticate space commits and records.
//
// A repo host does not sign a space commit or record. It MACs it under a secret
// it shares with one syncer for one request (see kem.js), so a leaked response
// proves nothing to a third party. The MAC travels in HTTP headers: the syncer
// asks with Atproto-Space-Want-Mac and the host answers with Atproto-Space-Mac.

import { cidToBytes } from '@pdsjs/core/repo';
import { parseDictionary, serializeDictionary } from './structured-fields.js';

export const COMMIT_VERSION = 1;
export const COMMIT_MAC_TYPE = 'atproto-space-repo';
export const RECORD_MAC_TYPE = 'atproto-space-record';
export const RECORD_MAC_VERSION = 1;

export const WANT_MAC_HEADER = 'atproto-space-want-mac';
export const MAC_HEADER = 'atproto-space-mac';

/** @typedef {typeof COMMIT_MAC_TYPE | typeof RECORD_MAC_TYPE} MacType */

/**
 * @typedef {Object} Commit
 * @property {number} ver
 * @property {string} repo - the author's DID
 * @property {string} space - the space URI
 * @property {string} rev
 * @property {Uint8Array} data - sha256 of the LtHash state
 */

export class SpaceMacError extends Error {
  /** @param {string} message */
  constructor(message) {
    super(message);
    this.name = 'SpaceMacError';
    this.code = 'BadSpaceMac';
  }
}

/**
 * @param {MacType} type
 * @returns {number}
 */
function macVersion(type) {
  return type === COMMIT_MAC_TYPE ? COMMIT_VERSION : RECORD_MAC_VERSION;
}

/**
 * @param {Uint8Array} key
 * @param {Uint8Array} data
 * @returns {Promise<Uint8Array>}
 */
export 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),
    ),
  );
}

/**
 * HKDF (RFC 5869) with SHA-256, extract then expand.
 *
 * An empty salt is replaced by HashLen zero bytes. RFC 5869 specifies that,
 * HMAC pads a short key with zeros to the same effect, and WebCrypto refuses
 * an empty HMAC key.
 *
 * @param {Uint8Array} ikm
 * @param {Uint8Array} salt
 * @param {Uint8Array} info
 * @param {number} length - at most 255 * 32
 * @returns {Promise<Uint8Array>}
 */
export async function hkdfSha256(ikm, salt, info, length) {
  const prk = await hmacSha256(salt.length ? salt : new Uint8Array(32), ikm);
  const out = new Uint8Array(length);
  /** @type {Uint8Array} */
  let prev = new Uint8Array(0);
  for (let i = 1, offset = 0; offset < length; i++) {
    prev = await hmacSha256(prk, concat(prev, info, new Uint8Array([i])));
    out.set(prev.subarray(0, length - offset), offset);
    offset += prev.length;
  }
  return out;
}

/**
 * ```
 * context = "atproto-space-repo-v1"
 *    || uint16be(len(space)) || space
 *    || uint16be(len(repo))  || repo
 *    || uint16be(len(rev))   || rev
 * mac = HMAC-SHA256(shared_secret, context || data)
 * ```
 *
 * @param {Uint8Array} sharedSecret
 * @param {Commit} commit
 * @returns {Promise<Uint8Array>}
 */
export function computeCommitMac(sharedSecret, commit) {
  return hmacSha256(
    sharedSecret,
    concat(
      utf8(`${COMMIT_MAC_TYPE}-v${COMMIT_VERSION}`),
      lengthPrefixed(commit.space),
      lengthPrefixed(commit.repo),
      lengthPrefixed(commit.rev),
      commit.data,
    ),
  );
}

/**
 * @param {Uint8Array} sharedSecret
 * @param {Commit} commit
 * @param {Uint8Array} mac
 * @returns {Promise<boolean>}
 */
export async function verifyCommitMac(sharedSecret, commit, mac) {
  return timingSafeEqual(await computeCommitMac(sharedSecret, commit), mac);
}

/**
 * ```
 * context = "atproto-space-record-v1" || uint16be(len(uri)) || uri
 * mac = HMAC-SHA256(shared_secret, context || record_cid_bytes)
 * ```
 *
 * @param {Uint8Array} sharedSecret
 * @param {string} uri
 * @param {string} cid
 * @returns {Promise<Uint8Array>}
 */
export function computeRecordMac(sharedSecret, uri, cid) {
  return hmacSha256(
    sharedSecret,
    concat(
      utf8(`${RECORD_MAC_TYPE}-v${RECORD_MAC_VERSION}`),
      lengthPrefixed(uri),
      cidToBytes(cid),
    ),
  );
}

/**
 * @param {Uint8Array} sharedSecret
 * @param {string} uri
 * @param {string} cid
 * @param {Uint8Array} mac
 * @returns {Promise<boolean>}
 */
export async function verifyRecordMac(sharedSecret, uri, cid, mac) {
  return timingSafeEqual(await computeRecordMac(sharedSecret, uri, cid), mac);
}

/**
 * The request header, e.g.
 * `type="atproto-space-repo", ver=1, key_share="did:key:zDna..."`.
 * @param {MacType} type
 * @param {string} keyShare
 * @returns {string}
 */
export function formatWantMacHeader(type, keyShare) {
  return serializeDictionary([
    ['type', type],
    ['ver', macVersion(type)],
    ['key_share', keyShare],
  ]);
}

/**
 * The `key_share` a request header carries.
 * @param {string|null|undefined} value
 * @param {MacType} type
 * @returns {string}
 * @throws {SpaceMacError}
 */
export function parseWantMacHeader(value, type) {
  const keyShare = parseMacFields(value, type).get('key_share');
  if (typeof keyShare !== 'string') {
    throw new SpaceMacError('missing or malformed key_share');
  }
  return keyShare;
}

/**
 * The response header, e.g. `type="atproto-space-repo", ver=1, mac=:AAEC...:`.
 * @param {MacType} type
 * @param {Uint8Array} mac
 * @returns {string}
 */
export function formatMacHeader(type, mac) {
  return serializeDictionary([
    ['type', type],
    ['ver', macVersion(type)],
    ['mac', mac],
  ]);
}

/**
 * The MAC a response header carries.
 * @param {string|null|undefined} value
 * @param {MacType} type
 * @returns {Uint8Array}
 * @throws {SpaceMacError}
 */
export function parseMacHeader(value, type) {
  const mac = parseMacFields(value, type).get('mac');
  if (!(mac instanceof Uint8Array)) {
    throw new SpaceMacError('missing or malformed mac');
  }
  return mac;
}

/**
 * Parse either header and check its `type` and `ver`.
 * @param {string|null|undefined} value
 * @param {MacType} type
 * @returns {Map<string, import('./structured-fields.js').SfBare>}
 */
function parseMacFields(value, type) {
  if (typeof value !== 'string') {
    throw new SpaceMacError('missing or malformed MAC header');
  }
  /** @type {Map<string, import('./structured-fields.js').SfMember>} */
  let dict;
  try {
    dict = parseDictionary(value);
  } catch {
    throw new SpaceMacError('malformed MAC header');
  }
  /** @type {Map<string, import('./structured-fields.js').SfBare>} */
  const fields = new Map();
  for (const [key, member] of dict) {
    if (member.kind !== 'item') {
      throw new SpaceMacError(`malformed MAC header field: ${key}`);
    }
    fields.set(key, member.value);
  }
  if (fields.get('type') !== type) {
    throw new SpaceMacError(`unsupported MAC type, expected ${type}`);
  }
  if (fields.get('ver') !== macVersion(type)) {
    throw new SpaceMacError(`unsupported MAC version for ${type}`);
  }
  return fields;
}

/**
 * Compares in time independent of where the inputs differ.
 * @param {Uint8Array} a
 * @param {Uint8Array} b
 * @returns {boolean}
 */
export function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

/**
 * @param {Uint8Array} a
 * @param {Uint8Array} b
 * @returns {boolean}
 */
export function bytesEqual(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}

/**
 * Big-endian, following the TLS convention for wire encodings.
 * @param {number} value
 * @returns {Uint8Array}
 */
export function uint16be(value) {
  if (value < 0 || value > 0xffff) {
    throw new Error(`value does not fit in a uint16: ${value}`);
  }
  return new Uint8Array([value >>> 8, value & 0xff]);
}

/**
 * @param {string} str
 * @returns {Uint8Array}
 */
export function utf8(str) {
  return new TextEncoder().encode(str);
}

/**
 * @param {string} str
 * @returns {Uint8Array}
 */
function lengthPrefixed(str) {
  const bytes = utf8(str);
  return concat(uint16be(bytes.length), bytes);
}

/**
 * @param {...Uint8Array} parts
 * @returns {Uint8Array}
 */
export 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;
}