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;
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 return { lastOperation: normalizeOp(lastOp.operation), base: lastOp };
70 }
71
72 async getCurrentRotationKeysForUser(did: string): Promise<string[]> {
73 const { lastOperation } = await this.getLastPlcOpFromPlc(did);
74 return lastOperation.rotationKeys || [];
75 }
76
77 async createNewSecp256k1Keypair(): Promise<
78 { privateKey: string; publicKey: `did:key:${string}` }
79 > {
80 const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
81 const publicKey = await keypair.exportPublicKey("did");
82 const privateKey = await keypair.exportPrivateKey("multikey");
83 return { privateKey, publicKey };
84 }
85
86 async getKeyPair(
87 privateKeyString: string,
88 type: "secp256k1" | "p256" = "secp256k1",
89 ): Promise<KeypairInfo> {
90 const HEX_REGEX = /^[0-9a-f]+$/i;
91 const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
92 let keypair: PrivateKey | undefined;
93
94 const trimmed = privateKeyString.trim();
95
96 if (HEX_REGEX.test(trimmed) && trimmed.length === 64) {
97 const privateKeyBytes = fromBase16(trimmed);
98 if (type === "p256") {
99 keypair = await P256PrivateKey.importRaw(privateKeyBytes);
100 } else {
101 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
102 }
103 } else if (MULTIKEY_REGEX.test(trimmed)) {
104 const match = parsePrivateMultikey(trimmed);
105 const privateKeyBytes = match.privateKeyBytes;
106 if (match.type === "p256") {
107 keypair = await P256PrivateKey.importRaw(privateKeyBytes);
108 } else if (match.type === "secp256k1") {
109 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
110 } else {
111 throw new Error(`Unsupported key type: ${match.type}`);
112 }
113 } else {
114 throw new Error(
115 "Invalid key format. Expected 64-char hex or multikey format.",
116 );
117 }
118
119 if (!keypair) {
120 throw new Error("Failed to parse private key");
121 }
122
123 return {
124 type: "private_key",
125 didPublicKey: await keypair.exportPublicKey("did"),
126 keypair,
127 };
128 }
129
130 async signAndPublishNewOp(
131 did: string,
132 signingRotationKey: PrivateKey,
133 alsoKnownAs: string[],
134 rotationKeys: string[],
135 pds: string,
136 verificationKey: string,
137 prev: string,
138 ): Promise<void> {
139 const rotationKeysToUse = [...new Set(rotationKeys)];
140 if (rotationKeysToUse.length === 0) {
141 throw new Error("No rotation keys provided");
142 }
143 if (rotationKeysToUse.length > 5) {
144 throw new Error("Maximum 5 rotation keys allowed");
145 }
146
147 const operation: PlcOperationData = {
148 type: "plc_operation",
149 prev,
150 alsoKnownAs,
151 rotationKeys: rotationKeysToUse,
152 services: {
153 atproto_pds: {
154 type: "AtprotoPersonalDataServer",
155 endpoint: pds,
156 },
157 },
158 verificationMethods: {
159 atproto: verificationKey,
160 },
161 };
162
163 const opBytes = CBOR.encode(operation);
164 const sigBytes = await signingRotationKey.sign(opBytes);
165 const signature = toBase64Url(sigBytes);
166
167 const signedOperation = {
168 ...operation,
169 sig: signature,
170 };
171
172 await this.pushPlcOperation(did, signedOperation);
173 }
174
175 async pushPlcOperation(
176 did: string,
177 operation: PlcOperationData,
178 ): Promise<void> {
179 const response = await fetch(`${this.plcDirectoryUrl}/${did}`, {
180 method: "POST",
181 headers: {
182 "Content-Type": "application/json",
183 },
184 body: JSON.stringify(operation),
185 });
186
187 if (!response.ok) {
188 const contentType = response.headers.get("content-type");
189 if (contentType?.includes("application/json")) {
190 const json = await response.json();
191 if (
192 typeof json === "object" && json !== null &&
193 typeof json.message === "string"
194 ) {
195 throw new Error(json.message);
196 }
197 }
198 throw new Error(`PLC directory returned HTTP ${response.status}`);
199 }
200 }
201
202 async createServiceAuthToken(
203 iss: string,
204 aud: string,
205 keypair: PrivateKey,
206 lxm: string,
207 ): Promise<string> {
208 const iat = Math.floor(Date.now() / 1000);
209 const exp = iat + 60;
210
211 const jti = (() => {
212 const bytes = new Uint8Array(16);
213 crypto.getRandomValues(bytes);
214 return Array.from(bytes)
215 .map((b) => b.toString(16).padStart(2, "0"))
216 .join("");
217 })();
218
219 const header = { typ: "JWT", alg: "ES256K" };
220 const payload = { iat, iss, aud, exp, lxm, jti };
221
222 const headerB64 = jsonToB64Url(header);
223 const payloadB64 = jsonToB64Url(payload);
224 const toSignStr = `${headerB64}.${payloadB64}`;
225
226 const toSignBytes = new TextEncoder().encode(toSignStr);
227 const sigBytes = await keypair.sign(toSignBytes);
228 const sigB64 = toBase64Url(sigBytes);
229
230 return `${toSignStr}.${sigB64}`;
231 }
232
233 async signPlcOperationWithCredentials(
234 did: string,
235 signingKey: PrivateKey,
236 credentials: {
237 rotationKeys?: string[];
238 alsoKnownAs?: string[];
239 verificationMethods?: Record<string, string>;
240 services?: Record<string, PlcService>;
241 },
242 additionalRotationKeys: string[],
243 prevCid: string,
244 ): Promise<void> {
245 const rotationKeys = [
246 ...new Set([
247 ...(additionalRotationKeys || []),
248 ...(credentials.rotationKeys || []),
249 ]),
250 ];
251
252 if (rotationKeys.length === 0) {
253 throw new Error("No rotation keys provided");
254 }
255 if (rotationKeys.length > 5) {
256 throw new Error("Maximum 5 rotation keys allowed");
257 }
258
259 const operation: PlcOperationData = {
260 type: "plc_operation",
261 prev: prevCid,
262 alsoKnownAs: credentials.alsoKnownAs || [],
263 rotationKeys,
264 services: credentials.services || {},
265 verificationMethods: credentials.verificationMethods || {},
266 };
267
268 const opBytes = CBOR.encode(operation);
269 const sigBytes = await signingKey.sign(opBytes);
270 const signature = toBase64Url(sigBytes);
271
272 const signedOperation = {
273 ...operation,
274 sig: signature,
275 };
276
277 await this.pushPlcOperation(did, signedOperation);
278 }
279}
280
281export const plcOps = new PlcOps();