this repo has no description
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();