// @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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ function parseMacFields(value, type) { if (typeof value !== 'string') { throw new SpaceMacError('missing or malformed MAC header'); } /** @type {Map} */ let dict; try { dict = parseDictionary(value); } catch { throw new SpaceMacError('malformed MAC header'); } /** @type {Map} */ 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; }