···11+<template>
22+ <div class="max-w-3xl mx-auto px-4 py-12">
33+ <div class="mb-8">
44+ <h1 class="text-2xl font-bold text-zinc-100 mb-2">Keytrace</h1>
55+ <p class="text-zinc-200">Keytrace takes ideas from Keybase and Keyoxide and brings them to the decentralized web.</p>
66+ <p class="text-zinc-400 text-md-start mt-4">
77+ The site lets you create proofs that you own a certain identity (like a GitHub account, website address, or social media profile) and have that stored in your user registry
88+ in atproto.
99+ </p>
1010+ <p class="text-zinc-400 text-md-start mt-4">
1111+ All of the identity claims are public and can be independently verified by anyone using the same steps using an npm module or by re-running them in this website. Below are
1212+ our recipes for how we verify whether you have access to an identity:
1313+ </p>
1414+ </div>
1515+1616+ <div v-if="pending" class="space-y-4">
1717+ <div v-for="i in 4" :key="i" class="animate-pulse bg-kt-card border border-zinc-800 rounded-lg p-5">
1818+ <div class="h-5 w-32 bg-zinc-800 rounded mb-2" />
1919+ <div class="h-4 w-64 bg-zinc-800/60 rounded" />
2020+ </div>
2121+ </div>
2222+2323+ <div v-else-if="recipes" class="space-y-4">
2424+ <NuxtLink
2525+ v-for="recipe in recipes"
2626+ :key="recipe.id"
2727+ :to="`/recipes/${recipe.id}`"
2828+ class="block bg-kt-card border border-zinc-800 rounded-lg p-5 hover:border-zinc-700 transition-colors group"
2929+ >
3030+ <div class="flex items-start justify-between">
3131+ <div>
3232+ <h2 class="text-lg font-semibold text-zinc-200 group-hover:text-violet-400 transition-colors">
3333+ {{ recipe.name }}
3434+ </h2>
3535+ <p class="text-sm text-zinc-500 mt-1">
3636+ {{ recipe.description }}
3737+ </p>
3838+ </div>
3939+ <div class="flex items-center gap-2 text-zinc-500">
4040+ <span v-if="recipe.homepage" class="text-xs">{{ getDomain(recipe.homepage) }}</span>
4141+ <ArrowRightIcon class="w-4 h-4 group-hover:text-violet-400 transition-colors" />
4242+ </div>
4343+ </div>
4444+ </NuxtLink>
4545+ </div>
4646+4747+ <!-- Info section -->
4848+ <div class="mt-12 bg-kt-card border border-zinc-800 rounded-lg p-6">
4949+ <h2 class="text-sm font-semibold text-zinc-300 mb-3">How Verification Works</h2>
5050+ <div class="text-sm text-zinc-400 space-y-3">
5151+ <p>Each recipe defines a specific way to verify ownership of an external identity. The process is fully transparent and reproducible:</p>
5252+ <ol class="list-decimal list-inside space-y-2 text-zinc-500">
5353+ <li>You create a proof at the external service (e.g., a GitHub gist, DNS TXT record)</li>
5454+ <li>The proof contains your ATProto DID to link the identities</li>
5555+ <li>Keytrace fetches the proof from the public URL</li>
5656+ <li>The runner checks if your DID is present in the expected location</li>
5757+ <li>If verified, keytrace signs an attestation and stores it in your ATProto repo</li>
5858+ </ol>
5959+ <p class="text-xs text-zinc-600 pt-2">Anyone can re-run verification using the same steps to independently confirm your claims.</p>
6060+ </div>
6161+ </div>
6262+ </div>
6363+</template>
6464+6565+<script setup lang="ts">
6666+import { ArrowRight as ArrowRightIcon } from "lucide-vue-next";
6767+6868+const { data: recipes, pending } = await useFetch("/api/recipes");
6969+7070+function getDomain(url: string): string {
7171+ try {
7272+ return new URL(url).hostname;
7373+ } catch {
7474+ return "";
7575+ }
7676+}
7777+</script>
···11+/**
22+ * GET /api/services
33+ *
44+ * Get all available service providers with their UI configuration.
55+ * Used by the add claim wizard to display service options and instructions.
66+ */
77+88+import { serviceProviders } from "@keytrace/runner";
99+1010+export default defineEventHandler(() => {
1111+ const providers = serviceProviders.getAllProviders();
1212+1313+ return providers.map((provider) => ({
1414+ id: provider.id,
1515+ name: provider.name,
1616+ homepage: provider.homepage,
1717+ ui: provider.ui,
1818+ }));
1919+});
···7676/**
7777 * Publish a public key to keytrace's ATProto repo as a dev.keytrace.key record.
7878 * Record key = date (YYYY-MM-DD).
7979+ * Skipped in local dev mode (when not using S3).
7980 */
8081async function publishKeyToATProto(date: string, publicJwk: JsonWebKey): Promise<void> {
8282+ if (!useS3()) {
8383+ console.log(`[keys] Skipping ATProto publish in local dev mode (date=${date})`);
8484+ return;
8585+ }
8686+8187 try {
8288 const agent = await getKeytraceAgent();
8389 const config = useRuntimeConfig();
···103109104110/**
105111 * Get the strong ref (URI + CID) for today's key record.
112112+ * Returns a local placeholder in dev mode (when not using S3).
106113 */
107114export async function getTodaysKeyRef(): Promise<{
108115 uri: string;
···110117}> {
111118 const today = new Date().toISOString().split("T")[0];
112119 const config = useRuntimeConfig();
120120+121121+ if (!useS3()) {
122122+ // Return a local placeholder in dev mode
123123+ return {
124124+ uri: `at://${config.keytraceDid}/dev.keytrace.key/${today}`,
125125+ cid: "local-dev-key",
126126+ };
127127+ }
128128+113129 const agent = await getKeytraceAgent();
114130115131 const response = await agent.com.atproto.repo.getRecord({
···11+{
22+ "lexicon": 1,
33+ "id": "dev.keytrace.signature",
44+ "defs": {
55+ "main": {
66+ "type": "object",
77+ "description": "A cryptographic signature attesting to a claim",
88+ "required": ["kid", "src", "signedAt", "attestation"],
99+ "properties": {
1010+ "kid": {
1111+ "type": "string",
1212+ "description": "Key identifier (e.g., date in YYYY-MM-DD format)"
1313+ },
1414+ "src": {
1515+ "type": "string",
1616+ "format": "at-uri",
1717+ "description": "AT URI reference to the signing key record (e.g., at://did:plc:xxx/dev.keytrace.key/2024-01-15)"
1818+ },
1919+ "signedAt": {
2020+ "type": "string",
2121+ "format": "datetime",
2222+ "description": "Timestamp when the signature was created"
2323+ },
2424+ "attestation": {
2525+ "type": "string",
2626+ "description": "The cryptographic signature (base64-encoded)"
2727+ }
2828+ }
2929+ }
3030+ }
3131+}
+15-1
packages/runner/src/claim.ts
···22import { DEFAULT_TIMEOUT } from "./constants.js";
33import { matchUri, type ServiceProviderMatch, type ProofRequest, type ProofTarget } from "./serviceProviders/index.js";
44import * as fetchers from "./fetchers/index.js";
55-import type { VerifyOptions, ClaimVerificationResult } from "./types.js";
55+import type { VerifyOptions, ClaimVerificationResult, IdentityMetadata } from "./types.js";
6677// did:plc identifiers are base32-encoded, lowercase
88const DID_PLC_RE = /^did:plc:[a-z2-7]{24}$/;
···93939494 if (checkProof(proofData, config.proof.target, claim.did)) {
9595 claim.status = ClaimStatus.VERIFIED;
9696+9797+ // Extract identity metadata via postprocess if available
9898+ let identity: IdentityMetadata | undefined;
9999+ if (match.provider.postprocess) {
100100+ const metadata = match.provider.postprocess(proofData, match.match);
101101+ identity = {
102102+ subject: metadata.subject,
103103+ avatarUrl: metadata.avatarUrl,
104104+ profileUrl: metadata.profileUrl,
105105+ displayName: metadata.displayName,
106106+ };
107107+ }
108108+96109 return {
97110 status: ClaimStatus.VERIFIED,
98111 errors: [],
99112 timestamp: new Date(),
113113+ identity,
100114 };
101115 }
102116 } catch (err) {
+2-1
packages/runner/src/index.ts
···1717 ProfileData,
1818 ClaimData,
1919 VerifyOptions,
2020+ IdentityMetadata,
2021} from "./types.js";
2122export { ClaimStatus } from "./types.js";
2223···48494950// Service providers
5051export * as serviceProviders from "./serviceProviders/index.js";
5151-export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI } from "./serviceProviders/types.js";
5252+export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./serviceProviders/types.js";
52535354// Fetchers
5455export * as fetchers from "./fetchers/index.js";
+5-1
packages/runner/src/profile.ts
···22import { createClaim, verifyClaim, type ClaimState } from "./claim.js";
33import { ClaimStatus } from "./types.js";
44import { COLLECTION_NSID, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js";
55-import type { ProfileData, ClaimData, VerifyOptions } from "./types.js";
55+import type { ProfileData, ClaimData, VerifyOptions, IdentityMetadata } from "./types.js";
6677/**
88 * DID document service entry
···111111 for (const record of records.data.records) {
112112 const value = record.value as {
113113 claimUri?: string;
114114+ type?: string;
114115 comment?: string;
115116 createdAt?: string;
117117+ identity?: IdentityMetadata;
116118 };
117119 if (value.claimUri) {
118120 claims.push({
119121 uri: value.claimUri,
120122 did,
123123+ type: value.type,
121124 comment: value.comment,
122125 createdAt: value.createdAt ?? new Date().toISOString(),
123126 rkey: parseAtUriRkey(record.uri),
127127+ identity: value.identity,
124128 });
125129 }
126130 }
+1-1
packages/runner/src/recipes/github-gist.ts
···2525 steps: [
2626 "Go to https://gist.github.com",
2727 "Create a new public gist",
2828- "Name the file keytrace.json",
2828+ "Name the file `keytrace.json`",
2929 "Paste the verification content below into the file",
3030 "Save the gist and paste the URL below",
3131 ],
···88 */
99const activitypub: ServiceProvider = {
1010 id: "activitypub",
1111- name: "ActivityPub",
1212- homepage: "",
1111+ name: "Mastodon",
1212+ homepage: "https://joinmastodon.org",
13131414 // Match Mastodon-style profile URLs: https://instance/@username
1515 reUri: /^https:\/\/([^/]+)\/@([^/]+)\/?$/,
···1717 // Could match other ActivityPub software with same URL pattern
1818 isAmbiguous: true,
19192020+ ui: {
2121+ description: "Link your Mastodon or Fediverse account",
2222+ icon: "at-sign",
2323+ inputLabel: "Profile URL",
2424+ inputPlaceholder: "https://mastodon.social/@username",
2525+ instructions: [
2626+ "Go to your Mastodon instance and open **Edit profile**",
2727+ "Add your DID to your **bio** or create a new **profile metadata field**",
2828+ "For metadata fields, set the label to `keytrace` and paste your DID as the value",
2929+ "Save your profile changes",
3030+ "Paste your full profile URL below (e.g., `https://mastodon.social/@username`)",
3131+ ],
3232+ proofTemplate: "{did}",
3333+ },
3434+2035 processURI(uri, match) {
2136 const [, domain, username] = match;
2237···4358 },
44594560 postprocess(data) {
4646- const actor = data as { preferredUsername?: string; name?: string };
6161+ const actor = data as { preferredUsername?: string; name?: string; icon?: { url?: string } };
4762 return {
4848- display: actor.name || actor.preferredUsername,
6363+ displayName: actor.name || actor.preferredUsername,
6464+ avatarUrl: actor.icon?.url,
4965 };
5066 },
51675268 getProofText(did) {
5369 return did;
7070+ },
7171+7272+ getProofLocation() {
7373+ return `Add to your profile bio or a profile metadata field`;
5474 },
55755676 tests: [
+19
packages/runner/src/serviceProviders/bsky.ts
···16161717 isAmbiguous: false,
18181919+ ui: {
2020+ description: "Link another Bluesky account",
2121+ icon: "cloud",
2222+ inputLabel: "Profile URL",
2323+ inputPlaceholder: "https://bsky.app/profile/username.bsky.social",
2424+ instructions: [
2525+ "Log into the Bluesky account you want to link",
2626+ "Go to **Settings** → **Edit Profile**",
2727+ "Add your DID to your **bio** (the verification DID, not this account's DID)",
2828+ "Save your profile changes",
2929+ "Paste the profile URL below",
3030+ ],
3131+ proofTemplate: "{did}",
3232+ },
3333+1934 processURI(uri, match) {
2035 const [, handle] = match;
2136···41564257 getProofText(did) {
4358 return did;
5959+ },
6060+6161+ getProofLocation() {
6262+ return `Add to your profile bio`;
4463 },
45644665 tests: [
+21-1
packages/runner/src/serviceProviders/dns.ts
···88 */
99const dns: ServiceProvider = {
1010 id: "dns",
1111- name: "DNS",
1111+ name: "Domain",
1212 homepage: "",
13131414 // Match dns:domain.tld URIs (must contain at least one dot)
···16161717 isAmbiguous: false,
18181919+ ui: {
2020+ description: "Link via DNS TXT record",
2121+ icon: "globe",
2222+ inputLabel: "Domain",
2323+ inputPlaceholder: "example.com",
2424+ instructions: [
2525+ "Open your domain's DNS settings (usually in your registrar or hosting provider)",
2626+ "Add a new **TXT record** at the root domain (or at `_keytrace.yourdomain.com`)",
2727+ "Set the record value to the verification content below",
2828+ "Save and wait for DNS propagation (may take a few minutes to an hour)",
2929+ "Enter your domain below and verify",
3030+ ],
3131+ proofTemplate: "keytrace-verification={did}",
3232+ },
3333+1934 processURI(uri, match) {
2035 const [, domain] = match;
2136···41564257 getProofText(did) {
4358 return `keytrace-verification=${did}`;
5959+ },
6060+6161+ getProofLocation(match) {
6262+ const [, domain] = match;
6363+ return `Add a TXT record at the root of ${domain} (or at _keytrace.${domain})`;
4464 },
45654666 tests: [
+30
packages/runner/src/serviceProviders/github.ts
···16161717 isAmbiguous: false,
18181919+ ui: {
2020+ description: "Link via a public gist",
2121+ icon: "github",
2222+ inputLabel: "Gist URL",
2323+ inputPlaceholder: "https://gist.github.com/username/abc123...",
2424+ instructions: [
2525+ "Go to [gist.github.com](https://gist.github.com) and create a new gist",
2626+ "Name the file `keytrace.json` (or `keytrace.md` or `proof.md`)",
2727+ "Paste the verification content below into the file",
2828+ "Make sure the gist is **public**, then save it",
2929+ "Copy the gist URL and paste it below",
3030+ ],
3131+ proofTemplate: '{\n "did": "{did}"\n}',
3232+ },
3333+1934 processURI(uri, match) {
2035 const [, username, gistId] = match;
2136···6883 };
6984 },
70858686+ postprocess(data, match) {
8787+ const [, username] = match;
8888+ const gist = data as { owner?: { avatar_url?: string; login?: string } };
8989+9090+ return {
9191+ subject: gist.owner?.login ?? username,
9292+ avatarUrl: gist.owner?.avatar_url,
9393+ profileUrl: `https://github.com/${gist.owner?.login ?? username}`,
9494+ };
9595+ },
9696+7197 getProofText(did) {
7298 return `Verifying my identity on keytrace: ${did}`;
9999+ },
100100+101101+ getProofLocation() {
102102+ return `Create a public gist with a file named keytrace.json, keytrace.md, or proof.md containing the proof text`;
73103 },
7410475105 tests: [
+1-1
packages/runner/src/serviceProviders/index.ts
···44import bsky from "./bsky.js";
55import type { ServiceProvider, ServiceProviderMatch } from "./types.js";
6677-export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI } from "./types.js";
77+export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./types.js";
8899const providers: Record<string, ServiceProvider> = {
1010 github,
+29-3
packages/runner/src/serviceProviders/types.ts
···5353}
54545555/**
5656+ * UI configuration for the add claim wizard
5757+ */
5858+export interface ServiceProviderUI {
5959+ /** Short description for service picker (e.g., "Link via a public gist") */
6060+ description: string;
6161+ /** Lucide icon name (e.g., "github", "globe") */
6262+ icon: string;
6363+ /** Label for the claim URI input field */
6464+ inputLabel: string;
6565+ /** Placeholder text for the claim URI input */
6666+ inputPlaceholder: string;
6767+ /** Step-by-step instructions (markdown supported) */
6868+ instructions: string[];
6969+ /** Template for proof content. Supports {did} and {handle} placeholders */
7070+ proofTemplate: string;
7171+}
7272+7373+/**
5674 * A service provider that can verify identity claims
5775 */
5876export interface ServiceProvider {
···6987 /** Whether matches are potentially ambiguous (could match multiple providers) */
7088 isAmbiguous?: boolean;
71899090+ /** UI configuration for the add claim wizard */
9191+ ui: ServiceProviderUI;
9292+7293 /** Process matched URI into verification config */
7394 processURI(uri: string, match: RegExpMatchArray): ProcessedURI;
74957575- /** Optional post-processing after fetch */
9696+ /** Optional post-processing after fetch to extract identity metadata */
7697 postprocess?(
7798 data: unknown,
7899 match: RegExpMatchArray,
79100 ): {
8080- display?: string;
8181- uri?: string;
101101+ subject?: string;
102102+ avatarUrl?: string;
103103+ profileUrl?: string;
104104+ displayName?: string;
82105 };
8310684107 /** Generate proof text for user to add to their profile */
85108 getProofText(did: string, handle?: string): string;
109109+110110+ /** Human-readable instructions for where to place the proof */
111111+ getProofLocation?(match: RegExpMatchArray): string;
8611287113 /** Test cases for validation */
88114 tests: {