this repo has no description
at main 8.0 kB view raw
1import { 2 defs, 3 type IndexedEntry, 4 normalizeOp, 5 type Operation, 6} from "@atcute/did-plc"; 7import { 8 P256PrivateKey, 9 parsePrivateMultikey, 10 Secp256k1PrivateKey, 11 Secp256k1PrivateKeyExportable, 12} from "@atcute/crypto"; 13import * as CBOR from "@atcute/cbor"; 14import { fromBase16, toBase64Url } from "@atcute/multibase"; 15 16export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey; 17 18export interface KeypairInfo { 19 type: "private_key"; 20 didPublicKey: `did:key:${string}`; 21 keypair: PrivateKey; 22} 23 24export interface PlcService { 25 type: string; 26 endpoint: string; 27} 28 29export interface PlcOperationData { 30 type: "plc_operation"; 31 prev: string | null; 32 alsoKnownAs: string[]; 33 rotationKeys: string[]; 34 services: Record<string, PlcService>; 35 verificationMethods: Record<string, string>; 36 sig?: string; 37} 38 39const jsonToB64Url = (obj: unknown): string => { 40 const enc = new TextEncoder(); 41 const json = JSON.stringify(obj); 42 return toBase64Url(enc.encode(json)); 43}; 44 45export class PlcOps { 46 private plcDirectoryUrl: string; 47 48 constructor(plcDirectoryUrl = "https://plc.directory") { 49 this.plcDirectoryUrl = plcDirectoryUrl; 50 } 51 52 async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> { 53 const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`); 54 if (!response.ok) { 55 throw new Error(`Failed to fetch PLC audit logs: ${response.status}`); 56 } 57 const json = await response.json(); 58 return defs.indexedEntryLog.parse(json); 59 } 60 61 async getLastPlcOpFromPlc( 62 did: string, 63 ): Promise<{ lastOperation: Operation; base: IndexedEntry }> { 64 const logs = await this.getPlcAuditLogs(did); 65 const lastOp = logs.at(-1); 66 if (!lastOp) { 67 throw new Error("No PLC operations found for this DID"); 68 } 69 if (lastOp.operation.type === "plc_tombstone") { 70 throw new Error("DID has been tombstoned"); 71 } 72 return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; 73 } 74 75 async getCurrentRotationKeysForUser(did: string): Promise<string[]> { 76 const { lastOperation } = await this.getLastPlcOpFromPlc(did); 77 return lastOperation.rotationKeys || []; 78 } 79 80 async createNewSecp256k1Keypair(): Promise< 81 { privateKey: string; publicKey: `did:key:${string}` } 82 > { 83 const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 84 const publicKey = await keypair.exportPublicKey("did"); 85 const privateKey = await keypair.exportPrivateKey("multikey"); 86 return { privateKey, publicKey }; 87 } 88 89 async getKeyPair( 90 privateKeyString: string, 91 type: "secp256k1" | "p256" = "secp256k1", 92 ): Promise<KeypairInfo> { 93 const HEX_REGEX = /^[0-9a-f]+$/i; 94 const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/; 95 let keypair: PrivateKey | undefined; 96 97 const trimmed = privateKeyString.trim(); 98 99 if (HEX_REGEX.test(trimmed) && trimmed.length === 64) { 100 const privateKeyBytes = fromBase16(trimmed); 101 if (type === "p256") { 102 keypair = await P256PrivateKey.importRaw(privateKeyBytes); 103 } else { 104 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 105 } 106 } else if (MULTIKEY_REGEX.test(trimmed)) { 107 const match = parsePrivateMultikey(trimmed); 108 const privateKeyBytes = match.privateKeyBytes; 109 if (match.type === "p256") { 110 keypair = await P256PrivateKey.importRaw(privateKeyBytes); 111 } else if (match.type === "secp256k1") { 112 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 113 } else { 114 throw new Error( 115 `Unsupported key type: ${(match as { type: string }).type}`, 116 ); 117 } 118 } else { 119 throw new Error( 120 "Invalid key format. Expected 64-char hex or multikey format.", 121 ); 122 } 123 124 if (!keypair) { 125 throw new Error("Failed to parse private key"); 126 } 127 128 return { 129 type: "private_key", 130 didPublicKey: await keypair.exportPublicKey("did"), 131 keypair, 132 }; 133 } 134 135 async signAndPublishNewOp( 136 did: string, 137 signingRotationKey: PrivateKey, 138 alsoKnownAs: string[], 139 rotationKeys: string[], 140 pds: string, 141 verificationKey: string, 142 prev: string, 143 ): Promise<void> { 144 const rotationKeysToUse = [...new Set(rotationKeys)]; 145 if (rotationKeysToUse.length === 0) { 146 throw new Error("No rotation keys provided"); 147 } 148 if (rotationKeysToUse.length > 5) { 149 throw new Error("Maximum 5 rotation keys allowed"); 150 } 151 152 const operation: PlcOperationData = { 153 type: "plc_operation", 154 prev, 155 alsoKnownAs, 156 rotationKeys: rotationKeysToUse, 157 services: { 158 atproto_pds: { 159 type: "AtprotoPersonalDataServer", 160 endpoint: pds, 161 }, 162 }, 163 verificationMethods: { 164 atproto: verificationKey, 165 }, 166 }; 167 168 const opBytes = CBOR.encode(operation); 169 const sigBytes = await signingRotationKey.sign(opBytes); 170 const signature = toBase64Url(sigBytes); 171 172 const signedOperation = { 173 ...operation, 174 sig: signature, 175 }; 176 177 await this.pushPlcOperation(did, signedOperation); 178 } 179 180 async pushPlcOperation( 181 did: string, 182 operation: PlcOperationData, 183 ): Promise<void> { 184 const response = await fetch(`${this.plcDirectoryUrl}/${did}`, { 185 method: "POST", 186 headers: { 187 "Content-Type": "application/json", 188 }, 189 body: JSON.stringify(operation), 190 }); 191 192 if (!response.ok) { 193 const contentType = response.headers.get("content-type"); 194 if (contentType?.includes("application/json")) { 195 const json = await response.json(); 196 if ( 197 typeof json === "object" && json !== null && 198 typeof json.message === "string" 199 ) { 200 throw new Error(json.message); 201 } 202 } 203 throw new Error(`PLC directory returned HTTP ${response.status}`); 204 } 205 } 206 207 async createServiceAuthToken( 208 iss: string, 209 aud: string, 210 keypair: PrivateKey, 211 lxm: string, 212 ): Promise<string> { 213 const iat = Math.floor(Date.now() / 1000); 214 const exp = iat + 60; 215 216 const jti = (() => { 217 const bytes = new Uint8Array(16); 218 crypto.getRandomValues(bytes); 219 return Array.from(bytes) 220 .map((b) => b.toString(16).padStart(2, "0")) 221 .join(""); 222 })(); 223 224 const header = { typ: "JWT", alg: "ES256K" }; 225 const payload = { iat, iss, aud, exp, lxm, jti }; 226 227 const headerB64 = jsonToB64Url(header); 228 const payloadB64 = jsonToB64Url(payload); 229 const toSignStr = `${headerB64}.${payloadB64}`; 230 231 const toSignBytes = new TextEncoder().encode(toSignStr); 232 const sigBytes = await keypair.sign(toSignBytes); 233 const sigB64 = toBase64Url(sigBytes); 234 235 return `${toSignStr}.${sigB64}`; 236 } 237 238 async signPlcOperationWithCredentials( 239 did: string, 240 signingKey: PrivateKey, 241 credentials: { 242 rotationKeys?: string[]; 243 alsoKnownAs?: string[]; 244 verificationMethods?: Record<string, string>; 245 services?: Record<string, PlcService>; 246 }, 247 additionalRotationKeys: string[], 248 prevCid: string, 249 ): Promise<void> { 250 const rotationKeys = [ 251 ...new Set([ 252 ...(additionalRotationKeys || []), 253 ...(credentials.rotationKeys || []), 254 ]), 255 ]; 256 257 if (rotationKeys.length === 0) { 258 throw new Error("No rotation keys provided"); 259 } 260 if (rotationKeys.length > 5) { 261 throw new Error("Maximum 5 rotation keys allowed"); 262 } 263 264 const operation: PlcOperationData = { 265 type: "plc_operation", 266 prev: prevCid, 267 alsoKnownAs: credentials.alsoKnownAs || [], 268 rotationKeys, 269 services: credentials.services || {}, 270 verificationMethods: credentials.verificationMethods || {}, 271 }; 272 273 const opBytes = CBOR.encode(operation); 274 const sigBytes = await signingKey.sign(opBytes); 275 const signature = toBase64Url(sigBytes); 276 277 const signedOperation = { 278 ...operation, 279 sig: signature, 280 }; 281 282 await this.pushPlcOperation(did, signedOperation); 283 } 284} 285 286export const plcOps = new PlcOps();