UNPKG

@skyware/labeler

Version:

A lightweight alternative to Ozone for operating an atproto labeler.

358 lines (357 loc) 13.8 kB
import { XRPCError } from "@atcute/client"; import { p256 } from "@noble/curves/p256"; import { secp256k1 as k256 } from "@noble/curves/secp256k1"; import { sha256 } from "@noble/hashes/sha256"; import * as ui8 from "uint8arrays"; const P256_DID_PREFIX = new Uint8Array([0x80, 0x24]); const SECP256K1_DID_PREFIX = new Uint8Array([0xe7, 0x01]); // should equal P256_DID_PREFIX.length and SECP256K1_DID_PREFIX.length const DID_PREFIX_LENGTH = 2; const BASE58_MULTIBASE_PREFIX = "z"; const DID_KEY_PREFIX = "did:key:"; export const P256_JWT_ALG = "ES256"; export const SECP256K1_JWT_ALG = "ES256K"; const didToSigningKeyCache = new Map(); /** * Resolves the atproto signing key for a DID. * @param did The DID to resolve. * @param forceRefresh Whether to skip the cache and always resolve the DID. * @returns The resolved signing key. */ export async function resolveDidToSigningKey(did, forceRefresh) { if (!forceRefresh) { const cached = didToSigningKeyCache.get(did); if (cached) { const now = Date.now(); if (now < cached.expires) { return cached.key; } didToSigningKeyCache.delete(did); } } const [, didMethod, ...didValueParts] = did.split(":"); let didKey = undefined; if (didMethod === "plc") { const res = await fetch(`https:/plc.directory/${encodeURIComponent(did)}`, { headers: { accept: "application/json" }, }); if (!res.ok) throw new Error(`Could not resolve DID: ${did}`); didKey = parseKeyFromDidDocument(await res.json(), did); } else if (didMethod === "web") { if (!didValueParts.length) throw new Error(`Poorly formatted DID: ${did}`); if (didValueParts.length > 1) throw new Error(`Unsupported did:web paths: ${did}`); const didValue = didValueParts[0]; const res = await fetch(`https://${didValue}/.well-known/did.json`, { headers: { accept: "application/json" }, }); if (!res.ok) throw new Error(`Could not resolve DID: ${did}`); didKey = parseKeyFromDidDocument(await res.json(), did); } if (!didKey) throw new Error(`Could not resolve DID: ${did}`); didToSigningKeyCache.set(did, { key: didKey, expires: Date.now() + 60 * 60 * 1000 }); return didKey; } /** * Verifies a JWT. * @param jwtStr The JWT to verify. * @param ownDid The DID of the service that is receiving the request. * @param lxm The lexicon method that is being called. * @returns The payload of the JWT. */ export async function verifyJwt(jwtStr, ownDid, lxm) { const parts = jwtStr.split("."); if (parts.length !== 3) { throw new XRPCError(401, { kind: "BadJwt", description: "Poorly formatted JWT" }); } const payload = parsePayload(parts[1]); const sig = parts[2]; if (Date.now() / 1000 > payload.exp) { throw new XRPCError(401, { kind: "JwtExpired", description: "JWT expired" }); } if (ownDid !== null && payload.aud !== ownDid) { throw new XRPCError(401, { kind: "BadJwtAudience", description: "JWT audience does not match service DID", }); } if (lxm !== null && payload.lxm !== lxm) { throw new XRPCError(401, { kind: "BadJwtLexiconMethod", description: payload.lxm !== undefined ? `Bad JWT lexicon method ("lxm"). Must match: ${lxm}` : `Missing JWT lexicon method ("lxm"). Must match: ${lxm}`, }); } const msgBytes = ui8.fromString(parts.slice(0, 2).join("."), "utf8"); const sigBytes = ui8.fromString(sig, "base64url"); const signingKey = await resolveDidToSigningKey(payload.iss, false).catch((e) => { console.error(e); throw new XRPCError(500, { kind: "InternalError", description: "Could not resolve DID" }); }); let validSig; try { validSig = verifySignatureWithKey(signingKey, msgBytes, sigBytes); } catch (err) { throw new XRPCError(401, { kind: "BadJwtSignature", description: "Could not verify JWT signature", }); } if (!validSig) { // get fresh signing key in case it failed due to a recent rotation const freshSigningKey = await resolveDidToSigningKey(payload.iss, true); try { validSig = freshSigningKey !== signingKey ? verifySignatureWithKey(freshSigningKey, msgBytes, sigBytes) : false; } catch (err) { throw new XRPCError(401, { kind: "BadJwtSignature", description: "Could not verify JWT signature", }); } } if (!validSig) { throw new XRPCError(401, { kind: "BadJwtSignature", description: "JWT signature does not match JWT issuer", }); } return payload; } export function k256Sign(privateKey, msg) { const msgHash = sha256(msg); const sig = k256.sign(msgHash, privateKey, { lowS: true }); return sig.toCompactRawBytes(); } /** * Verifies a signature using a signing key in did:key format. * @param didKey The signing key to verify the signature with in did:key format. * @param msgBytes The message contents to verify. * @param sigBytes The signature to verify. */ function verifySignatureWithKey(didKey, msgBytes, sigBytes) { if (!didKey.startsWith("did:key:")) throw new Error("Incorrect prefix for did:key: " + didKey); const { jwtAlg } = parseDidMultikey(didKey); const curve = jwtAlg === P256_JWT_ALG ? "p256" : "k256"; return verifyDidSig(curve, didKey, msgBytes, sigBytes); } /** * Parses a DID document and extracts the atproto signing key. * @param doc The DID document to parse. * @param did The DID the document is for. * @returns The atproto signing key. */ const parseKeyFromDidDocument = (doc, did) => { if (!Array.isArray(doc?.verificationMethod)) { throw new Error(`Could not parse signingKey from doc: ${JSON.stringify(doc)}`); } const key = doc.verificationMethod.find((method) => method?.id === `${did}#atproto` || method?.id === `#atproto`); if (!key || typeof key !== "object" || !("type" in key) || typeof key.type !== "string" || !("publicKeyMultibase" in key) || typeof key.publicKeyMultibase !== "string") { throw new Error(`Could not resolve DID: ${did}`); } const keyBytes = multibaseToBytes(key.publicKeyMultibase); let didKey = undefined; if (key.type === "EcdsaSecp256r1VerificationKey2019") { didKey = formatDidKey(P256_JWT_ALG, keyBytes); } else if (key.type === "EcdsaSecp256k1VerificationKey2019") { didKey = formatDidKey(SECP256K1_JWT_ALG, keyBytes); } else if (key.type === "Multikey") { const parsed = parseDidMultikey("did:key:" + key.publicKeyMultibase); didKey = formatDidKey(parsed.jwtAlg, parsed.keyBytes); } if (!didKey) throw new Error(`Could not parse signingKey from doc: ${JSON.stringify(doc)}`); return didKey; }; /** * Parses a hex- or base64-encoded private key to a Uint8Array. * @param privateKey The private key to parse. */ export const parsePrivateKey = (privateKey) => { let keyBytes; try { keyBytes = ui8.fromString(privateKey, "hex"); if (keyBytes.byteLength !== 32) throw 0; } catch { try { keyBytes = ui8.fromString(privateKey, "base64url"); } catch { } } finally { if (!keyBytes) { throw new Error("Invalid private key. Must be hex or base64url, and 32 bytes long."); } return keyBytes; } }; /** * Formats a pubkey in did:key format. * @param jwtAlg The JWT algorithm used by the signing key. * @param keyBytes The bytes of the pubkey. */ export const formatDidKey = (jwtAlg, keyBytes) => DID_KEY_PREFIX + formatMultikey(jwtAlg, keyBytes); /** * Checks if a bytestring starts with a prefix. * @param bytes The bytestring to check. * @param prefix The prefix to check for. */ const hasPrefix = (bytes, prefix) => { return ui8.equals(prefix, bytes.subarray(0, prefix.byteLength)); }; /** * Compresses a pubkey to be used in a did:key. * @param curve p256 (secp256r1) or k256 (secp256k1) * @param keyBytes The pubkey to compress. * @see https://medium.com/asecuritysite-when-bob-met-alice/02-03-or-04-so-what-are-compressed-and-uncompressed-public-keys-6abcb57efeb6 */ const compressPubkey = (curve, keyBytes) => { const ProjectivePoint = curve === "p256" ? p256.ProjectivePoint : k256.ProjectivePoint; return ProjectivePoint.fromHex(keyBytes).toRawBytes(true); }; /** * Decompresses a pubkey. * @param curve p256 (secp256r1) or k256 (secp256k1) * @param compressed The compressed pubkey to decompress. */ const decompressPubkey = (curve, compressed) => { if (compressed.length !== 33) { throw new Error("Incorrect compressed pubkey length: " + compressed.length); } const ProjectivePoint = curve === "p256" ? p256.ProjectivePoint : k256.ProjectivePoint; return ProjectivePoint.fromHex(compressed).toRawBytes(false); }; /** * Verifies a signature using a signing key in did:key format. * @param curve p256 (secp256r1) or k256 (secp256k1) * @param did The signing key in did:key format. * @param data The data to verify. * @param sig The signature to verify. */ const verifyDidSig = (curve, did, data, sig) => { const prefixedBytes = extractPrefixedBytes(extractMultikey(did)); const prefix = curve === "p256" ? P256_DID_PREFIX : SECP256K1_DID_PREFIX; if (!hasPrefix(prefixedBytes, prefix)) { throw new Error("Invalid curve for DID: " + did); } const keyBytes = prefixedBytes.slice(prefix.length); const msgHash = sha256(data); return (curve === "p256" ? p256 : k256).verify(sig, msgHash, keyBytes, { lowS: false }); }; /** * Formats a signing key as [base58 multibase](https://github.com/multiformats/multibase). * @param jwtAlg The JWT algorithm used by the signing key. * @param keyBytes The bytes of the signing key. */ const formatMultikey = (jwtAlg, keyBytes) => { const curve = jwtAlg === P256_JWT_ALG ? "p256" : "k256"; let prefixedBytes; if (jwtAlg === P256_JWT_ALG) { prefixedBytes = ui8.concat([P256_DID_PREFIX, compressPubkey(curve, keyBytes)]); } else if (jwtAlg === SECP256K1_JWT_ALG) { prefixedBytes = ui8.concat([SECP256K1_DID_PREFIX, compressPubkey(curve, keyBytes)]); } else { throw new Error("Invalid JWT algorithm: " + jwtAlg); } return (BASE58_MULTIBASE_PREFIX + ui8.toString(prefixedBytes, "base58btc")); }; /** * Parses and decompresses the public key and JWT algorithm from multibase. * @param didKey The did:key to parse. */ const parseDidMultikey = (didKey) => { const multikey = extractMultikey(didKey); const prefixedBytes = extractPrefixedBytes(multikey); const keyCurve = hasPrefix(prefixedBytes, P256_DID_PREFIX) ? "p256" : hasPrefix(prefixedBytes, SECP256K1_DID_PREFIX) ? "k256" : null; if (!keyCurve) throw new Error("Invalid curve for multikey: " + multikey); const keyBytes = decompressPubkey(keyCurve, prefixedBytes.subarray(DID_PREFIX_LENGTH)); return { jwtAlg: keyCurve === "p256" ? P256_JWT_ALG : SECP256K1_JWT_ALG, keyBytes }; }; /** * Extracts the key component of a did:key. * @param did The did:key to extract the key from. * @returns A compressed pubkey, without the did:key prefix. */ const extractMultikey = (did) => { if (!did.startsWith(DID_KEY_PREFIX)) throw new Error("Incorrect prefix for did:key: " + did); return did.slice(DID_KEY_PREFIX.length); }; /** * Removes the base58 multibase prefix from a compressed pubkey. * @param multikey The compressed pubkey to remove the prefix from. * @returns The pubkey without the multibase base58 prefix. */ const extractPrefixedBytes = (multikey) => { if (!multikey.startsWith(BASE58_MULTIBASE_PREFIX)) { throw new Error("Incorrect prefix for multikey: " + multikey); } return ui8.fromString(multikey.slice(BASE58_MULTIBASE_PREFIX.length), "base58btc"); }; /** * Parses a JWT payload. * @param b64 The JWT payload to parse. */ const parsePayload = (b64) => { const payload = JSON.parse(ui8.toString(ui8.fromString(b64, "base64url"), "utf8")); if (!payload || typeof payload !== "object" || typeof payload.iss !== "string" || typeof payload.aud !== "string" || typeof payload.exp !== "number" || (payload.lxm && typeof payload.lxm !== "string") || (payload.nonce && typeof payload.nonce !== "string")) { throw new XRPCError(401, { kind: "BadJwt", description: "Poorly formatted JWT" }); } return payload; }; /** * Parses a multibase encoded string to a Uint8Array. * @param mb The multibase encoded string. */ const multibaseToBytes = (mb) => { const base = mb[0]; const key = mb.slice(1); switch (base) { case "f": return ui8.fromString(key, "base16"); case "F": return ui8.fromString(key, "base16upper"); case "b": return ui8.fromString(key, "base32"); case "B": return ui8.fromString(key, "base32upper"); case "z": return ui8.fromString(key, "base58btc"); case "m": return ui8.fromString(key, "base64"); case "u": return ui8.fromString(key, "base64url"); case "U": return ui8.fromString(key, "base64urlpad"); default: throw new Error(`Unsupported multibase: :${mb}`); } };