-
Notifications
You must be signed in to change notification settings - Fork 36
/
vdaDid.ts
363 lines (299 loc) · 13.3 KB
/
vdaDid.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import Axios from 'axios'
import { ethers } from 'ethers'
import { DIDDocument } from '@verida/did-document'
import EncryptionUtils from '@verida/encryption-utils'
import BlockchainApi from "./blockchain/blockchainApi";
import { interpretIdentifier } from '@verida/vda-common'
import { VdaDidConfigurationOptions, VdaDidEndpointResponses } from '@verida/types'
export default class VdaDid {
private options: VdaDidConfigurationOptions
private blockchain: BlockchainApi
private lastEndpointErrors?: VdaDidEndpointResponses
constructor(options: VdaDidConfigurationOptions) {
this.options = options
this.blockchain = new BlockchainApi(options)
}
/**
* Publish the first version of a DIDDocument to a list of endpoints.
*
* If an endpoint fails to accept the DID Document, that endpoint will be ignored and won't be included in the
* list of valid endpoints on chain.
*
* @param didDocument
* @param endpoints
* @return VdaDidEndpointResponses Map of endpoints where the DID Document was successfully published
*/
public async create(didDocument: DIDDocument, endpoints: string[], retries: number = 3): Promise<VdaDidEndpointResponses> {
this.lastEndpointErrors = undefined
if (!this.options.signKey) {
throw new Error(`Unable to create DID: No private key specified in config.`)
}
const doc = didDocument.export()
if (doc.versionId !== 0) {
throw new Error(`Unable to create DID: Document must be version 0 of the DID Document.`)
}
if (endpoints.length === 0) {
throw new Error(`Unable to create DID: No endpoints provided.`)
}
// Sign the DID Document
didDocument.signProof(this.options.signKey!)
// Submit to all the endpoints
const promises = []
for (let i in endpoints) {
promises.push(Axios.post(`${endpoints[i]}`, {
document: didDocument.export()
}))
}
// Verify all endpoints successfully created the DID Document
const finalEndpoints: any = {}
try {
// If any of the promises fail, the exception will be thrown
const results = await Promise.all(promises)
for (let i in endpoints) {
finalEndpoints[endpoints[i]] = {
status: 'success'
}
}
} catch (err: any) {
const message = err.response ? (err.response.data.message ? err.response.data.message : err.response.data) : err.message
if (message.match('DID Document already exists')) {
try {
const blockchainEntry = await this.blockchain.lookup(didDocument.id)
} catch (err: any) {
// DID document exists on the nodes, but not on the blockchain -- this shouldn't happen
// but we will cleanup by removing from the nodes and trying again
await this.deleteFromEndpoints(endpoints)
// try again
if (retries > 0) {
return await this.create(didDocument, endpoints, retries--)
}
}
// DID already exists, so use update instead
throw new Error('Unable to create DID: Already exists')
}
throw new Error(`Unable to create DID: Endpoints failed to accept the DID Document (${message})`)
}
// Publish final endpoints on-chain
try {
await this.blockchain.register(endpoints)
} catch (err: any) {
// blockchain write failed, so roll back endpoint DID document storage on the endpoints
await this.deleteFromEndpoints(endpoints)
throw new Error(`Unable to save DID to blockchain: ${err.message}`)
}
return finalEndpoints
}
/**
* Publish an updated version of a DIDDocument to a list of endpoints.
*
* If an endpoint fails to accept the DID Document, that will be reflected in the response.
*
* Note: Any failed endpoints will remain on-chain and will need to have the update re-attempted or remove the endpoint from the DID Registry
*
* @param didDocument
* @returns VdaDidEndpointResponses Map of endpoints where the DID Document was successfully published
*/
public async update(didDocument: DIDDocument, controllerPrivateKey?: string): Promise<VdaDidEndpointResponses> {
this.lastEndpointErrors = undefined
if (!this.options.signKey) {
throw new Error(`Unable to update DID Document. No private key specified in config.`)
}
const attributes = didDocument.export()
if (attributes.created == attributes.updated) {
throw new Error(`Unable to update DID Document. "updated" timestamp matches "created" timestamp.`)
}
didDocument.signProof(this.options.signKey)
// Fetch the endpoint list from the blockchain
const response: any = await this.blockchain.lookup(didDocument.id)
const didInfo = interpretIdentifier(didDocument.id)
let updateController = false
const currentController = `did:vda:${didInfo.network}:${response.didController}`.toLowerCase()
// @ts-ignore
if (currentController != didDocument.export().controller) {
// Controller has changed, ensure we have a private key
if (!controllerPrivateKey) {
throw new Error(`Unable to update DID Document. Changing controller, but "controllerPrivateKey" not specified.`)
}
// Ensure new controller in the DID Document matches the private key
const controllerAddress = ethers.utils.computeAddress(controllerPrivateKey)
if ((<string> didDocument.export().controller!).toLowerCase() !== `did:vda:${this.options.chainNameOrId}:${controllerAddress}`) {
//console.log((<string> didDocument.export().controller!).toLowerCase(), `did:vda:${this.options.chainNameOrId}:${controllerAddress}`)
throw new Error(`Unable to update DID Document. Changing controller, but private key doens't match controller in DID Document`)
}
updateController = true
}
// Update all the endpoints
const promises = []
for (let i in response.endpoints) {
const endpoint = response.endpoints[i]
promises.push(Axios.put(`${endpoint}`, {
document: didDocument.export()
}));
}
const results: any = await Promise.allSettled(promises)
const finalEndpoints: VdaDidEndpointResponses = {}
let successCount = 0
let failResponse: any = {}
let failEndpointUri: string = ''
for (let i in response.endpoints) {
const result = results[i]
const endpoint = response.endpoints[i]
if (result.status == 'rejected') {
const err = result.reason // @todo: is this correct
failResponse = {
status: 'fail',
message: err.response && err.response.data && err.response.data.message ? err.response.data.message : err.message
}
finalEndpoints[endpoint] = failResponse
failEndpointUri = endpoint
} else {
finalEndpoints[endpoint] = {
status: 'success'
}
successCount++
}
}
if (successCount === 0) {
this.lastEndpointErrors = finalEndpoints
throw new Error(`Unable to update DID: All endpoints failed to accept the DID Document (${failEndpointUri}: ${failResponse.message})`)
}
// If the controller doesn't match the DID, the controller may have changed
if (updateController) {
// If the DID controller has changed, update on-chain via `setController()`
await this.blockchain.setController(controllerPrivateKey!)
}
return finalEndpoints
}
// @todo: make async for all endpoints
private async deleteFromEndpoints(endpoints: string[]): Promise<any> {
const did = this.options.identifier.toLowerCase()
const nowInMinutes = Math.round((new Date()).getTime() / 1000 / 60)
const proofString = `Delete DID Document ${did} at ${nowInMinutes}`
const privateKey = new Uint8Array(Buffer.from(this.options.signKey!.substr(2),'hex'))
const signature = EncryptionUtils.signData(proofString, privateKey)
// Delete DID Document from all the endpoints
const promises = []
for (let i in endpoints) {
const endpoint = endpoints[i]
promises.push(Axios.delete(`${endpoints[i]}`, {
headers: {
signature
}
}))
}
const results = await Promise.allSettled(promises)
const finalEndpoints: VdaDidEndpointResponses = {}
let successCount = 0
for (let i in endpoints) {
const endpoint = endpoints[i]
const result = results[i]
if (result.status == 'rejected') {
const err = result.reason // @todo: is this correct
finalEndpoints[endpoint] = {
status: 'fail',
message: err.response && err.response.data && err.response.data.message ? err.response.data.message : err.message
}
} else {
finalEndpoints[endpoint] = {
status: 'success'
}
successCount++
}
}
return {
successCount,
finalEndpoints
}
}
public async delete(): Promise<VdaDidEndpointResponses> {
if (!this.options.signKey) {
throw new Error(`Unable to delete DID. No private key specified in config.`)
}
const did = this.options.identifier.toLowerCase()
// Fetch the endpoint list from the blockchain
const response = await this.blockchain.lookup(did)
// 1. Call revoke() on the DID registry
await this.blockchain.revoke()
// 2. Call DELETE on all endpoints
const {
successCount,
finalEndpoints
} = await this.deleteFromEndpoints(response.endpoints)
if (successCount === 0) {
this.lastEndpointErrors = finalEndpoints
throw new Error(`Unable to delete DID: All endpoints failed to accept the delete request`)
}
return finalEndpoints
}
/**
* Add a new to an existing DID
*
* @param endpointUri
* @param verifyAllVersions
*/
public async addEndpoint(endpointUri: string, verifyAllVersions=false) {
if (!this.options.signKey) {
throw new Error(`Unable to create DID. No private key specified in config.`)
}
// 1. Fetch all versions of the DID
const lookupResponse = await this.blockchain.lookup(this.options.identifier)
const endpoints = lookupResponse.endpoints
const versions = await this.fetchDocumentHistory(endpoints)
const versionHistory = []
for (let i in versions) {
versionHistory.push(versions[i].export())
}
// 2. Call /migrate on the new endpoint
// @todo: generate signature
const proofString = ''
const signature = ''
try {
const response = await Axios.post(`${endpointUri}/migrate`, {
versions: versionHistory,
signature
});
} catch (err: any) {
//console.error('addEndpoint error!!')
if (err.response) {
throw new Error(`Unable to add endpoint. ${err.response.data.message}`)
}
throw new Error(`Unable to add endpoint. ${err.message}`)
}
endpoints.push(endpointUri)
// Update the blockchain
await this.blockchain.register(endpoints)
}
// @todo: Implement
public async removeEndpoint(did: string, endpoint: string) {
if (!this.options.signKey) {
throw new Error(`Unable to create DID. No private key specified in config.`)
}
// @todo
}
public getLastEndpointErrors() {
return this.lastEndpointErrors
}
private async fetchDocumentHistory(endpoints: string[]): Promise<DIDDocument[]> {
const documents: DIDDocument[] = []
const endpointVersions: any = {}
for (let i in endpoints) {
const endpointUri = endpoints[i]
endpointVersions[endpointUri] = []
try {
const response = await Axios.get(`${endpointUri}?allVersions=true`);
if (response.data.status == 'success') {
for (let j in response.data.data.versions) {
const version = response.data.data.versions[j]
const doc = new DIDDocument(version)
endpointVersions[endpointUri].push(doc)
}
}
} catch (err: any) {
throw new Error(`Unable to fetch DID Document history. ${err.message}`)
}
}
// @todo: check consensus
// Return consensus of versioned DID Document
return endpointVersions[endpoints[0]]
}
}