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. */