diff --git a/apps/check/package.json b/apps/check/package.json index 1fc3d2a9..6d87cc0f 100644 --- a/apps/check/package.json +++ b/apps/check/package.json @@ -14,9 +14,11 @@ }, "dependencies": { "@atcute/atproto": "^4.0.0", + "@atcute/car": "^6.0.2", "@atcute/cbor": "^2.3.3", "@atcute/cid": "^2.4.1", "@atcute/client": "^5.0.0", + "@atcute/crypto": "^2.4.4", "@atcute/identity": "^2.0.0", "@atcute/identity-resolver": "^2.0.0", "@atcute/lexicons": "^2.0.0", diff --git a/apps/check/src/checks/repo-read.ts b/apps/check/src/checks/repo-read.ts index aface921..6bc0ee95 100644 --- a/apps/check/src/checks/repo-read.ts +++ b/apps/check/src/checks/repo-read.ts @@ -8,6 +8,7 @@ import type { Did, Nsid } from "@atcute/lexicons/syntax"; import { CarReader } from "@ipld/car"; import { validateLexicon } from "../lib/xrpc"; import type { Check, CheckOutcome } from "../types"; +import { verifyCar } from "../lib/verify" let cachedClient: { pds: string; client: Client } | undefined; @@ -721,6 +722,30 @@ const getRepoCarValidates: Check = { }, }; +const getRepoCarVerifyCommitSignature: Check = { + id: "repo-read.verify-commit-signature.validates", + category: "repo-read", + label: "CAR file commit signature validates", + requires: ["pds", "did"], + run: async (): Promise => { + if (!repoCarBytes) { + return { status: "skip", message: "no CAR bytes to parse" }; + } + let result = await verifyCar(repoCarBytes) + if (result.ok) { + return { + status: "pass", + message: `Commit object Signature validates`, + }; + } else { + return { + status: "fail", + message: result.message, + }; + } + } +}; + export const repoReadChecks: Check[] = [ describeRepo, describeRepoValidates, @@ -733,4 +758,5 @@ export const repoReadChecks: Check[] = [ listRecordsCursor, getRepoCar, getRepoCarValidates, + getRepoCarVerifyCommitSignature, ]; diff --git a/apps/check/src/lib/spec-urls.ts b/apps/check/src/lib/spec-urls.ts index 9cb430fc..6915d6b0 100644 --- a/apps/check/src/lib/spec-urls.ts +++ b/apps/check/src/lib/spec-urls.ts @@ -33,6 +33,7 @@ const MAP: Record = { "repo-read.list-records-cursor": `${LEX}/com/atproto/repo/listRecords.json`, "repo-read.get-repo-car": `${LEX}/com/atproto/sync/getRepo.json`, "repo-read.get-repo-car.validates": "https://ipld.io/specs/transport/car/carv1/", + "repo-read.verify-commit-signature.validates":"https://atproto.com/specs/repository#commit-objects", // sync "sync.get-latest-commit": `${LEX}/com/atproto/sync/getLatestCommit.json`, diff --git a/apps/check/src/lib/types.ts b/apps/check/src/lib/types.ts new file mode 100644 index 00000000..a2b81bf4 --- /dev/null +++ b/apps/check/src/lib/types.ts @@ -0,0 +1,38 @@ +import type { Bytes, CidLink } from '@atcute/cbor'; +import { isBytes, isCidLink } from '@atcute/cbor'; + +/** + * commit object stored at the root of an atproto repository CAR. + * + * mirrors the `Commit` type in `@atcute/repo`. `prev` is normally `null` — a + * non-null value here is the kind of "unexpected field value" this tool is built + * to surface. we intentionally do *not* use `@atcute/repo`'s `isCommit()` as a + * gate: it accepts `prev` as either `null` or a `CidLink`, so it would happily + * pass over exactly the anomaly we're hunting. + */ +export interface Commit { + version: 3; + did: string; + data: CidLink; + rev: string; + sig: Bytes; + /** backwards-compat with v2; history bookkeeping is not required, so this is normally null */ + prev: CidLink | null; +} + +/** loose shape we accept for decoding: we render any extra fields, but warn about them */ +export type DecodedCommit = Record; + +/** is `value` a well-formed commit with all six expected fields present? */ +export const isWellFormedCommit = (value: unknown): value is Commit => { + if (value === null || typeof value !== 'object') return false; + const obj = value as Record; + return ( + obj.version === 3 && + typeof obj.did === 'string' && + isCidLink(obj.data) && + typeof obj.rev === 'string' && + (obj.prev === null || isCidLink(obj.prev)) && + isBytes(obj.sig) + ); +}; diff --git a/apps/check/src/lib/verify.ts b/apps/check/src/lib/verify.ts new file mode 100644 index 00000000..99c08f7c --- /dev/null +++ b/apps/check/src/lib/verify.ts @@ -0,0 +1,207 @@ +import * as CAR from '@atcute/car'; +import * as CBOR from '@atcute/cbor'; +import type { Bytes } from '@atcute/cbor'; +import { fromBytes as unwrapBytes } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; +import { getPublicKeyFromDidController, verifySig } from '@atcute/crypto'; +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, +} from '@atcute/identity-resolver'; +import { getAtprotoVerificationMaterial, isAtprotoDid, isPlcDid, isWebDid, webDidToDocumentUrl } from '@atcute/identity'; + +import { isWellFormedCommit } from './types.ts'; + +export interface VerifyOk { + ok: true; + /** the deserialized commit object, for debug display (may carry unexpected fields) */ + commit: Record; + /** the signing key actually resolved for the commit's DID */ + publicKey: { type: string; jwtAlg: string; publicKeyMultibase: string }; + /** did the signature verify against the resolved key? */ + signatureValid: boolean; + /** the commit's DID, for the link-out */ + did: string; + /** a human-viewable URL for the DID document the key was resolved from */ + didDocUrl: string; + /** raw bytes of the commit block (as stored in the CAR), for copying to a CBOR debugger */ + commitBytes: Uint8Array; +} + +/** shape we surface to the UI: material from the DID doc + jwtAlg from the parsed key */ +interface ResolvedKey { + type: string; + jwtAlg: string; + publicKeyMultibase: string; +} + +export interface VerifyErr { + ok: false; + /** stage that failed, for a faithful error report */ + stage: 'parse' | 'resolve' | 'verify' | 'unknown'; + message: string; + /** the deserialized commit, if we got far enough to decode it (always offered to the debug view) */ + commit?: Record; +} + +export type VerifyResult = VerifyOk | VerifyErr; + +const didResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, +}); + +/** + * extract the root commit from a CAR, resolve its DID to a public key, and verify + * the commit's signature. + * + * the signature step mirrors `@atcute/repo`'s `verifyRecord`: strip the `sig` + * field, re-serialize the remaining fields as dag-cbor, and verify against those + * bytes. `verifySig` uses `crypto.subtle.verify` (or noble for secp256k1), both + * of which SHA-256 the data internally — so we pass the raw unsigned-commit bytes + * and **must not** pre-hash. + * + * on failure the decoded commit is still returned (when available) so the debug + * view can show what was in it. + */ +export const verifyCar = async (carBytes: Uint8Array): Promise => { + let commit: Record; + let commitBytes: Uint8Array; + try { + const extracted = extractCommit(carBytes); + commit = extracted.commit; + commitBytes = extracted.bytes; + } catch (err) { + return { + ok: false, + stage: 'parse', + message: err instanceof Error ? err.message : String(err), + }; + } + + // resolve the DID before any signature work, so a resolution failure is a + // distinct, faithful report rather than a generic "verification failed". + let publicKey; + try { + publicKey = await resolveKey(commit); + } catch (err) { + return { + ok: false, + stage: 'resolve', + message: err instanceof Error ? err.message : String(err), + commit, + }; + } + + try { + const valid = await verifyCommitSignature(commit, publicKey.found); + return { + ok: true, + commit, + publicKey: publicKey.resolved, + signatureValid: valid, + did: publicKey.did, + didDocUrl: publicKey.didDocUrl, + commitBytes, + }; + } catch (err) { + return { + ok: false, + stage: 'verify', + message: err instanceof Error ? err.message : String(err), + commit, + }; + } +}; + +/** read the CAR root block and decode it as a commit */ +const extractCommit = (carBytes: Uint8Array): { commit: Record; bytes: Uint8Array } => { + const reader = CAR.fromUint8Array(carBytes); + const roots = reader.roots; + if (roots.length < 1) { + throw new Error(`CAR has no roots; got=${roots.length}`); + } + + // index every block by CID string; the commit is whatever roots[0] points at + const blocks = new Map(); + for (const entry of reader) { + blocks.set(CID.toString(entry.cid), entry.bytes); + } + + const rootCid = roots[0].$link; + const commitBytes = blocks.get(rootCid); + if (commitBytes === undefined) { + throw new Error(`root CID not present in CAR blocks; cid=${rootCid}`); + } + + const decoded = CBOR.decode(commitBytes) as Record; + if (decoded === null || typeof decoded !== 'object') { + throw new Error(`root block did not decode to a map`); + } + + return { commit: decoded, bytes: commitBytes }; +}; + +/** a human-viewable URL for a DID document (plc.directory for did:plc, well-known for did:web) */ +const didDocUrlFor = (did: string): string => { + if (isPlcDid(did)) { + return `https://plc.directory/${did}`; + } + if (isWebDid(did)) { + return webDidToDocumentUrl(did).href; + } + return ''; +}; + +/** resolve the commit's DID to a public key plus the material we display */ +const resolveKey = async (commit: Record): Promise<{ + found: ReturnType; + resolved: ResolvedKey; + did: string; + didDocUrl: string; +}> => { + const did = commit['did']; + if (typeof did !== 'string') { + throw new Error(`commit has no string 'did' field`); + } + if (!isAtprotoDid(did)) { + throw new Error(`commit 'did' is not a supported atproto DID: ${did}`); + } + + const doc = await didResolver.resolve(did); + const material = getAtprotoVerificationMaterial(doc); + if (material === undefined) { + throw new Error(`DID document has no #atproto verification method`); + } + + const found = getPublicKeyFromDidController(material); + return { + found, + resolved: { type: found.type, jwtAlg: found.jwtAlg, publicKeyMultibase: material.publicKeyMultibase }, + did, + didDocUrl: didDocUrlFor(did), + }; +}; + +/** + * verify the commit signature: strip `sig`, re-encode, verify against the bytes. + * the unsigned commit is re-encoded with the same dag-cbor codec the signer used, + * so the round-trip is faithful (CidLink values round-trip through their tags). + */ +const verifyCommitSignature = async ( + commit: Record, + found: ReturnType, +): Promise => { + if (!isWellFormedCommit(commit)) { + throw new Error(`commit is not well-formed (missing/invalid fields)`); + } + + const { sig, ...unsigned } = commit; + const sigBytes = unwrapBytes(sig as Bytes) as Uint8Array; + const data = CBOR.encode(unsigned) as Uint8Array; + + return await verifySig(found, sigBytes, data); +};