Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
11 kB · 429 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430import { defs, type IndexedEntry, normalizeOp, type Operation,} from "@atcute/did-plc";import { P256PrivateKey, parsePrivateMultikey, parsePublicMultikey, Secp256k1PrivateKey, Secp256k1PrivateKeyExportable,} from "@atcute/crypto";import * as CBOR from "@atcute/cbor";import { fromBase16, fromBase58Btc, fromBase64Url, toBase64Url,} from "@atcute/multibase";
export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey;
export interface KeypairInfo { type: "private_key"; didPublicKey: `did:key:${string}`; keypair: PrivateKey;}
export interface PlcService { type: string; endpoint: string;}
export interface PlcOperationData { type: "plc_operation"; prev: string | null; alsoKnownAs: string[]; rotationKeys: string[]; services: Record<string, PlcService>; verificationMethods: Record<string, string>; sig?: string;}
type KeyCurve = "secp256k1" | "p256";
const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i;const BASE58BTC_CHARSET_REGEX = /^[a-km-zA-HJ-NP-Z1-9]+$/;
const importRawBytes = ( bytes: Uint8Array, curve: KeyCurve,): Promise<PrivateKey> => curve === "p256" ? P256PrivateKey.importRaw(bytes) : Secp256k1PrivateKey.importRaw(bytes);
const importFromMultikeyMatch = ( match: ReturnType<typeof parsePrivateMultikey>,): Promise<PrivateKey> => match.type === "p256" ? P256PrivateKey.importRaw(match.privateKeyBytes) : Secp256k1PrivateKey.importRaw(match.privateKeyBytes);
const importJwk = async ( json: string, _curve: KeyCurve,): Promise<PrivateKey> => { const parsed: unknown = JSON.parse(json); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("Invalid JWK: expected a JSON object"); } const jwk = parsed as Record<string, unknown>;
if (jwk.kty !== "EC") { throw new Error( `Unsupported JWK key type: ${ String(jwk.kty) }. Only EC keys are supported`, ); }
if (typeof jwk.d !== "string") { throw new Error( "This JWK is a public key (missing 'd' parameter). The private key JWK is required", ); }
const detectedCurve: KeyCurve = (() => { switch (jwk.crv) { case "secp256k1": return "secp256k1"; case "P-256": return "p256"; default: throw new Error( `Unsupported JWK curve: ${ String(jwk.crv) }. Expected secp256k1 or P-256`, ); } })();
const privateKeyBytes = fromBase64Url(jwk.d); return importRawBytes(privateKeyBytes, detectedCurve);};
const importMultikeyOrBase58 = ( input: string, curve: KeyCurve,): Promise<PrivateKey> => { try { const match = parsePrivateMultikey(input); return importFromMultikeyMatch(match); } catch { try { parsePublicMultikey(input); throw new Error( "This is a public multikey. The private key multikey is required", ); } catch (publicErr) { if ( publicErr instanceof Error && publicErr.message.includes("public multikey") ) { throw publicErr; } }
try { return importBase58Raw(input, curve); } catch { return importBase58Raw(input.slice(1), curve); } }};
const importBase58Raw = ( input: string, curve: KeyCurve,): Promise<PrivateKey> => { const bytes = fromBase58Btc(input); if (bytes.length !== 32) { throw new Error( `Invalid base58 key: decoded to ${bytes.length} bytes, expected 32`, ); } return importRawBytes(bytes, curve);};
const detectAndImportPrivateKey = ( input: string, curve: KeyCurve,): Promise<PrivateKey> => { if (input.startsWith("{")) { return importJwk(input, curve); }
if (HEX_PRIVATE_KEY_REGEX.test(input)) { return importRawBytes(fromBase16(input.toLowerCase()), curve); }
if (input.startsWith("z")) { return importMultikeyOrBase58(input, curve); }
if (BASE58BTC_CHARSET_REGEX.test(input)) { return importBase58Raw(input, curve); }
throw new Error( "Unrecognized key format. Expected hex, base58, multikey, or JWK", );};
const jsonToB64Url = (obj: unknown): string => { const enc = new TextEncoder(); const json = JSON.stringify(obj); return toBase64Url(enc.encode(json));};
export class PlcOps { private plcDirectoryUrl: string;
constructor(plcDirectoryUrl = "https://plc.directory") { this.plcDirectoryUrl = plcDirectoryUrl; }
async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> { const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`); if (!response.ok) { throw new Error(`Failed to fetch PLC audit logs: ${response.status}`); } const json = await response.json(); return defs.indexedEntryLog.parse(json); }
async getLastPlcOpFromPlc( did: string, ): Promise<{ lastOperation: Operation; base: IndexedEntry }> { const logs = await this.getPlcAuditLogs(did); const lastOp = logs.at(-1); if (!lastOp) { throw new Error("No PLC operations found for this DID"); } if (lastOp.operation.type === "plc_tombstone") { throw new Error("DID has been tombstoned"); } return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; }
async getCurrentRotationKeysForUser(did: string): Promise<string[]> { const { lastOperation } = await this.getLastPlcOpFromPlc(did); return lastOperation.rotationKeys || []; }
async createNewSecp256k1Keypair(): Promise< { privateKey: string; publicKey: `did:key:${string}` } > { const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); const publicKey = await keypair.exportPublicKey("did"); const privateKey = await keypair.exportPrivateKey("multikey"); return { privateKey, publicKey }; }
async getKeyPair( privateKeyString: string, type: KeyCurve = "secp256k1", ): Promise<KeypairInfo> { const trimmed = privateKeyString.trim();
if (trimmed.length === 0) { throw new Error("Private key is required"); }
if (trimmed.startsWith("did:key:")) { throw new Error( "This is a did:key public key identifier. The private key is required", ); }
const keypair = await detectAndImportPrivateKey(trimmed, type);
return { type: "private_key", didPublicKey: await keypair.exportPublicKey("did"), keypair, }; }
async getMatchingKeyPair( privateKeyString: string, acceptableDidKeys: readonly string[], ): Promise<KeypairInfo | null> { const curves: readonly KeyCurve[] = ["secp256k1", "p256"]; const results = await Promise.allSettled( curves.map((curve) => this.getKeyPair(privateKeyString, curve)), ); const candidates = results .filter( (r): r is PromiseFulfilledResult<KeypairInfo> => r.status === "fulfilled", ) .map((r) => r.value); if (candidates.length === 0) { const rejection = results.find( (r): r is PromiseRejectedResult => r.status === "rejected", ); throw rejection?.reason ?? new Error("Unrecognized key format"); } return ( candidates.find((info) => acceptableDidKeys.includes(info.didPublicKey) ) ?? null ); }
async signAndPublishNewOp( did: string, signingRotationKey: PrivateKey, alsoKnownAs: string[], rotationKeys: string[], pds: string, verificationKey: string, prev: string, ): Promise<void> { const rotationKeysToUse = [...new Set(rotationKeys)]; if (rotationKeysToUse.length === 0) { throw new Error("No rotation keys provided"); } if (rotationKeysToUse.length > 5) { throw new Error("Maximum 5 rotation keys allowed"); }
const operation: PlcOperationData = { type: "plc_operation", prev, alsoKnownAs, rotationKeys: rotationKeysToUse, services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: pds, }, }, verificationMethods: { atproto: verificationKey, }, };
const opBytes = CBOR.encode(operation); const sigBytes = await signingRotationKey.sign(opBytes); const signature = toBase64Url(sigBytes);
const signedOperation = { ...operation, sig: signature, };
await this.pushPlcOperation(did, signedOperation); }
async pushPlcOperation( did: string, operation: PlcOperationData, ): Promise<void> { const response = await fetch(`${this.plcDirectoryUrl}/${did}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(operation), });
if (!response.ok) { const contentType = response.headers.get("content-type"); if (contentType?.includes("application/json")) { const json = await response.json(); if ( typeof json === "object" && json !== null && typeof json.message === "string" ) { throw new Error(json.message); } } throw new Error(`PLC directory returned HTTP ${response.status}`); } }
async createServiceAuthToken( iss: string, aud: string, keypair: PrivateKey, lxm: string, ): Promise<string> { const iat = Math.floor(Date.now() / 1000); const exp = iat + 60;
const jti = (() => { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); })();
const header = { typ: "JWT", alg: "ES256K" }; const payload = { iat, iss, aud, exp, lxm, jti };
const headerB64 = jsonToB64Url(header); const payloadB64 = jsonToB64Url(payload); const toSignStr = `${headerB64}.${payloadB64}`;
const toSignBytes = new TextEncoder().encode(toSignStr); const sigBytes = await keypair.sign(toSignBytes); const sigB64 = toBase64Url(sigBytes);
return `${toSignStr}.${sigB64}`; }
async signPlcOperationWithCredentials( did: string, signingKey: PrivateKey, credentials: { rotationKeys?: string[]; alsoKnownAs?: string[]; verificationMethods?: Record<string, string>; services?: Record<string, PlcService>; }, additionalRotationKeys: string[], prevCid: string, ): Promise<void> { const rotationKeys = [ ...new Set([ ...(additionalRotationKeys || []), ...(credentials.rotationKeys || []), ]), ];
if (rotationKeys.length === 0) { throw new Error("No rotation keys provided"); } if (rotationKeys.length > 5) { throw new Error("Maximum 5 rotation keys allowed"); }
const operation: PlcOperationData = { type: "plc_operation", prev: prevCid, alsoKnownAs: credentials.alsoKnownAs || [], rotationKeys, services: credentials.services || {}, verificationMethods: credentials.verificationMethods || {}, };
const opBytes = CBOR.encode(operation); const sigBytes = await signingKey.sign(opBytes); const signature = toBase64Url(sigBytes);
const signedOperation = { ...operation, sig: signature, };
await this.pushPlcOperation(did, signedOperation); }}
export const plcOps = new PlcOps();