chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/spaces/http-signature - the HTTP message signature a space credential
// travels with (RFC 9421).
//
// Every request presenting a credential signs it together with the DID the
// request is addressed to, `Atproto-Space-Audience`, under the label and tag
// `atproto-space-credential`, ecdsa-p256-sha256, by the P-256 key the
// credential names in `cnf.kid`. A host given the credential to serve one
// account cannot present it to another.
//
// A signature covers no method, URL or nonce, so it may be reused for one
// credential and one audience while the credential is valid.
import { compressPublicKey, sign } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { parseDidKey } from '@pdsjs/core/verify';
import {
parseDictionary,
serializeInnerList,
toPaddedBase64,
} from './structured-fields.js';
import { SpaceTokenError } from './token.js';
const LABEL = 'atproto-space-credential';
const TAG = 'atproto-space-credential';
const ALG = 'ecdsa-p256-sha256';
const COMPONENTS = ['authorization', 'atproto-space-audience'];
const SIGNATURE_INPUT = `(${COMPONENTS.map((c) => `"${c}"`).join(' ')});tag="${TAG}"`;
/**
* @typedef {Object} SpaceSigKey
* @property {string} did - the public key as a P-256 did:key
* @property {(data: Uint8Array) => Promise<Uint8Array>} sign - 64-byte r||s
*/
/**
* A fresh P-256 key for one credential. The private key is not extractable.
* @returns {Promise<SpaceSigKey>}
*/
export async function generateSpaceSigKey() {
const pair = /** @type {CryptoKeyPair} */ (
await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign', 'verify'],
)
);
const raw = new Uint8Array(
await crypto.subtle.exportKey('raw', pair.publicKey),
);
return {
did: publicKeyToDidKey(compressPublicKey(raw), 'p256'),
sign: (data) => sign(pair.privateKey, data),
};
}
/**
* Whether `keyId` is a P-256 did:key, the only kind a credential binds to.
* @param {unknown} keyId
* @returns {boolean}
*/
export function isP256DidKey(keyId) {
if (typeof keyId !== 'string' || !keyId.startsWith('did:key:')) return false;
try {
return parseDidKey(keyId).curve === 'p256';
} catch {
return false;
}
}
/**
* The authorization, audience and signature headers for a request presenting
* a space credential.
*
* @param {SpaceSigKey} key
* @param {{authorization: string, audience: string}} opts
* @returns {Promise<Record<string, string>>}
*/
export async function createSpaceSigHeaders(key, { authorization, audience }) {
const signature = await key.sign(
signatureBase(authorization, audience, SIGNATURE_INPUT),
);
return {
authorization,
'atproto-space-audience': audience,
'signature-input': `${LABEL}=${SIGNATURE_INPUT}`,
signature: `${LABEL}=:${toPaddedBase64(signature)}:`,
};
}
/**
* Verify the `atproto-space-credential` signature on a request against the key
* the credential is bound to (its `cnf.kid`). A `keyid` parameter, when
* present, must name that key.
*
* @param {Headers} headers
* @param {Object} opts
* @param {string} opts.keyId
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
* @returns {Promise<{audience: string}>}
* @throws {SpaceTokenError} code BadSpaceSignature
*/
export async function verifyCredentialSig(headers, { keyId, verifier }) {
const authorization = single(headers, 'authorization');
const audience = single(headers, 'atproto-space-audience');
const input = memberOf(headers.get('signature-input'), 'Signature-Input');
const signature = memberOf(headers.get('signature'), 'Signature');
if (input.kind !== 'innerList') fail('Signature-Input is not an inner list');
if (
signature.kind !== 'item' ||
!(signature.value instanceof Uint8Array) ||
signature.params.size !== 0
) {
fail('Signature is not a byte sequence');
}
if (
input.items.length !== COMPONENTS.length ||
input.items.some(
(item, i) => item.value !== COMPONENTS[i] || item.params.size !== 0,
)
) {
fail(
`signature must cover exactly ${COMPONENTS.map((c) => `"${c}"`).join(' ')}, in order`,
);
}
if (input.params.get('tag') !== TAG) fail(`signature tag must be "${TAG}"`);
const alg = input.params.get('alg');
if (alg !== undefined && alg !== ALG) {
fail(`signature algorithm must be ${ALG}`);
}
const named = input.params.get('keyid');
if (named !== undefined && named !== keyId) {
fail('signature keyid does not match the credential key');
}
if (!isP256DidKey(keyId)) fail('signature key must be a P-256 did:key');
const bytes = /** @type {Uint8Array} */ (signature.value);
const base = signatureBase(
authorization,
audience,
serializeInnerList(input.items, input.params),
);
let valid = false;
if (bytes.length === 64) {
try {
valid = await verifier.verify(keyId, base, bytes);
} catch {
valid = false;
}
}
if (!valid) fail('invalid HTTP message signature');
return { audience };
}
/**
* @param {string} authorization
* @param {string} audience
* @param {string} params - the serialized signature parameters
* @returns {Uint8Array}
*/
function signatureBase(authorization, audience, params) {
return new TextEncoder().encode(
[
`"authorization": ${authorization.trim()}`,
`"atproto-space-audience": ${audience.trim()}`,
`"@signature-params": ${params}`,
].join('\n'),
);
}
/**
* One field value. Fetch joins repeated fields with ", ", and neither a token
* nor a DID contains a comma, so a comma means the field was sent twice.
*
* @param {Headers} headers
* @param {string} name
* @returns {string}
*/
function single(headers, name) {
const value = headers.get(name);
if (!value || value.includes(',')) {
fail(`request requires exactly one "${name}" field`);
}
return /** @type {string} */ (value);
}
/**
* @param {string} message
* @returns {never}
*/
function fail(message) {
throw new SpaceTokenError(message, 'BadSpaceSignature');
}
/**
* @param {string|null} header
* @param {string} name
* @returns {import('./structured-fields.js').SfMember}
*/
function memberOf(header, name) {
if (!header) fail(`missing ${name} field`);
/** @type {Map<string, import('./structured-fields.js').SfMember>} */
let dict;
try {
dict = parseDictionary(/** @type {string} */ (header));
} catch {
fail(`malformed ${name} field`);
}
const member = dict.get(LABEL);
if (!member) fail(`no ${LABEL} member in ${name}`);
return /** @type {import('./structured-fields.js').SfMember} */ (member);
}