@interop/did-method-webvh 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,36 @@
1
1
  import { documentStateIsValid, hashChainValid, newKeysAreInNextKeys, scidIsFromHash } from '../assertions.js';
2
- import { METHOD, PLACEHOLDER } from '../constants.js';
2
+ import { CONTEXT_LINKED_VP, ERROR_TYPE_INVALID_DID, ERROR_TYPE_NOT_FOUND, METHOD, METHOD_PARAMETER_KEYS, METHOD_PROTOCOL_V1_0, PLACEHOLDER, SERVICE_TYPE_LINKED_VP, SERVICE_TYPE_RELATIVE_REF, ServiceFragment, } from '../constants.js';
3
3
  import { DidResolutionError } from '../interfaces.js';
4
- import { createDate, createDIDDoc, createSCID, deepClone, deriveHash, enrichAlsoKnownAs, findVerificationMethod, generateParallelDidWeb, getBaseUrl, parseCanonicalAddress, replaceCreateDidPlaceholders, replaceValueInObject, validateCreateDidDocument, } from '../utils.js';
4
+ import { createNextVersionTime, parseUtcIso8601VersionTime, validateUtcIso8601NotInFuture, } from '../utils/iso8601-datetime.js';
5
+ import { createDate, createDIDDoc, createSCID, deepClone, deriveHash, enrichAlsoKnownAs, findVerificationMethod, generateParallelDidWeb, getBaseUrl, parseCanonicalAddress, parseDidWebvhIdentifier, replaceCreateDidPlaceholders, replaceValueInObject, serviceFragmentExists, validateCreateDidDocument, validateMethodSpecificPathSegments, } from '../utils.js';
5
6
  import { countVerifiedWitnessApprovals, fetchWitnessProofs, validateWitnessParameter } from '../witness.js';
6
- const VERSION = '1.0';
7
- const PROTOCOL = `did:${METHOD}:${VERSION}`;
7
+ const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
8
8
  const requireDidId = (id) => {
9
9
  if (!id) {
10
10
  throw new Error('DID document id is missing');
11
11
  }
12
12
  return id;
13
13
  };
14
+ const parseAndValidateVersionId = (versionId, expectedVersionNumber) => {
15
+ const firstDashIndex = versionId.indexOf('-');
16
+ const lastDashIndex = versionId.lastIndexOf('-');
17
+ if (firstDashIndex === -1 || firstDashIndex !== lastDashIndex) {
18
+ throw new Error(`versionId '${versionId}' must contain exactly one '-' separator`);
19
+ }
20
+ const version = versionId.slice(0, firstDashIndex);
21
+ const entryHash = versionId.slice(firstDashIndex + 1);
22
+ if (!/^\d+$/.test(version)) {
23
+ throw new Error(`versionId '${versionId}' must have a numeric version prefix`);
24
+ }
25
+ if (entryHash.length === 0) {
26
+ throw new Error(`versionId '${versionId}' must have a non-empty hash component`);
27
+ }
28
+ const versionNumber = Number(version);
29
+ if (versionNumber !== expectedVersionNumber) {
30
+ throw new Error(`version '${version}' in log doesn't match expected '${expectedVersionNumber}'.`);
31
+ }
32
+ return { version, versionNumber, entryHash };
33
+ };
14
34
  export const createDID = async (options) => {
15
35
  if (!options.updateKeys) {
16
36
  throw new Error('Update keys not supplied');
@@ -26,8 +46,12 @@ export const createDID = async (options) => {
26
46
  const parsed = parseCanonicalAddress(addressInput);
27
47
  const didDomainComponent = parsed.didDomainComponent;
28
48
  const allPaths = [...(parsed.paths || []), ...(options.paths || [])];
49
+ validateMethodSpecificPathSegments(allPaths, 'createDID path segments');
29
50
  const path = allPaths.length > 0 ? allPaths.join(':') : undefined;
30
51
  const controller = `did:${METHOD}:${PLACEHOLDER}:${didDomainComponent}${path ? `:${path}` : ''}`;
52
+ if (options.created) {
53
+ validateUtcIso8601NotInFuture(options.created, 'createDID created');
54
+ }
31
55
  const createdDate = createDate(options.created);
32
56
  // Safety guard: Strip secret keys from verification methods before creating DID document
33
57
  const safeVerificationMethods = options.verificationMethods?.map((vm) => {
@@ -72,7 +96,7 @@ export const createDID = async (options) => {
72
96
  versionId: PLACEHOLDER,
73
97
  versionTime: createdDate,
74
98
  parameters: {
75
- method: PROTOCOL,
99
+ method: METHOD_PROTOCOL_V1_0,
76
100
  ...params,
77
101
  },
78
102
  state: doc,
@@ -103,6 +127,9 @@ export const createDID = async (options) => {
103
127
  throw new Error(`version ${prelimEntry.versionId} is invalid.`);
104
128
  }
105
129
  const didId = requireDidId(prelimEntry.state.id);
130
+ if (didId !== didWithScid) {
131
+ throw new Error(`Created DID document id must match expected DID '${didWithScid}', got '${didId}'`);
132
+ }
106
133
  const webDoc = options.alsoKnownAsWeb ? generateParallelDidWeb(didId, prelimEntry.state) : undefined;
107
134
  return {
108
135
  did: didId,
@@ -123,14 +150,19 @@ export const resolveDIDFromLog = async (log, options = {}) => {
123
150
  throw new Error('Cannot specify both verificationMethod and version number/id');
124
151
  }
125
152
  const resolutionLog = log.map((l) => deepClone(l));
153
+ if (resolutionLog.length === 0) {
154
+ throw new Error(`Log identity binding check failed: no entries to process`);
155
+ }
126
156
  const protocol = resolutionLog[0]?.parameters?.method;
127
- if (protocol !== PROTOCOL) {
157
+ if (protocol !== METHOD_PROTOCOL_V1_0) {
128
158
  throw new Error(`'${protocol}' is not a supported method version.`);
129
159
  }
130
160
  let did = '';
131
161
  let doc = null;
132
162
  let resolvedDoc = null;
163
+ let resolvedDid = null;
133
164
  let lastValidDoc = null;
165
+ let lastValidDid = null;
134
166
  const meta = {
135
167
  versionId: '',
136
168
  created: '',
@@ -147,31 +179,41 @@ export const resolveDIDFromLog = async (log, options = {}) => {
147
179
  let resolvedMeta = null;
148
180
  let lastValidMeta = null;
149
181
  let i = 0;
182
+ const hasExplicitHistoricalSelector = options.versionNumber !== undefined ||
183
+ options.versionId !== undefined ||
184
+ options.versionTime !== undefined ||
185
+ options.verificationMethod !== undefined;
186
+ let didIdMatchCount = 0;
150
187
  let host = '';
188
+ let previousVersionTime;
151
189
  const requiredWitnessChecks = [];
152
- // Fast resolution is opt-in; full verification is the default conformant path.
153
- const fastResolve = options.fastResolve ?? false;
154
- const isFirstEntry = (idx) => idx === 0;
155
- const isLastFewEntries = (idx) => idx >= resolutionLog.length - 10; // Verify last 10 entries
156
- const shouldVerifyEntry = (idx) => !fastResolve || isFirstEntry(idx) || isLastFewEntries(idx);
190
+ let witnessThresholdFailure = false;
157
191
  try {
158
192
  while (i < resolutionLog.length) {
159
193
  const { versionId, versionTime, parameters, state, proof } = resolutionLog[i];
160
- const [version, entryHash] = versionId.split('-');
194
+ const { version, versionNumber, entryHash } = parseAndValidateVersionId(versionId, i + 1);
161
195
  const previousWitness = meta.witness ? deepClone(meta.witness) : undefined;
162
- if (parseInt(version, 10) !== i + 1) {
163
- throw new Error(`version '${version}' in log doesn't match expected '${i + 1}'.`);
164
- }
165
196
  meta.versionId = versionId;
166
- if (versionTime) {
167
- // TODO check timestamps make sense
197
+ if (!versionTime) {
198
+ throw new Error(`version '${version}' is missing versionTime`);
199
+ }
200
+ const currentVersionTime = parseUtcIso8601VersionTime(versionTime, `version '${version}' versionTime`);
201
+ if (previousVersionTime && currentVersionTime.getTime() <= previousVersionTime.getTime()) {
202
+ throw new Error(`versionTime for version '${version}' must be greater than previous entry time`);
168
203
  }
204
+ // Check against resolver's current time for each entry per spec normative language
205
+ const maxAllowedFutureTime = Date.now() + MAX_FUTURE_SKEW_MS;
206
+ if (currentVersionTime.getTime() > maxAllowedFutureTime) {
207
+ throw new Error(`versionTime for version '${version}' must not be more than 5 minutes in the future`);
208
+ }
209
+ previousVersionTime = currentVersionTime;
169
210
  meta.updated = versionTime;
170
211
  let newDoc = state;
212
+ const parsedStateDid = parseDidWebvhIdentifier(requireDidId(newDoc.id), `version '${version}' state.id`);
171
213
  if (version === '1') {
172
214
  meta.created = versionTime;
173
215
  newDoc = state;
174
- host = newDoc.id.split(':').at(-1);
216
+ host = parsedStateDid.locationKey;
175
217
  meta.scid = parameters.scid;
176
218
  if (options.scid && options.scid !== meta.scid) {
177
219
  throw new Error(`SCID in DID '${options.scid}' does not match SCID in log '${meta.scid}'`);
@@ -182,81 +224,101 @@ export const resolveDIDFromLog = async (log, options = {}) => {
182
224
  meta.prerotation = meta.nextKeyHashes.length > 0;
183
225
  meta.witness = parameters.witness || meta.witness;
184
226
  meta.watchers = parameters.watchers ?? null;
185
- if (shouldVerifyEntry(i)) {
186
- // Optimized: Use efficient object manipulation instead of JSON stringify/parse
187
- const logEntry = {
188
- versionId: PLACEHOLDER,
189
- versionTime: meta.created,
190
- parameters: replaceValueInObject(parameters, meta.scid, PLACEHOLDER),
191
- state: replaceValueInObject(newDoc, meta.scid, PLACEHOLDER),
192
- };
193
- const logEntryHash = await deriveHash(logEntry);
194
- meta.previousLogEntryHash = logEntryHash;
195
- if (!(await scidIsFromHash(meta.scid, logEntryHash))) {
196
- throw new Error(`SCID '${meta.scid}' not derived from logEntryHash '${logEntryHash}'`);
197
- }
198
- // Optimized: Direct object manipulation instead of JSON stringify/parse
199
- const prelimEntry = replaceValueInObject(logEntry, PLACEHOLDER, meta.scid);
200
- const logEntryHash2 = await deriveHash(prelimEntry);
201
- const verified = await documentStateIsValid({ ...prelimEntry, versionId: `1-${logEntryHash2}`, proof }, meta.updateKeys, meta.witness, false, options.verifier);
202
- if (!verified) {
203
- throw new Error(`version ${meta.versionId} failed verification of the proof.`);
204
- }
227
+ // Optimized: Use efficient object manipulation instead of JSON stringify/parse
228
+ const logEntry = {
229
+ versionId: PLACEHOLDER,
230
+ versionTime: meta.created,
231
+ parameters: replaceValueInObject(parameters, meta.scid, PLACEHOLDER),
232
+ state: replaceValueInObject(newDoc, meta.scid, PLACEHOLDER),
233
+ };
234
+ const logEntryHash = await deriveHash(logEntry);
235
+ meta.previousLogEntryHash = logEntryHash;
236
+ if (!(await scidIsFromHash(meta.scid, logEntryHash))) {
237
+ throw new Error(`SCID '${meta.scid}' not derived from logEntryHash '${logEntryHash}'`);
238
+ }
239
+ if (parsedStateDid.scid !== meta.scid) {
240
+ throw new Error(`SCID in state.id '${parsedStateDid.scid}' does not match SCID in log '${meta.scid}'`);
241
+ }
242
+ // Optimized: Direct object manipulation instead of JSON stringify/parse
243
+ const prelimEntry = replaceValueInObject(logEntry, PLACEHOLDER, meta.scid);
244
+ const logEntryHash2 = await deriveHash(prelimEntry);
245
+ const verified = await documentStateIsValid({ ...prelimEntry, versionId: `1-${logEntryHash2}`, proof }, meta.updateKeys, meta.witness, false, options.verifier);
246
+ if (!verified) {
247
+ throw new Error(`version ${meta.versionId} failed verification of the proof.`);
205
248
  }
206
249
  }
207
250
  else {
208
251
  // version number > 1
209
- const newHost = newDoc.id.split(':').at(-1);
210
- if (!meta.portable && newHost !== host) {
252
+ // Validate method parameter: must not be present or must equal the supported method
253
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.method)) {
254
+ const entryMethod = parameters.method;
255
+ if (entryMethod !== METHOD_PROTOCOL_V1_0) {
256
+ throw new Error(`version '${version}' has unsupported or downgraded method '${entryMethod}'; ` +
257
+ `expected '${METHOD_PROTOCOL_V1_0}'`);
258
+ }
259
+ }
260
+ // scid MUST NOT appear in later entries
261
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.scid)) {
262
+ throw new Error(`version '${version}' must not contain SCID parameter`);
263
+ }
264
+ // portable: true cannot be introduced after the first entry — it can only remain
265
+ // true if it was already enabled in the first entry
266
+ if (parameters.portable === true && !meta.portable) {
267
+ throw new Error(`version '${version}' cannot set portable: true; portability can only be enabled in the first entry`);
268
+ }
269
+ // Setting portable: false in a later entry permanently locks portability off
270
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.portable) && parameters.portable === false) {
271
+ meta.portable = false;
272
+ }
273
+ if (parsedStateDid.scid !== meta.scid) {
274
+ throw new Error(`SCID in state.id '${parsedStateDid.scid}' does not match SCID in log '${meta.scid}'`);
275
+ }
276
+ const newLocation = parsedStateDid.locationKey;
277
+ if (!meta.portable && newLocation !== host) {
211
278
  throw new Error('Cannot move DID: portability is disabled');
212
279
  }
213
- else if (newHost !== host) {
214
- host = newHost;
280
+ else if (newLocation !== host) {
281
+ host = newLocation;
215
282
  }
216
- // Hash chain — ALWAYS runs (cheap), even in fast-resolve
283
+ // Hash chain
217
284
  const { proof: _proof, ...entryWithoutProof } = resolutionLog[i];
218
285
  const recomputedHash = await deriveHash({ ...entryWithoutProof, versionId: resolutionLog[i - 1].versionId });
219
286
  if (!hashChainValid(recomputedHash, entryHash)) {
220
287
  throw new Error(`Hash chain broken at '${meta.versionId}'`);
221
288
  }
222
- if (shouldVerifyEntry(i)) {
223
- // Signature verification expensive, skipped for middle entries in fast-resolve
224
- const keys = meta.prerotation ? parameters.updateKeys : meta.updateKeys;
225
- const verified = await documentStateIsValid(resolutionLog[i], keys, meta.witness, false, options.verifier);
226
- if (!verified) {
227
- throw new Error(`version ${meta.versionId} failed verification of the proof.`);
228
- }
229
- if (meta.prerotation) {
230
- await newKeysAreInNextKeys(parameters.updateKeys ?? [], meta.nextKeyHashes ?? []);
231
- }
289
+ // Signature verification
290
+ const keys = meta.prerotation ? parameters.updateKeys : meta.updateKeys;
291
+ const verified = await documentStateIsValid(resolutionLog[i], keys, meta.witness, false, options.verifier);
292
+ if (!verified) {
293
+ throw new Error(`version ${meta.versionId} failed verification of the proof.`);
232
294
  }
233
- if (parameters.updateKeys) {
234
- meta.updateKeys = parameters.updateKeys;
295
+ if (meta.prerotation) {
296
+ await newKeysAreInNextKeys(parameters.updateKeys ?? [], meta.nextKeyHashes ?? []);
297
+ }
298
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.updateKeys)) {
299
+ meta.updateKeys = parameters.updateKeys ?? [];
235
300
  }
236
301
  if (parameters.deactivated === true) {
237
302
  meta.deactivated = true;
238
303
  }
239
- if (parameters.nextKeyHashes && parameters.nextKeyHashes.length > 0) {
240
- meta.nextKeyHashes = parameters.nextKeyHashes;
241
- meta.prerotation = true;
242
- }
243
- else {
244
- meta.nextKeyHashes = [];
245
- meta.prerotation = false;
304
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.nextKeyHashes)) {
305
+ meta.nextKeyHashes = parameters.nextKeyHashes ?? [];
306
+ meta.prerotation = meta.nextKeyHashes.length > 0;
246
307
  }
247
- if ('witness' in parameters) {
308
+ const legacyParameters = parameters;
309
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.witness)) {
248
310
  meta.witness = parameters.witness;
249
311
  }
250
- else if (parameters.witnesses) {
312
+ else if (legacyParameters.witnesses) {
251
313
  meta.witness = {
252
- witnesses: parameters.witnesses,
253
- threshold: parameters.witnessThreshold || parameters.witnesses.length,
314
+ witnesses: legacyParameters.witnesses,
315
+ threshold: legacyParameters.witnessThreshold || legacyParameters.witnesses.length,
254
316
  };
255
317
  }
256
318
  if (meta.witness?.witnesses?.length) {
257
319
  validateWitnessParameter(meta.witness);
258
320
  }
259
- if ('watchers' in parameters) {
321
+ if (Object.hasOwn(parameters, METHOD_PARAMETER_KEYS.watchers)) {
260
322
  meta.watchers = parameters.watchers ?? null;
261
323
  }
262
324
  }
@@ -264,64 +326,55 @@ export const resolveDIDFromLog = async (log, options = {}) => {
264
326
  if (requiredWitness) {
265
327
  requiredWitnessChecks.push({
266
328
  targetVersionId: meta.versionId,
267
- targetVersionNumber: parseInt(version, 10),
329
+ targetVersionNumber: versionNumber,
268
330
  witness: requiredWitness,
269
331
  });
270
332
  }
271
333
  // Optimized: Use efficient cloning instead of clone() function
272
334
  doc = deepClone(newDoc);
273
- did = doc.id;
274
- // Only add default services for entries we need to process
275
- if (shouldVerifyEntry(i) || i === resolutionLog.length - 1) {
276
- // Add default services if they don't exist
277
- doc.service = Array.isArray(doc.service) ? doc.service : [];
278
- const baseUrl = getBaseUrl(did);
279
- if (!doc.service.some((s) => s.id === '#files')) {
280
- doc.service.push({
281
- id: '#files',
282
- type: 'relativeRef',
283
- serviceEndpoint: baseUrl,
284
- });
285
- }
286
- if (!doc.service.some((s) => s.id === '#whois')) {
287
- doc.service.push({
288
- '@context': 'https://identity.foundation/linked-vp/contexts/v1',
289
- id: '#whois',
290
- type: 'LinkedVerifiablePresentation',
291
- serviceEndpoint: `${baseUrl}/whois.vp`,
292
- });
293
- }
335
+ did = requireDidId(doc.id);
336
+ if (options.requestedDid && did === options.requestedDid) {
337
+ didIdMatchCount++;
294
338
  }
295
- if (options.verificationMethod && findVerificationMethod(doc, options.verificationMethod)) {
296
- if (!resolvedDoc) {
297
- resolvedDoc = deepClone(doc);
298
- resolvedMeta = { ...meta };
299
- }
339
+ // Add default services if they don't exist
340
+ doc.service = Array.isArray(doc.service) ? doc.service : [];
341
+ const baseUrl = getBaseUrl(did);
342
+ if (!serviceFragmentExists(doc.service, ServiceFragment.Files, did)) {
343
+ doc.service.push({
344
+ id: '#files',
345
+ type: SERVICE_TYPE_RELATIVE_REF,
346
+ serviceEndpoint: baseUrl,
347
+ });
300
348
  }
301
- if (options.versionNumber === parseInt(version, 10) || options.versionId === meta.versionId) {
302
- if (!resolvedDoc) {
303
- resolvedDoc = deepClone(doc);
304
- resolvedMeta = { ...meta };
305
- }
349
+ if (!serviceFragmentExists(doc.service, ServiceFragment.Whois, did)) {
350
+ doc.service.push({
351
+ '@context': CONTEXT_LINKED_VP,
352
+ id: '#whois',
353
+ type: SERVICE_TYPE_LINKED_VP,
354
+ serviceEndpoint: `${baseUrl}/whois.vp`,
355
+ });
306
356
  }
307
- if (options.versionTime && options.versionTime > new Date(meta.updated)) {
308
- if (resolutionLog[i + 1] && options.versionTime < new Date(resolutionLog[i + 1].versionTime)) {
309
- if (!resolvedDoc) {
310
- resolvedDoc = deepClone(doc);
311
- resolvedMeta = { ...meta };
312
- }
313
- }
314
- else if (!resolutionLog[i + 1]) {
315
- if (!resolvedDoc) {
316
- resolvedDoc = deepClone(doc);
317
- resolvedMeta = { ...meta };
318
- }
319
- }
357
+ // Latch the first entry matching the requested selector as the resolved result.
358
+ let matchesSelector = (!!options.verificationMethod && !!findVerificationMethod(doc, options.verificationMethod)) ||
359
+ options.versionNumber === versionNumber ||
360
+ options.versionId === meta.versionId;
361
+ if (!matchesSelector && options.versionTime && options.versionTime > new Date(meta.updated)) {
362
+ const nextEntry = resolutionLog[i + 1];
363
+ matchesSelector = !nextEntry || options.versionTime < new Date(nextEntry.versionTime);
364
+ }
365
+ if (matchesSelector && !resolvedDoc) {
366
+ resolvedDoc = deepClone(doc);
367
+ resolvedDid = did;
368
+ resolvedMeta = { ...meta };
320
369
  }
321
370
  lastValidDoc = deepClone(doc);
371
+ lastValidDid = did;
322
372
  lastValidMeta = { ...meta };
323
373
  i++;
324
374
  }
375
+ if (options.requestedDid && didIdMatchCount === 0) {
376
+ throw new Error(`Requested DID '${options.requestedDid}' does not match state.id in any valid log version`);
377
+ }
325
378
  if (requiredWitnessChecks.length > 0) {
326
379
  if (!options.witnessProofs) {
327
380
  options.witnessProofs = await fetchWitnessProofs(did);
@@ -335,6 +388,7 @@ export const resolveDIDFromLog = async (log, options = {}) => {
335
388
  const approvals = await countVerifiedWitnessApprovals(resolutionLog[check.targetVersionNumber - 1], candidateProofs, check.witness, options.verifier);
336
389
  const threshold = parseInt((check.witness.threshold ?? 0).toString(), 10);
337
390
  if (approvals < threshold) {
391
+ witnessThresholdFailure = true;
338
392
  throw new Error(`Witness threshold not met for version ${check.targetVersionId}: got ${approvals}, need ${check.witness.threshold}`);
339
393
  }
340
394
  }
@@ -344,25 +398,59 @@ export const resolveDIDFromLog = async (log, options = {}) => {
344
398
  if (!resolvedDoc) {
345
399
  throw e;
346
400
  }
347
- if (resolvedMeta) {
401
+ if (resolvedMeta && (!hasExplicitHistoricalSelector || witnessThresholdFailure)) {
348
402
  const message = e instanceof Error ? e.message : String(e);
349
403
  resolvedMeta.error = DidResolutionError.InvalidDid;
350
404
  resolvedMeta.problemDetails = {
351
- type: 'https://w3id.org/security#INVALID_CONTROLLED_IDENTIFIER_DOCUMENT_ID',
405
+ type: ERROR_TYPE_INVALID_DID,
352
406
  title: 'The resolved DID is invalid.',
353
407
  detail: message,
354
408
  };
355
409
  }
356
410
  }
357
411
  if (!resolvedDoc) {
358
- resolvedDoc = lastValidDoc;
412
+ if (hasExplicitHistoricalSelector) {
413
+ if (!lastValidMeta || !lastValidDid) {
414
+ throw new Error('DID resolution failed: No valid result available for explicit selector');
415
+ }
416
+ return {
417
+ did: lastValidDid,
418
+ doc: null,
419
+ meta: {
420
+ ...lastValidMeta,
421
+ error: DidResolutionError.NotFound,
422
+ problemDetails: {
423
+ type: ERROR_TYPE_NOT_FOUND,
424
+ title: 'The requested DID version was not found.',
425
+ detail: 'The supplied explicit version selector did not match any entry in the DID log.',
426
+ },
427
+ },
428
+ };
429
+ }
359
430
  resolvedMeta = lastValidMeta;
431
+ resolvedDid = lastValidDid;
432
+ if (resolvedMeta && !(resolvedMeta.deactivated && !hasExplicitHistoricalSelector)) {
433
+ resolvedDoc = lastValidDoc;
434
+ }
360
435
  }
361
436
  if (!resolvedMeta) {
362
437
  throw new Error('DID resolution failed: No valid metadata found');
363
438
  }
439
+ if (!resolvedDid) {
440
+ throw new Error('DID resolution failed: No valid identifier found');
441
+ }
442
+ if (resolvedMeta.deactivated && !hasExplicitHistoricalSelector) {
443
+ return {
444
+ did: resolvedDid,
445
+ doc: null,
446
+ meta: resolvedMeta,
447
+ };
448
+ }
449
+ if (!resolvedDoc) {
450
+ throw new Error('DID resolution failed: No valid document found');
451
+ }
364
452
  return {
365
- did,
453
+ did: resolvedDid,
366
454
  doc: resolvedDoc,
367
455
  meta: resolvedMeta,
368
456
  };
@@ -370,14 +458,25 @@ export const resolveDIDFromLog = async (log, options = {}) => {
370
458
  export const updateDID = async (options) => {
371
459
  const log = options.log;
372
460
  const lastEntry = log[log.length - 1];
461
+ const lastEntryDid = requireDidId(lastEntry.state.id);
462
+ const parsedLastEntryDid = parseDidWebvhIdentifier(lastEntryDid, 'last entry state.id');
373
463
  const lastMeta = (await resolveDIDFromLog(log, { verifier: options.verifier, witnessProofs: options.witnessProofs }))
374
464
  .meta;
465
+ const currentUpdateKeys = options.updateKeys;
375
466
  if (lastMeta.deactivated) {
376
467
  throw new Error('Cannot update deactivated DID');
377
468
  }
469
+ if (lastMeta.prerotation && currentUpdateKeys === undefined) {
470
+ throw new Error('updateKeys must be provided while pre-rotation is active');
471
+ }
378
472
  const versionNumber = log.length + 1;
379
- const createdDate = createDate(options.updated);
473
+ // Validate user-provided timestamp with skew tolerance before creating the versionTime
474
+ if (options.updated) {
475
+ validateUtcIso8601NotInFuture(options.updated, 'updateDID updated', MAX_FUTURE_SKEW_MS);
476
+ }
477
+ const createdDate = createNextVersionTime(lastMeta.updated, options.updated, createDate);
380
478
  const watchersValue = options.watchers !== undefined ? options.watchers : lastMeta.watchers;
479
+ const resolvedNextKeyHashes = options.nextKeyHashes ?? lastMeta.nextKeyHashes ?? [];
381
480
  const witnessInput = options.witness;
382
481
  const witness = witnessInput?.witnesses?.length
383
482
  ? {
@@ -385,15 +484,24 @@ export const updateDID = async (options) => {
385
484
  threshold: witnessInput.threshold ?? 0,
386
485
  }
387
486
  : {};
487
+ if (options.portable === true) {
488
+ throw new Error('portable: true cannot be set in an update entry; portability can only be enabled in the first entry');
489
+ }
388
490
  const params = {
389
- updateKeys: options.updateKeys ?? [],
390
- nextKeyHashes: options.nextKeyHashes ?? [],
491
+ ...(options.updateKeys !== undefined || lastMeta.prerotation
492
+ ? { updateKeys: options.updateKeys ?? lastMeta.updateKeys }
493
+ : {}),
494
+ ...(options.nextKeyHashes !== undefined ? { nextKeyHashes: options.nextKeyHashes } : {}),
495
+ ...(options.portable === false ? { portable: false } : {}),
391
496
  witness,
392
497
  watchers: watchersValue ?? [],
393
498
  };
394
499
  if (params.witness?.witnesses?.length) {
395
500
  validateWitnessParameter(params.witness);
396
501
  }
502
+ if (lastMeta.prerotation) {
503
+ await newKeysAreInNextKeys(currentUpdateKeys ?? [], lastMeta.nextKeyHashes ?? []);
504
+ }
397
505
  // Safety guard: Strip secret keys from verification methods before creating DID document
398
506
  const safeVerificationMethods = options.verificationMethods?.map((vm) => {
399
507
  if (vm.secretKeyMultibase) {
@@ -403,26 +511,77 @@ export const updateDID = async (options) => {
403
511
  }
404
512
  return vm;
405
513
  });
406
- const { doc } = await createDIDDoc({
514
+ // Determine the controller (the DID id) for this update. When a new location
515
+ // (address/domain) is supplied, rebuild the controller from that location while
516
+ // preserving the SCID, so a portable DID can actually move. The SCID is the
517
+ // stable part of the identifier; only the location component changes.
518
+ const requestedAddress = options.address || options.domain;
519
+ let controller;
520
+ let controllerPaths = parsedLastEntryDid.paths;
521
+ if (options.controller) {
522
+ controller = options.controller;
523
+ }
524
+ else if (requestedAddress) {
525
+ const parsedNewAddress = parseCanonicalAddress(requestedAddress);
526
+ // Paths: explicit options.paths (combined with any address-embedded paths) take
527
+ // precedence; otherwise use address-embedded paths; otherwise inherit the prior
528
+ // paths so re-passing a bare domain on a pathed DID doesn't silently drop them.
529
+ const addressPaths = parsedNewAddress.paths || [];
530
+ const newLocationPaths = options.paths !== undefined
531
+ ? [...addressPaths, ...options.paths]
532
+ : addressPaths.length
533
+ ? addressPaths
534
+ : (parsedLastEntryDid.paths ?? []);
535
+ const newLocationKey = newLocationPaths.length
536
+ ? `${parsedNewAddress.didDomainComponent}:${newLocationPaths.join(':')}`
537
+ : parsedNewAddress.didDomainComponent;
538
+ controller = `did:${METHOD}:${parsedLastEntryDid.scid}:${newLocationKey}`;
539
+ controllerPaths = newLocationPaths.length ? newLocationPaths : undefined;
540
+ if (controller !== lastEntryDid && !lastMeta.portable) {
541
+ throw new Error('Cannot move DID: portability is disabled');
542
+ }
543
+ }
544
+ else {
545
+ controller = lastEntryDid;
546
+ }
547
+ const { doc: normalizedUpdateDoc } = await createDIDDoc({
407
548
  ...options,
408
- controller: options.controller || lastEntry.state.id || '',
549
+ controller,
409
550
  context: options.context || lastEntry.state['@context'],
410
- domain: options.domain ?? lastEntry.state.id?.split(':').at(-1) ?? '',
551
+ domain: requestedAddress ?? parsedLastEntryDid.didDomainComponent,
552
+ paths: controllerPaths,
411
553
  updateKeys: options.updateKeys ?? [],
412
554
  verificationMethods: safeVerificationMethods ?? [],
413
555
  });
414
- // Add services if provided
415
- if (options.services && options.services.length > 0) {
556
+ // Carry the prior DID document forward and selectively overlay only the fields
557
+ // this update actually supplies, so a sparse updateDID() preserves prior state.
558
+ const doc = deepClone(lastEntry.state);
559
+ doc['@context'] = normalizedUpdateDoc['@context'];
560
+ doc.id = normalizedUpdateDoc.id;
561
+ doc.controller = normalizedUpdateDoc.controller;
562
+ if (safeVerificationMethods !== undefined) {
563
+ doc.verificationMethod = normalizedUpdateDoc.verificationMethod;
564
+ doc.authentication = normalizedUpdateDoc.authentication;
565
+ doc.assertionMethod = normalizedUpdateDoc.assertionMethod;
566
+ doc.keyAgreement = normalizedUpdateDoc.keyAgreement;
567
+ doc.capabilityDelegation = normalizedUpdateDoc.capabilityDelegation;
568
+ doc.capabilityInvocation = normalizedUpdateDoc.capabilityInvocation;
569
+ }
570
+ if (options.services !== undefined) {
416
571
  doc.service = options.services;
417
572
  }
418
- // Add assertionMethod if provided
419
- if (options.assertionMethod) {
573
+ if (options.authentication !== undefined) {
574
+ doc.authentication = options.authentication;
575
+ }
576
+ if (options.assertionMethod !== undefined) {
420
577
  doc.assertionMethod = options.assertionMethod;
421
578
  }
422
- // Add keyAgreement if provided
423
- if (options.keyAgreement) {
579
+ if (options.keyAgreement !== undefined) {
424
580
  doc.keyAgreement = options.keyAgreement;
425
581
  }
582
+ if (options.alsoKnownAs !== undefined) {
583
+ doc.alsoKnownAs = options.alsoKnownAs;
584
+ }
426
585
  const logEntry = {
427
586
  versionId: lastEntry.versionId,
428
587
  versionTime: createdDate,
@@ -442,7 +601,10 @@ export const updateDID = async (options) => {
442
601
  const signedProof = await options.signer.sign({ document: prelimEntry, proof: proofTemplate });
443
602
  const allProofs = [{ ...proofTemplate, proofValue: signedProof.proofValue }];
444
603
  prelimEntry.proof = allProofs;
445
- const keysToVerify = lastMeta.prerotation ? params.updateKeys : lastMeta.updateKeys;
604
+ const keysToVerify = lastMeta.prerotation ? currentUpdateKeys : lastMeta.updateKeys;
605
+ if (!keysToVerify) {
606
+ throw new Error('updateKeys could not be determined for update verification');
607
+ }
446
608
  const verified = await documentStateIsValid(prelimEntry, keysToVerify, lastMeta.witness, true, options.verifier);
447
609
  if (!verified) {
448
610
  throw new Error(`version ${prelimEntry.versionId} is invalid.`);
@@ -451,7 +613,7 @@ export const updateDID = async (options) => {
451
613
  ...lastMeta,
452
614
  versionId: prelimEntry.versionId,
453
615
  updated: prelimEntry.versionTime,
454
- prerotation: (params.nextKeyHashes?.length ?? 0) > 0,
616
+ prerotation: resolvedNextKeyHashes.length > 0,
455
617
  ...params,
456
618
  };
457
619
  const hasWebAlias = (prelimEntry.state.alsoKnownAs ?? []).some((alias) => alias.startsWith('did:web:'));
@@ -473,7 +635,7 @@ export const deactivateDID = async (options) => {
473
635
  throw new Error('DID already deactivated');
474
636
  }
475
637
  const versionNumber = log.length + 1;
476
- const createdDate = createDate();
638
+ const createdDate = createNextVersionTime(lastMeta.updated, undefined, createDate);
477
639
  const params = {
478
640
  updateKeys: options.updateKeys ?? lastMeta.updateKeys,
479
641
  deactivated: true,
@@ -522,9 +684,10 @@ const getEntryWitnessParameter = (parameters) => {
522
684
  return parameters.witness ?? {};
523
685
  }
524
686
  if (parameters.witnesses) {
687
+ const legacyParameters = parameters;
525
688
  return {
526
- witnesses: parameters.witnesses,
527
- threshold: parameters.witnessThreshold || parameters.witnesses.length,
689
+ witnesses: legacyParameters.witnesses,
690
+ threshold: legacyParameters.witnessThreshold || legacyParameters.witnesses.length,
528
691
  };
529
692
  }
530
693
  return undefined;