chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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;
}