diff --git a/README.md b/README.md index 5e03696..20a3d87 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ above. Artist keeps the title and the search box and answers with the player alone — avatar, name, badges — no collections, trees, or raw JSON. The mode lives in the URL, so every view stays shareable and statically rendered. +Badge awards live in the player's own repo, so the artist view shows only those +carrying a valid attestation from folk.lol's `#attestations` key over that exact +record in that exact repo. The nerd view shows every award and labels it +`folk signed`, `unsigned`, or `other issuer`. + ## Development ```bash diff --git a/package.json b/package.json index cc01923..d3b2dd1 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,13 @@ }, "dependencies": { "@atproto/syntax": "^0.7.4", + "@ipld/dag-cbor": "^10.0.2", + "@noble/curves": "^2.3.0", + "@noble/hashes": "^2.3.0", "@pierre/diffs": "^1.3.5", "@pierre/trees": "1.0.0-beta.6", "@vercel/analytics": "^2.0.1", + "multiformats": "^14.0.5", "next": "16.3.0", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdd4a65..9d7a935 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@atproto/syntax': specifier: ^0.7.4 version: 0.7.4 + '@ipld/dag-cbor': + specifier: ^10.0.2 + version: 10.0.2 + '@noble/curves': + specifier: ^2.3.0 + version: 2.3.0 + '@noble/hashes': + specifier: ^2.3.0 + version: 2.3.0 '@pierre/diffs': specifier: ^1.3.5 version: 1.3.5(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -20,6 +29,9 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + multiformats: + specifier: ^14.0.5 + version: 14.0.5 next: specifier: 16.3.0 version: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -277,6 +289,9 @@ packages: cpu: [x64] os: [win32] + '@ipld/dag-cbor@10.0.2': + resolution: {integrity: sha512-1eCD4YMTlkx5i36oQUmeAojLS/wu+XCd1Gs31BA0afk0TaVkhnyMofIAT/pi0wV3HS3/G7QSN/1NibhiksMyZQ==} + '@next/env@16.3.0': resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} @@ -332,6 +347,14 @@ packages: cpu: [x64] os: [win32] + '@noble/curves@2.3.0': + resolution: {integrity: sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@pierre/diffs@1.3.5': resolution: {integrity: sha512-BhaLEiUvR+BdIyOYdogA4JLQjluWPubuwySmmIqEkcE0FwIWRbgJgHNC/r884dlxEr89fO4hTk/sBln0a7NSOw==} peerDependencies: @@ -606,6 +629,10 @@ packages: caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + cborg@6.1.1: + resolution: {integrity: sha512-Ci8+V1OMFtqea8EXicsATqV+3kEqCGXCicM+5MEzpKTxqy58WMzhJUjtUqAjfWpKPZ0wpG8F+u5mC8Xy/0Zevg==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -672,6 +699,9 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + multiformats@14.0.5: + resolution: {integrity: sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -975,6 +1005,11 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true + '@ipld/dag-cbor@10.0.2': + dependencies: + cborg: 6.1.1 + multiformats: 14.0.5 + '@next/env@16.3.0': {} '@next/swc-darwin-arm64@16.3.0': @@ -1001,6 +1036,12 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.0': optional: true + '@noble/curves@2.3.0': + dependencies: + '@noble/hashes': 2.3.0 + + '@noble/hashes@2.3.0': {} + '@pierre/diffs@1.3.5(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@pierre/theme': 2.0.0 @@ -1187,6 +1228,8 @@ snapshots: caniuse-lite@1.0.30001809: {} + cborg@6.1.1: {} + ccount@2.0.1: {} character-entities-html4@2.1.0: {} @@ -1262,6 +1305,8 @@ snapshots: micromark-util-types@2.0.2: {} + multiformats@14.0.5: {} + nanoid@3.3.18: {} next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): diff --git a/src/components/badge-card.tsx b/src/components/badge-card.tsx index e102f2f..fccd53e 100644 --- a/src/components/badge-card.tsx +++ b/src/components/badge-card.tsx @@ -3,9 +3,16 @@ import type { Badge } from "@/lib/badge"; import styles from "./badge-card.module.css"; import { Card } from "./ui/card"; import { Field, Fields } from "./ui/field"; +import { Pill } from "./ui/pill"; const SIZE = 96; +const PROVENANCE_LABEL = { + folk: "folk signed", + unsigned: "unsigned", + external: "other issuer", +} as const; + /** * Badge art is a blob on an arbitrary PDS, so it goes through a plain `img`: * the Next image optimizer would need every possible host allowlisted, and the @@ -28,7 +35,12 @@ export function BadgeCard({ badge }: { badge: Badge }) { )}
- {badge.name &&
{badge.name}
} +
+ {badge.name}{" "} + + {PROVENANCE_LABEL[badge.provenance]} + +
{badge.description && (

{badge.description}

)} diff --git a/src/lib/atproto/attestation.ts b/src/lib/atproto/attestation.ts new file mode 100644 index 0000000..adc0830 --- /dev/null +++ b/src/lib/atproto/attestation.ts @@ -0,0 +1,99 @@ +import * as dagCbor from "@ipld/dag-cbor"; +import { p256 } from "@noble/curves/nist.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { sha256 as nobleSha256 } from "@noble/hashes/sha2.js"; +import { base58btc } from "multiformats/bases/base58"; +import { base64 } from "multiformats/bases/base64"; +import { CID } from "multiformats/cid"; +import { sha256 } from "multiformats/hashes/sha2"; + +/** + * CID-first record attestations, per the badge.blue specification. An issuer + * signs a record that lives in *someone else's* repo, which is what makes an + * award in a player-writable repo worth anything: the player owns the record, + * but only the issuer can have signed it. + * + * Verification only - this is an observer. The signing half, and the notes on + * how the recipe was recovered, live in folk.lol's `@folk/shared`. + * + * Three details decide whether a signature verifies: + * + * 1. `signatures` is stripped before hashing, so a second attestation never + * invalidates the first. + * 2. `$sig` carries `repository`, the DID of the repo holding the record. + * Copying a signed record elsewhere changes the CID and breaks it. + * 3. What is signed is sha256(cid.bytes) - the hash of the CID's 36 bytes, + * not the CID bytes and not the dag-cbor digest. + */ + +export const SIGNATURE_TYPE = "community.lexicon.attestations.signature"; + +export type InlineSignature = { + $type: string; + /** DID-relative verification method, e.g. `did:plc:folk#attestations`. */ + key: string; + signature: { $bytes: string }; +}; + +type AttestableRecord = Record & { signatures?: unknown[] }; + +/** Multicodec prefixes for the two key types atproto uses, as varint byte pairs. */ +const CURVES = { + "128,36": p256, + "231,1": secp256k1, +} as const; + +function decodeMultikey(multibase: string): { + curve: typeof p256 | typeof secp256k1; + key: Uint8Array; +} { + const bytes = base58btc.decode(multibase); + const curve = CURVES[`${bytes[0]},${bytes[1]}` as keyof typeof CURVES]; + if (!curve) { + throw new Error(`unsupported key type: multicodec ${bytes[0]},${bytes[1]}`); + } + return { curve, key: bytes.slice(2) }; +} + +async function attestationPayload( + record: AttestableRecord, + meta: Record, + repository: string, +): Promise { + const { signatures: _dropped, ...body } = record; + const { cid: _cid, signature: _signature, ...cleanMeta } = meta; + const encoded = dagCbor.encode({ + ...body, + $sig: { ...cleanMeta, repository }, + }); + const cid = CID.createV1(dagCbor.code, await sha256.digest(encoded)); + return nobleSha256(cid.bytes); +} + +export function isInlineSignature(value: unknown): value is InlineSignature { + if (!value || typeof value !== "object") return false; + const entry = value as InlineSignature; + return ( + typeof entry.key === "string" && typeof entry.signature?.$bytes === "string" + ); +} + +/** False rather than throwing, so a caller can just drop what will not verify. */ +export async function verifyAttestation( + record: AttestableRecord, + entry: InlineSignature, + repository: string, + publicKeyMultibase: string, +): Promise { + try { + const { curve, key } = decodeMultikey(publicKeyMultibase); + const { signature, ...meta } = entry; + const payload = await attestationPayload(record, meta, repository); + // `$bytes` is plain unpadded base64, not multibase - hence the 'm' prefix. + return curve.verify(base64.decode(`m${signature.$bytes}`), payload, key, { + prehash: false, + }); + } catch { + return false; + } +} diff --git a/src/lib/atproto/resolve.ts b/src/lib/atproto/resolve.ts index 92591c7..73b9523 100644 --- a/src/lib/atproto/resolve.ts +++ b/src/lib/atproto/resolve.ts @@ -175,9 +175,13 @@ function getDeclaredHandle(doc: DidDocument): string | null { return null; } -function getSigningKey(doc: DidDocument): string | null { +/** The Multikey published under a DID-relative fragment, e.g. `#attestations`. */ +export function getVerificationKey( + doc: DidDocument, + fragment: string, +): string | null { const method = (doc.verificationMethod ?? []).find((entry) => - entry.id.endsWith("#atproto"), + entry.id.endsWith(fragment), ); return method?.publicKeyMultibase ?? null; } @@ -223,7 +227,7 @@ export async function resolveActor(actor: Actor): Promise { handle: declaredHandle, handleVerified, pds, - signingKey: getSigningKey(didDoc), + signingKey: getVerificationKey(didDoc, "#atproto"), didDoc, }; } diff --git a/src/lib/badge.ts b/src/lib/badge.ts index 7c3521c..c70fe9c 100644 --- a/src/lib/badge.ts +++ b/src/lib/badge.ts @@ -1,6 +1,9 @@ +import { isInlineSignature, verifyAttestation } from "./atproto/attestation"; import { loadIdentity, loadRecord } from "./atproto/cached"; import { blobCid, blobUrl, type RecordValue } from "./atproto/repo"; +import { getVerificationKey } from "./atproto/resolve"; import { actorId, parseAtUri } from "./atproto/uri"; +import { folkAuthority } from "./folk"; import { hrefForAtUri } from "./links"; export const BADGE_AWARD = "community.lexicon.badge.award"; @@ -22,15 +25,62 @@ export type Badge = { /** Repo that published the definition — the party vouching for the award. */ issuer: string; issuedAt: string | null; + provenance: BadgeProvenance; }; +/** + * `folk` - defined by FOLK and, for an award, signed by its key for this + * exact record in this exact repo. + * `unsigned` - a FOLK definition with no valid attestation. Anyone can write + * this into their own repo; it is a claim, not an achievement. + * `external` - another issuer's badge. Real, just not FOLK's to vouch for. + */ +export type BadgeProvenance = "folk" | "unsigned" | "external"; + function str(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +/** + * An award is a record in the *player's* repo, so its content proves nothing on + * its own. What counts is an attestation over it by FOLK's `#attestations` key, + * bound to the repo it sits in. + */ +async function attestedByFolk( + award: RecordValue, + repository: string, +): Promise { + const authority = await folkAuthority().catch(() => null); + if (!authority) return false; + + const publicKey = getVerificationKey(authority.didDoc, "#attestations"); + if (!publicKey) return false; + + const entries = Array.isArray(award.signatures) ? award.signatures : []; + for (const entry of entries) { + if (!isInlineSignature(entry)) continue; + if (entry.key !== `${authority.did}#attestations`) continue; + if (await verifyAttestation(award, entry, repository, publicKey)) { + return true; + } + } + return false; +} + +async function isFolkRepo(did: string): Promise { + const authority = await folkAuthority().catch(() => null); + return authority?.did === did; +} + function buildBadge( definition: RecordValue, - ctx: { pds: string; did: string; uri: string; issuer: string }, + ctx: { + pds: string; + did: string; + uri: string; + issuer: string; + provenance: BadgeProvenance; + }, issuedAt: string | null, ): Badge { const cid = blobCid(definition.image); @@ -43,6 +93,7 @@ function buildBadge( definitionHref: parsed ? hrefForAtUri(parsed) : null, issuer: ctx.issuer, issuedAt, + provenance: ctx.provenance, }; } @@ -65,7 +116,13 @@ export async function loadBadge( if (collection === BADGE_DEFINITION) { return buildBadge( value, - { pds: self.pds, did: self.did, uri: self.uri, issuer: self.display }, + { + pds: self.pds, + did: self.did, + uri: self.uri, + issuer: self.display, + provenance: (await isFolkRepo(self.did)) ? "folk" : "external", + }, str(value.createdAt), ); } @@ -86,6 +143,13 @@ export async function loadBadge( parsed.collection, parsed.rkey, ); + let provenance: BadgeProvenance = "external"; + if (await isFolkRepo(issuer.did)) { + provenance = (await attestedByFolk(value, self.did)) + ? "folk" + : "unsigned"; + } + return buildBadge( record.value, { @@ -93,6 +157,7 @@ export async function loadBadge( did: issuer.did, uri, issuer: issuer.handle ?? issuer.did, + provenance, }, str(value.issued) ?? str(value.createdAt), ); diff --git a/src/lib/folk.ts b/src/lib/folk.ts index afa286b..95f0c49 100644 --- a/src/lib/folk.ts +++ b/src/lib/folk.ts @@ -1,13 +1,20 @@ +import { cache } from "react"; import { describeRepo, isFolkCollection, listRecords, listReposByCollection, } from "./atproto/repo"; -import { resolveActor } from "./atproto/resolve"; +import { type Identity, resolveActor } from "./atproto/resolve"; export const LEXICON_AUTHORITY = "folk.lol"; +/** The repo that publishes the lexicons, the badge definitions, and the key that signs awards. */ +export const folkAuthority = cache( + (): Promise => + resolveActor({ type: "handle", handle: LEXICON_AUTHORITY }), +); + /** NSIDs in use before their lexicon was published. Remove each one as it ships. */ const UNPUBLISHED = ["lol.folk.profile"]; @@ -44,10 +51,7 @@ export async function getFolkLexicons(): Promise<{ authorityDid: string; lexicons: Lexicon[]; }> { - const authority = await resolveActor({ - type: "handle", - handle: LEXICON_AUTHORITY, - }); + const authority = await folkAuthority(); const { records } = await listRecords( authority.pds, authority.did, diff --git a/src/lib/player.ts b/src/lib/player.ts index 4ea16b3..8ca6084 100644 --- a/src/lib/player.ts +++ b/src/lib/player.ts @@ -103,7 +103,12 @@ async function loadBadges(identity: Identity): Promise { }), ); - return badges.filter((badge): badge is PlayerBadge => Boolean(badge)); + // Only what FOLK signed: an award is a record in the player's own repo, so an + // unattested one is a claim, not an achievement. + return badges.filter( + (badge): badge is PlayerBadge => + badge !== null && badge.provenance === "folk", + ); } /** Everyone on the network holding a FOLK profile, face and name only. */