Fork of github.com/did-method-plc/did-method-plc
1import * as cbor from '@ipld/dag-cbor'
2import { CID } from 'multiformats/cid'
3import * as uint8arrays from 'uint8arrays'
4import { Keypair, sha256, verifySignature } from '@atproto/crypto'
5import { check, cidForCbor } from '@atproto/common'
6import * as t from './types'
7import {
8 GenesisHashError,
9 ImproperOperationError,
10 InvalidSignatureError,
11 MisorderedOperationError,
12} from './error'
13
14export const didForCreateOp = async (op: t.CompatibleOp) => {
15 const hashOfGenesis = await sha256(cbor.encode(op))
16 const hashB32 = uint8arrays.toString(hashOfGenesis, 'base32')
17 const truncated = hashB32.slice(0, 24)
18 return `did:plc:${truncated}`
19}
20
21// Operations formatting
22// ---------------------------
23
24export const formatAtprotoOp = (opts: {
25 signingKey: string
26 handle: string
27 pds: string
28 rotationKeys: string[]
29 prev: CID | null
30}): t.UnsignedOperation => {
31 return {
32 type: 'plc_operation',
33 verificationMethods: {
34 atproto: opts.signingKey,
35 },
36 rotationKeys: opts.rotationKeys,
37 alsoKnownAs: [ensureAtprotoPrefix(opts.handle)],
38 services: {
39 atproto_pds: {
40 type: 'AtprotoPersonalDataServer',
41 endpoint: ensureHttpPrefix(opts.pds),
42 },
43 },
44 prev: opts.prev?.toString() ?? null,
45 }
46}
47
48export const atprotoOp = async (opts: {
49 signingKey: string
50 handle: string
51 pds: string
52 rotationKeys: string[]
53 prev: CID | null
54 signer: Keypair
55}) => {
56 return addSignature(formatAtprotoOp(opts), opts.signer)
57}
58
59export const createOp = async (opts: {
60 signingKey: string
61 handle: string
62 pds: string
63 rotationKeys: string[]
64 signer: Keypair
65}): Promise<{ op: t.Operation; did: string }> => {
66 const op = await atprotoOp({ ...opts, prev: null })
67 const did = await didForCreateOp(op)
68 return { op, did }
69}
70
71export const createUpdateOp = async (
72 lastOp: t.CompatibleOp,
73 signer: Keypair,
74 fn: (normalized: t.UnsignedOperation) => Omit<t.UnsignedOperation, 'prev'>,
75): Promise<t.Operation> => {
76 const prev = await cidForCbor(lastOp)
77 // omit sig so it doesn't accidentally make its way into the next operation
78 // eslint-disable-next-line @typescript-eslint/no-unused-vars
79 const { sig, ...normalized } = normalizeOp(lastOp)
80 const unsigned = await fn(normalized)
81 return addSignature(
82 {
83 ...unsigned,
84 prev: prev.toString(),
85 },
86 signer,
87 )
88}
89
90export const createAtprotoUpdateOp = async (
91 lastOp: t.CompatibleOp,
92 signer: Keypair,
93 opts: Partial<{
94 signingKey: string
95 handle: string
96 pds: string
97 rotationKeys: string[]
98 }>,
99) => {
100 return createUpdateOp(lastOp, signer, (normalized) => {
101 const updated = { ...normalized }
102 if (opts.signingKey) {
103 updated.verificationMethods = {
104 ...normalized.verificationMethods,
105 atproto: opts.signingKey,
106 }
107 }
108 if (opts.handle) {
109 const formatted = ensureAtprotoPrefix(opts.handle)
110 const handleI = normalized.alsoKnownAs.findIndex((h) =>
111 h.startsWith('at://'),
112 )
113 if (handleI < 0) {
114 updated.alsoKnownAs = [formatted, ...normalized.alsoKnownAs]
115 } else {
116 updated.alsoKnownAs = [
117 ...normalized.alsoKnownAs.slice(0, handleI),
118 formatted,
119 ...normalized.alsoKnownAs.slice(handleI + 1),
120 ]
121 }
122 }
123 if (opts.pds) {
124 const formatted = ensureHttpPrefix(opts.pds)
125 updated.services = {
126 ...normalized.services,
127 atproto_pds: {
128 type: 'AtprotoPersonalDataServer',
129 endpoint: formatted,
130 },
131 }
132 }
133 if (opts.rotationKeys) {
134 updated.rotationKeys = opts.rotationKeys
135 }
136 return updated
137 })
138}
139
140export const updateAtprotoKeyOp = async (
141 lastOp: t.CompatibleOp,
142 signer: Keypair,
143 signingKey: string,
144): Promise<t.Operation> => {
145 return createAtprotoUpdateOp(lastOp, signer, { signingKey })
146}
147
148export const updateHandleOp = async (
149 lastOp: t.CompatibleOp,
150 signer: Keypair,
151 handle: string,
152): Promise<t.Operation> => {
153 return createAtprotoUpdateOp(lastOp, signer, { handle })
154}
155
156export const updatePdsOp = async (
157 lastOp: t.CompatibleOp,
158 signer: Keypair,
159 pds: string,
160): Promise<t.Operation> => {
161 return createAtprotoUpdateOp(lastOp, signer, { pds })
162}
163
164export const updateRotationKeysOp = async (
165 lastOp: t.CompatibleOp,
166 signer: Keypair,
167 rotationKeys: string[],
168): Promise<t.Operation> => {
169 return createAtprotoUpdateOp(lastOp, signer, { rotationKeys })
170}
171
172export const tombstoneOp = async (
173 prev: CID,
174 key: Keypair,
175): Promise<t.Tombstone> => {
176 return addSignature(
177 {
178 type: 'plc_tombstone',
179 prev: prev.toString(),
180 },
181 key,
182 )
183}
184
185// Signing operations
186// ---------------------------
187
188export const addSignature = async <T extends Record<string, unknown>>(
189 object: T,
190 key: Keypair,
191): Promise<T & { sig: string }> => {
192 const data = new Uint8Array(cbor.encode(object))
193 const sig = await key.sign(data)
194 return {
195 ...object,
196 sig: uint8arrays.toString(sig, 'base64url'),
197 }
198}
199
200export const signOperation = async (
201 op: t.UnsignedOperation,
202 signingKey: Keypair,
203): Promise<t.Operation> => {
204 return addSignature(op, signingKey)
205}
206
207// Backwards compatibility
208// ---------------------------
209
210export const deprecatedSignCreate = async (
211 op: t.UnsignedCreateOpV1,
212 signingKey: Keypair,
213): Promise<t.CreateOpV1> => {
214 return addSignature(op, signingKey)
215}
216
217export const normalizeOp = (op: t.CompatibleOp): t.Operation => {
218 if (check.is(op, t.def.operation)) {
219 return op
220 }
221 return {
222 type: 'plc_operation',
223 verificationMethods: {
224 atproto: op.signingKey,
225 },
226 rotationKeys: [op.recoveryKey, op.signingKey],
227 alsoKnownAs: [ensureAtprotoPrefix(op.handle)],
228 services: {
229 atproto_pds: {
230 type: 'AtprotoPersonalDataServer',
231 endpoint: ensureHttpPrefix(op.service),
232 },
233 },
234 prev: op.prev,
235 sig: op.sig,
236 }
237}
238
239// Verifying operations/signatures
240// ---------------------------
241
242export const assureValidCreationOp = async (
243 did: string,
244 op: t.CompatibleOpOrTombstone,
245): Promise<t.DocumentData> => {
246 if (check.is(op, t.def.tombstone)) {
247 throw new MisorderedOperationError()
248 }
249 const normalized = normalizeOp(op)
250 await assureValidSig(normalized.rotationKeys, op)
251 const expectedDid = await didForCreateOp(op)
252 if (expectedDid !== did) {
253 throw new GenesisHashError(expectedDid)
254 }
255 if (op.prev !== null) {
256 throw new ImproperOperationError('expected null prev on create', op)
257 }
258 const { verificationMethods, rotationKeys, alsoKnownAs, services } =
259 normalized
260 return { did, verificationMethods, rotationKeys, alsoKnownAs, services }
261}
262
263export const assureValidSig = async (
264 allowedDidKeys: string[],
265 op: t.CompatibleOpOrTombstone,
266): Promise<string> => {
267 const { sig, ...opData } = op
268 if (sig.endsWith('=')) {
269 throw new InvalidSignatureError(op)
270 }
271 const sigBytes = uint8arrays.fromString(sig, 'base64url')
272 const dataBytes = new Uint8Array(cbor.encode(opData))
273 for (const didKey of allowedDidKeys) {
274 const isValid = await verifySignature(didKey, dataBytes, sigBytes)
275 if (isValid) {
276 return didKey
277 }
278 }
279 throw new InvalidSignatureError(op)
280}
281
282// Util
283// ---------------------------
284
285export const ensureHttpPrefix = (str: string): string => {
286 if (str.startsWith('http://') || str.startsWith('https://')) {
287 return str
288 }
289 return `https://${str}`
290}
291
292export const ensureAtprotoPrefix = (str: string): string => {
293 if (str.startsWith('at://')) {
294 return str
295 }
296 const stripped = str.replace('http://', '').replace('https://', '')
297 return `at://${stripped}`
298}