@did-btcr2/method 0.63.0 → 0.64.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.
@@ -18,6 +18,7 @@ import {
18
18
  } from '@did-btcr2/common';
19
19
  import type { HashBytes } from '@did-btcr2/common';
20
20
  import type {
21
+ Btcr2DataIntegrityProof,
21
22
  SignedBTCR2Update,
22
23
  UnsignedBTCR2Update
23
24
  } from './btcr2-update.js';
@@ -28,9 +29,9 @@ import {
28
29
  SchnorrMultikey
29
30
  } from '@did-btcr2/cryptosuite';
30
31
  import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
31
- import { DidBtcr2 } from '../did-btcr2.js';
32
32
  import { Appendix } from '../utils/appendix.js';
33
33
  import { DidDocument, ID_PLACEHOLDER_VALUE } from '../utils/did-document.js';
34
+ import { errorCause } from '../utils/error-cause.js';
34
35
  import { BeaconFactory } from './beacon/factory.js';
35
36
  import type { BeaconService, BeaconSignal, BlockMetadata } from './beacon/interfaces.js';
36
37
  import { BeaconUtils } from './beacon/utils.js';
@@ -217,6 +218,9 @@ function validateVersionId(value: unknown): number | undefined {
217
218
  /** An XML Datetime in UTC with the `Z` designator and no fraction, for example `2026-07-01T00:00:00Z`. */
218
219
  const UTC_XSD_DATETIME = /^-?\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
219
220
 
221
+ /** The timezone part that an XML Schema `dateTimeStamp` requires: `Z` or an offset. */
222
+ const XSD_TIMEZONE = /(Z|[+-]\d{2}:\d{2})$/;
223
+
220
224
  /**
221
225
  * Parse `ResolutionOptions.versionTime`. DID Resolution v1 requires an XML Datetime
222
226
  * normalized to UTC without sub-second precision. The specification raises
@@ -541,22 +545,104 @@ export class Resolver {
541
545
  }
542
546
  }
543
547
 
548
+ /**
549
+ * Decode a hash of a BTCR2 Update (`sourceHash` or `targetHash`). The specification encodes
550
+ * both with base64url without padding.
551
+ * @param {unknown} value The encoded hash.
552
+ * @param {'sourceHash' | 'targetHash'} field The name of the field, for the error.
553
+ * @returns {HashBytes} The decoded bytes.
554
+ * @throws {ResolveError} `INVALID_DID_UPDATE` if the value is not a string or does not decode.
555
+ */
556
+ private static decodeUpdateHash(value: unknown, field: 'sourceHash' | 'targetHash'): HashBytes {
557
+ if(typeof value === 'string') {
558
+ try {
559
+ return decodeHash(value, 'base64urlnopad');
560
+ } catch(error) {
561
+ throw new ResolveError(
562
+ `Invalid update: ${field} does not decode as base64url: ${errorCause(error).message}`,
563
+ INVALID_DID_UPDATE, { [field]: value, cause: errorCause(error) }
564
+ );
565
+ }
566
+ }
567
+ throw new ResolveError(`Invalid update: ${field} is not a string`, INVALID_DID_UPDATE, { [field]: value });
568
+ }
569
+
570
+ /**
571
+ * Parse a `created` or `expires` value of an update proof. Data Integrity types both as an
572
+ * XML Schema `dateTimeStamp`: an XML Datetime with a timezone. A value without a timezone
573
+ * names no fixed instant, so two resolvers would read two instants; it is rejected.
574
+ * @param {Btcr2DataIntegrityProof} proof The update proof.
575
+ * @param {'created' | 'expires'} field The field to parse.
576
+ * @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the field is absent.
577
+ * @throws {ResolveError} `INVALID_DID_UPDATE` for a value that is not an XML Datetime with a timezone.
578
+ */
579
+ private static proofInstant(proof: Btcr2DataIntegrityProof, field: 'created' | 'expires'): number | undefined {
580
+ const value = proof[field];
581
+ if(value === undefined) return undefined;
582
+ if(typeof value === 'string' && XSD_TIMEZONE.test(value) && DateUtils.isValidXsdDateTime(value)) {
583
+ const ms = Date.parse(value);
584
+ if(Number.isFinite(ms)) return ms;
585
+ }
586
+ throw new ResolveError(
587
+ `Invalid update: proof.${field} is not an XML Datetime with a timezone`,
588
+ INVALID_DID_UPDATE, { [field]: value }
589
+ );
590
+ }
591
+
592
+ /**
593
+ * Spec "Check `update.proof`": the proof time window against the block that contains the
594
+ * Beacon Signal. `created` must not be after the header time of the block: a controller
595
+ * signs a short time before the block, and on mainnet the header time is about one hour
596
+ * after the `mediantime`. `expires` must not be before the block `mediantime`: it limits a
597
+ * replay, and a single miner cannot change `mediantime`. `expires` must not be before
598
+ * `created`. Each comparison has no tolerance.
599
+ * @param {Btcr2DataIntegrityProof} proof The update proof.
600
+ * @param {BlockMetadata} block The block of the Beacon Signal.
601
+ * @throws {ResolveError} `INVALID_DID_UPDATE` if a value is outside the window.
602
+ */
603
+ private static checkProofWindow(proof: Btcr2DataIntegrityProof, block: BlockMetadata): void {
604
+ const created = Resolver.proofInstant(proof, 'created');
605
+ const expires = Resolver.proofInstant(proof, 'expires');
606
+ if(created !== undefined && created > block.time * 1000) {
607
+ throw new ResolveError(
608
+ 'Invalid update: proof.created is after the header time of the block that contains the Beacon Signal',
609
+ INVALID_DID_UPDATE, { created: proof.created, blockTime: block.time }
610
+ );
611
+ }
612
+ if(expires !== undefined && expires < block.mediantime * 1000) {
613
+ throw new ResolveError(
614
+ 'Invalid update: proof.expires is before the mediantime of the block that contains the Beacon Signal',
615
+ INVALID_DID_UPDATE, { expires: proof.expires, mediantime: block.mediantime }
616
+ );
617
+ }
618
+ if(created !== undefined && expires !== undefined && expires < created) {
619
+ throw new ResolveError(
620
+ 'Invalid update: proof.expires is before proof.created',
621
+ INVALID_DID_UPDATE, { created: proof.created, expires: proof.expires }
622
+ );
623
+ }
624
+ }
625
+
544
626
  /**
545
627
  * Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | Apply update}
546
628
  * and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}.
629
+ * Every failure that the specification names raises `INVALID_DID_UPDATE`. An error of the
630
+ * cryptosuite, the multikey, the hash decoder, or the patch rides along as `data.cause`.
547
631
  * @param {DidDocument} currentDocument The current DID Document to apply the update to.
548
632
  * @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply.
633
+ * @param {BlockMetadata} block The block that contains the Beacon Signal that announced the update.
549
634
  * @returns {DidDocument} The updated DID Document after applying the update.
550
635
  * @throws {ResolveError} `INVALID_DID_UPDATE` if the update is invalid or cannot be applied.
551
636
  */
552
637
  private static applyUpdate(
553
638
  currentDocument: DidDocument,
554
- update: SignedBTCR2Update
639
+ update: SignedBTCR2Update,
640
+ block: BlockMetadata
555
641
  ): DidDocument {
556
642
  // Spec "Apply update": the hash of the current document must be the decoded
557
643
  // update.sourceHash (byte comparison).
558
644
  const currentDocumentHash = canonicalHashBytes(currentDocument);
559
- const sourceHashBytes = decodeHash(update.sourceHash, 'base64urlnopad');
645
+ const sourceHashBytes = Resolver.decodeUpdateHash(update.sourceHash, 'sourceHash');
560
646
  if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
561
647
  throw new ResolveError(
562
648
  `Hash mismatch: update.sourceHash !== currentDocumentHash`,
@@ -585,50 +671,37 @@ export class Resolver {
585
671
  );
586
672
  }
587
673
 
588
- // Get the capability id from the to update proof.
589
- const capabilityId = update.proof?.capability;
590
- // Since this field is optional, check that it exists
591
- if (!capabilityId) {
592
- // If it does not exist, throw INVALID_DID_UPDATE error
593
- throw new ResolveError('No root capability found in update', INVALID_DID_UPDATE, update);
594
- }
595
-
596
- // Get the root capability object by dereferencing the capabilityId
597
- const rootCapability = Appendix.dereferenceZcapId(capabilityId);
598
-
599
- // Deconstruct the invocationTarget and controller from the root capability
600
- const { invocationTarget, controller: rootController } = rootCapability;
601
- // Check that both invocationTarget and rootController equal currentDocument.id
602
- if (![invocationTarget, rootController].every((id) => id === currentDocument.id)) {
603
- // If they do not all match, throw INVALID_DID_UPDATE error
604
- throw new ResolveError(
605
- 'Invalid root capability',
606
- INVALID_DID_UPDATE, { rootCapability, currentDocument }
607
- );
608
- }
609
-
610
- // Get the verificationMethod field from the update proof as verificationMethodId.
611
- const verificationMethodId = update.proof?.verificationMethod;
612
- // Since this field is optional, check that it exists
613
- if(!verificationMethodId) {
614
- // If it does not exist, throw INVALID_DID_UPDATE error
615
- throw new ResolveError('No verificationMethod found in update', INVALID_DID_UPDATE, update);
674
+ // Spec "Check update.proof": each proof field by string equality, before the method
675
+ // lookup and before signature verification, so that a failure names the field. The
676
+ // capability is the URN that the Data Integrity Config specifies for this DID; the root
677
+ // capability is not derived, the specification makes that optional.
678
+ const proof = update.proof;
679
+ const expectedFields: Array<[ string, string ]> = [
680
+ [ 'type', 'DataIntegrityProof' ],
681
+ [ 'cryptosuite', 'bip340-jcs-2025' ],
682
+ [ 'proofPurpose', 'capabilityInvocation' ],
683
+ [ 'capabilityAction', 'Write' ],
684
+ [ 'capability', `urn:zcap:root:${encodeURIComponent(currentDocument.id)}` ],
685
+ ];
686
+ for(const [ field, expected ] of expectedFields) {
687
+ const actual = (proof as Record<string, unknown>)[field];
688
+ if(actual !== expected) {
689
+ throw new ResolveError(
690
+ `Invalid update: proof.${field} must equal "${expected}"`,
691
+ INVALID_DID_UPDATE, { field, expected, actual }
692
+ );
693
+ }
616
694
  }
617
695
 
618
- // Spec "Check update.proof": raise INVALID_DID_UPDATE if
619
- // currentDocument.capabilityInvocation does not contain
620
- // update.proof.verificationMethod. Locating the method in verificationMethod[] and
621
- // verifying its signature is not sufficient on its own: a key the controller
622
- // published only for authentication (or for no relationship at all) must not be
623
- // able to authorize a DID update. The write path enforces this in DidBtcr2.update();
624
- // without it here the read path applies an update signed by any key in the document.
625
- // Checked before the method is located so an unauthorized method always fails with
626
- // this typed error, whether or not it also appears in verificationMethod[].
627
- const authorizedMethodId = Appendix.relationshipMethodId(verificationMethodId, currentDocument.id);
628
- const authorized = authorizedMethodId !== undefined && currentDocument.capabilityInvocation?.some(
629
- entry => Appendix.relationshipMethodId(entry, currentDocument.id) === authorizedMethodId
630
- );
631
- if(!authorized) {
696
+ // Spec "Check update.proof": the entry of currentDocument.capabilityInvocation that
697
+ // identifies update.proof.verificationMethod, in the reference form or the embedded
698
+ // form. A key the controller published for authentication only, or for no relationship
699
+ // at all, must not authorize an update; the membership test runs before the method
700
+ // lookup so that such a key always fails with this typed error. The method is the
701
+ // entry itself when embedded, else the member of verificationMethod[] with that id.
702
+ const verificationMethodId = proof.verificationMethod;
703
+ const entry = Appendix.capabilityInvocationEntry(currentDocument, verificationMethodId);
704
+ if(entry === undefined) {
632
705
  throw new ResolveError(
633
706
  'Invalid update: verificationMethod is not authorized for capabilityInvocation',
634
707
  INVALID_DID_UPDATE, {
@@ -637,48 +710,75 @@ export class Resolver {
637
710
  }
638
711
  );
639
712
  }
713
+ const vm = Appendix.verificationMethodOfEntry(currentDocument, entry);
714
+ if(vm === undefined) {
715
+ throw new ResolveError(
716
+ 'Invalid update: verificationMethod is not found in the verificationMethod of the current document',
717
+ INVALID_DID_UPDATE, { verificationMethodId }
718
+ );
719
+ }
640
720
 
641
- // Get the verificationMethod from the DID Document using the verificationMethodId.
642
- const vm = DidBtcr2.getSigningMethod(currentDocument, verificationMethodId);
643
-
644
- // Construct a new SchnorrMultikey.
645
- const multikey = SchnorrMultikey.fromVerificationMethod(vm);
646
-
647
- // Construct a new BIP340Cryptosuite with the SchnorrMultikey.
648
- const cryptosuite = new BIP340Cryptosuite(multikey);
649
-
650
- // Canonicalize the update
651
- const canonicalUpdate = canonicalize(update);
652
-
653
- // Construct a DataIntegrityProof with the cryptosuite
654
- const diProof = new BIP340DataIntegrityProof(cryptosuite);
655
-
656
- // Call the verifyProof method
657
- const verificationResult = diProof.verifyProof(canonicalUpdate, 'capabilityInvocation');
658
-
659
- // If the result is not verified, throw INVALID_DID_UPDATE error
660
- if (!verificationResult.verified) {
721
+ // Spec "Check update.proof": the proof time window against the block of the signal.
722
+ Resolver.checkProofWindow(proof, block);
723
+
724
+ // Verify the proof with the public key that the verification method publishes. The
725
+ // multikey names the method by its absolute DID URL, as the proof does. An error of the
726
+ // multikey or the cryptosuite (a key that does not decode, a proof value that does not
727
+ // decode, a created value the suite rejects) is an invalid update.
728
+ let verified: boolean;
729
+ try {
730
+ const multikey = SchnorrMultikey.fromVerificationMethod({
731
+ ...vm, id : Appendix.absoluteDidUrl(vm.id, currentDocument.id) ?? vm.id
732
+ });
733
+ const diProof = new BIP340DataIntegrityProof(new BIP340Cryptosuite(multikey));
734
+ verified = diProof.verifyProof(canonicalize(update), 'capabilityInvocation').verified;
735
+ } catch(error) {
661
736
  throw new ResolveError(
662
- 'Invalid update: proof not verified',
663
- INVALID_DID_UPDATE, verificationResult
737
+ `Invalid update: proof verification failed: ${errorCause(error).message}`,
738
+ INVALID_DID_UPDATE, { verificationMethodId, cause: errorCause(error) }
664
739
  );
665
740
  }
741
+ if(!verified) {
742
+ throw new ResolveError('Invalid update: proof not verified', INVALID_DID_UPDATE, { verificationMethodId });
743
+ }
666
744
 
667
- // Apply the update.patch to the currentDocument to get the updatedDocument.
668
- const updatedDocument = JSONPatch.apply(currentDocument, update.patch) as DidDocument;
745
+ // Spec "Apply update": apply update.patch strictly. The first operation that fails,
746
+ // including a failed test, fails the whole patch.
747
+ let updatedDocument: DidDocument;
748
+ try {
749
+ updatedDocument = JSONPatch.apply(currentDocument, update.patch, { strict: true }) as DidDocument;
750
+ } catch(error) {
751
+ throw new ResolveError(
752
+ `Invalid update: ${errorCause(error).message}`,
753
+ INVALID_DID_UPDATE, { cause: errorCause(error) }
754
+ );
755
+ }
669
756
 
670
- // Verify that updatedDocument is conformant to DID Core v1.1.
671
- DidDocument.validate(updatedDocument);
757
+ // Spec "Apply update": the patched document keeps the DID as its id and conforms to
758
+ // DID Core v1.1.
759
+ if(updatedDocument?.id !== currentDocument.id) {
760
+ throw new ResolveError(
761
+ `Invalid update: the patch changes the document id (from "${currentDocument.id}" to "${String(updatedDocument?.id)}")`,
762
+ INVALID_DID_UPDATE, { sourceId: currentDocument.id, targetId: updatedDocument?.id }
763
+ );
764
+ }
765
+ try {
766
+ DidDocument.validate(updatedDocument);
767
+ } catch(error) {
768
+ throw new ResolveError(
769
+ `Invalid update: the patched document does not conform to DID Core: ${errorCause(error).message}`,
770
+ INVALID_DID_UPDATE, { cause: errorCause(error) }
771
+ );
772
+ }
672
773
 
673
774
  // Canonicalize and hash the updatedDocument (raw bytes).
674
775
  const updatedDocumentHash = canonicalHashBytes(updatedDocument);
675
776
 
676
777
  // Prepare the update targetHash for comparison with updatedDocumentHash.
677
- const updateTargetHash = decodeHash(update.targetHash);
778
+ const updateTargetHash = Resolver.decodeUpdateHash(update.targetHash, 'targetHash');
678
779
 
679
780
  // Make sure the update.targetHash equals updatedDocumentHash.
680
781
  if (!equalBytes(updateTargetHash, updatedDocumentHash)) {
681
- // If they do not match, throw INVALID_DID_UPDATE error.
682
782
  throw new ResolveError(
683
783
  `Invalid update: update.targetHash !== updatedDocumentHash`,
684
784
  INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash }
@@ -863,7 +963,7 @@ export class Resolver {
863
963
 
864
964
  // Second arm: update.targetVersionId == currentVersionId + 1. Apply the update,
865
965
  // append the unsigned update hash to the history, increment the version.
866
- this.#currentDocument = Resolver.applyUpdate(document, update);
966
+ this.#currentDocument = Resolver.applyUpdate(document, update, block);
867
967
  const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']) as UnsignedBTCR2Update;
868
968
  this.#updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
869
969
  this.#currentVersionId++;
@@ -1,11 +1,12 @@
1
1
  import type { BitcoinConnection } from '@did-btcr2/bitcoin';
2
2
  import type { PatchOperation } from '@did-btcr2/common';
3
- import { canonicalHash, INVALID_DID_UPDATE, JSONPatch, UpdateError } from '@did-btcr2/common';
3
+ import { canonicalHash, canonicalize, INVALID_DID_UPDATE, JSONPatch, UpdateError } from '@did-btcr2/common';
4
4
  import { SchnorrMultikey } from '@did-btcr2/cryptosuite';
5
5
  import type { Signer } from '@did-btcr2/keypair';
6
6
  import type { Btcr2DataIntegrityConfig, SignedBTCR2Update, UnsignedBTCR2Update } from './btcr2-update.js';
7
7
  import { BTCR2_UPDATE_CONTEXT } from './btcr2-update.js';
8
8
  import { DidDocument, type Btcr2DidDocument, type DidVerificationMethod } from '../utils/did-document.js';
9
+ import { errorCause } from '../utils/error-cause.js';
9
10
  import type { BroadcastResult } from './beacon/beacon.js';
10
11
  import type { CASBroadcastOptions } from './beacon/cas-beacon.js';
11
12
  import { BeaconFactory } from './beacon/factory.js';
@@ -224,7 +225,19 @@ export class Updater {
224
225
  sourceHash : canonicalHash(sourceDocument),
225
226
  };
226
227
 
227
- const targetDocument = JSONPatch.apply(sourceDocument, patches);
228
+ // Spec "Construct BTCR2 Unsigned Update": apply jsonPatch strictly. The first operation
229
+ // that fails, including a failed test, fails the whole patch. A patch that a lenient
230
+ // library applies with no effect is refused here, before it is signed and announced: a
231
+ // conformant resolver would reject the announced update.
232
+ let targetDocument: Record<string, any>;
233
+ try {
234
+ targetDocument = JSONPatch.apply(sourceDocument, patches, { strict: true });
235
+ } catch(error) {
236
+ throw new UpdateError(
237
+ `Invalid patch: ${errorCause(error).message}`,
238
+ INVALID_DID_UPDATE, { cause: errorCause(error) }
239
+ );
240
+ }
228
241
 
229
242
  // Spec (operations/update.md): "An INVALID_DID_UPDATE error MUST be raised if
230
243
  // didTargetDocument.id is not equal to didSourceDocument.id." `DidDocument.isValid`
@@ -327,7 +340,32 @@ export class Updater {
327
340
  };
328
341
 
329
342
  const diproof = multikey.toCryptosuite().toDataIntegrityProof();
330
- return diproof.addProof(unsignedUpdate, config);
343
+ const signedUpdate = diproof.addProof(unsignedUpdate, config) as SignedBTCR2Update;
344
+
345
+ // Spec "Construct BTCR2 Signed Update": verify update.proof before the announcement,
346
+ // with the public key that the verification method publishes, not the signer's key. A
347
+ // signer that returns a wrong signature for the right key passes the key comparison
348
+ // above. An announced update with an invalid proof permanently invalidates the DID, so
349
+ // the failure surfaces here, before the state machine asks for funding.
350
+ let verified: boolean;
351
+ try {
352
+ const verifier = SchnorrMultikey.fromVerificationMethod({ ...verificationMethod, id: absoluteMethodId });
353
+ verified = verifier.toCryptosuite().toDataIntegrityProof()
354
+ .verifyProof(canonicalize(signedUpdate), 'capabilityInvocation').verified;
355
+ } catch(error) {
356
+ throw new UpdateError(
357
+ `Invalid update: the proof does not verify with the public key of "${verificationMethod.id}": `
358
+ + errorCause(error).message,
359
+ INVALID_DID_UPDATE, { verificationMethodId: verificationMethod.id, cause: errorCause(error) }
360
+ );
361
+ }
362
+ if(!verified) {
363
+ throw new UpdateError(
364
+ `Invalid update: the proof does not verify with the public key of "${verificationMethod.id}".`,
365
+ INVALID_DID_UPDATE, { verificationMethodId: verificationMethod.id }
366
+ );
367
+ }
368
+ return signedUpdate;
331
369
  }
332
370
 
333
371
  /**
package/src/did-btcr2.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  DidErrorCode
19
19
  } from '@web5/dids';
20
20
  import type { BeaconService } from './core/beacon/interfaces.js';
21
+ import { DEACTIVATION_PATCH } from './core/btcr2-update.js';
21
22
  import { Identifier } from './core/identifier.js';
22
23
  import type { ResolutionOptions } from './core/interfaces.js';
23
24
  import { Resolver } from './core/resolver.js';
@@ -155,9 +156,11 @@ export class DidBtcr2 implements DidMethod {
155
156
  * @param {string} params.verificationMethodId The verification method ID to sign with.
156
157
  * @param {string} params.beaconId The beacon service ID to broadcast through.
157
158
  * @returns {Updater} A sans-I/O state machine for driving the update.
158
- * @throws {UpdateError} If the verification method is not authorized, not found,
159
- * not of type `Multikey`, or does not have a `zQ3s` publicKeyMultibase prefix.
160
- * Also throws if the beacon service is not found.
159
+ * @throws {UpdateError} `INVALID_DID_UPDATE` if `sourceVersionId` is not an integer of at
160
+ * least 1, if no entry of `capabilityInvocation` identifies the verification method, if a
161
+ * reference entry names no member of `verificationMethod`, or if the beacon service is not
162
+ * found. `INVALID_DID_DOCUMENT` if the method is not of type `Multikey` or does not have a
163
+ * `zQ3s` publicKeyMultibase prefix.
161
164
  */
162
165
  static update({
163
166
  sourceDocument,
@@ -172,30 +175,37 @@ export class DidBtcr2 implements DidMethod {
172
175
  verificationMethodId: string;
173
176
  beaconId: string;
174
177
  }): Updater {
175
- // Validate that the verificationMethodId is authorized for capabilityInvocation.
176
- // Both sides are resolved to absolute DID URLs first, so the caller's spelling of the
177
- // reference and the document's spelling of the entry may differ: either is legal per
178
- // DID Core. This is the same rule the read path applies to an update's proof, so an
179
- // update this factory authorizes is one the resolver will also accept.
180
- const authorizedMethodId = Appendix.relationshipMethodId(verificationMethodId, sourceDocument.id);
181
- const authorized = authorizedMethodId !== undefined && sourceDocument.capabilityInvocation?.some(
182
- entry => Appendix.relationshipMethodId(entry, sourceDocument.id) === authorizedMethodId
183
- );
184
- if(!authorized) {
178
+ // The version of the source document is a positive integer: versionId starts at 1, and
179
+ // targetVersionId is sourceVersionId + 1. Without the guard, Number(undefined) + 1 is
180
+ // NaN, and the update carries no usable version.
181
+ if(!Number.isInteger(sourceVersionId) || sourceVersionId < 1) {
185
182
  throw new UpdateError(
186
- 'Invalid verificationMethodId: not authorized for capabilityInvocation',
187
- INVALID_DID_DOCUMENT, sourceDocument
183
+ `Invalid sourceVersionId: expected an integer of at least 1, got ${String(sourceVersionId)}.`,
184
+ INVALID_DID_UPDATE, { sourceVersionId }
188
185
  );
189
186
  }
190
187
 
191
- // Get the verification method to be used for signing the update
192
- const verificationMethod = this.getSigningMethod(sourceDocument, verificationMethodId);
188
+ // Spec "Construct BTCR2 Signed Update": an entry of capabilityInvocation must identify
189
+ // the verificationMethodId, in the reference form or the embedded form. Both sides are
190
+ // resolved to absolute DID URLs first, so the caller's spelling of the reference and the
191
+ // document's spelling of the entry may differ: either is legal per DID Core. This is the
192
+ // same rule the read path applies to an update's proof, so an update this factory
193
+ // authorizes is one the resolver will also accept.
194
+ const entry = Appendix.capabilityInvocationEntry(sourceDocument, verificationMethodId);
195
+ if(entry === undefined) {
196
+ throw new UpdateError(
197
+ 'Invalid verificationMethodId: not authorized for capabilityInvocation',
198
+ INVALID_DID_UPDATE, { verificationMethodId, capabilityInvocation: sourceDocument.capabilityInvocation }
199
+ );
200
+ }
193
201
 
194
- // Validate the verificationMethod exists in the sourceDocument
202
+ // The verification method is the embedded object of the entry, or the member of
203
+ // verificationMethod[] that a reference entry names.
204
+ const verificationMethod = Appendix.verificationMethodOfEntry(sourceDocument, entry);
195
205
  if(!verificationMethod) {
196
206
  throw new UpdateError(
197
- 'Invalid verificationMethod: not found in source document',
198
- INVALID_DID_DOCUMENT, { sourceDocument, verificationMethodId }
207
+ 'Invalid verificationMethodId: not found in source document',
208
+ INVALID_DID_UPDATE, { verificationMethodId }
199
209
  );
200
210
  }
201
211
 
@@ -233,16 +243,50 @@ export class DidBtcr2 implements DidMethod {
233
243
  );
234
244
  }
235
245
 
236
- // Return a sans-I/O state machine the caller will drive
246
+ // Return a sans-I/O state machine the caller will drive. The prefix check above proves
247
+ // that the method carries a publicKeyMultibase, which the btcr2 method type requires.
237
248
  return new Updater({
238
249
  sourceDocument,
239
250
  patches,
240
251
  sourceVersionId,
241
- verificationMethod,
252
+ verificationMethod : verificationMethod as DidVerificationMethod,
242
253
  beaconService,
243
254
  });
244
255
  }
245
256
 
257
+ /**
258
+ * Entry point for section {@link https://dcdpr.github.io/did-btcr2/operations/deactivate.html | 7.4 Deactivate}.
259
+ *
260
+ * Deactivate is the Update operation with the predetermined patch {@link DEACTIVATION_PATCH}:
261
+ * it adds the `deactivated` property with the value `true`. The factory returns the
262
+ * {@link Updater} that {@link DidBtcr2.update} returns for that patch, and the caller drives
263
+ * it in the same way. Resolution stops at the deactivation for good. The factory does not
264
+ * refuse a source document that is deactivated already; the api does (ADR 100).
265
+ *
266
+ * @param params Deactivation parameters: the parameters of {@link DidBtcr2.update} without `patches`.
267
+ * @returns {Updater} A sans-I/O state machine for driving the deactivation.
268
+ * @throws {UpdateError} As {@link DidBtcr2.update}.
269
+ */
270
+ static deactivate({
271
+ sourceDocument,
272
+ sourceVersionId,
273
+ verificationMethodId,
274
+ beaconId,
275
+ }: {
276
+ sourceDocument: Btcr2DidDocument;
277
+ sourceVersionId: number;
278
+ verificationMethodId: string;
279
+ beaconId: string;
280
+ }): Updater {
281
+ return this.update({
282
+ sourceDocument,
283
+ patches : [{ ...DEACTIVATION_PATCH }],
284
+ sourceVersionId,
285
+ verificationMethodId,
286
+ beaconId,
287
+ });
288
+ }
289
+
246
290
  /**
247
291
  * Given the W3C DID Document of a `did:btcr2` identifier, return the signing verification method that will be used
248
292
  * for signing messages and credentials. If given, the `methodId` parameter is used to select the
@@ -272,9 +316,10 @@ export class DidBtcr2 implements DidMethod {
272
316
 
273
317
  // An unusable target matches nothing: without this guard it compares equal to every
274
318
  // method whose own id is unusable, and the document's first malformed method is
275
- // returned as the signing method.
276
- const verificationMethod = targetId === undefined ? undefined : didDocument.verificationMethod?.find(
277
- (vm: DidVerificationMethod) => Appendix.absoluteDidUrl(vm.id, didDocument.id) === targetId
319
+ // returned as the signing method. The search covers verificationMethod[] first, then the
320
+ // methods that a verification relationship embeds.
321
+ const verificationMethod = targetId === undefined ? undefined : Appendix.getVerificationMethods(didDocument).find(
322
+ vm => Appendix.absoluteDidUrl(vm.id, didDocument.id) === targetId
278
323
  );
279
324
 
280
325
  // If no verification method is found, throw an error
@@ -60,6 +60,51 @@ export class Appendix {
60
60
  return Appendix.absoluteDidUrl(id, did);
61
61
  }
62
62
 
63
+ /**
64
+ * Finds the entry of `document.capabilityInvocation` that identifies `methodId`. A
65
+ * reference entry identifies it when the two DID URLs are equal. An embedded verification
66
+ * method object identifies it when its `id` is equal. Both spellings of a DID URL compare
67
+ * equal, as in {@link relationshipMethodId}. This is the lookup of the specification steps
68
+ * "Check `update.proof`" (the read path) and "Construct BTCR2 Signed Update" (the write
69
+ * path); the caller raises `INVALID_DID_UPDATE` when no entry identifies the method.
70
+ *
71
+ * @param {DidDocument} document The DID document.
72
+ * @param {unknown} methodId The verification method id, absolute or relative. A non-string yields `undefined`.
73
+ * @returns {string | DidVerificationMethod | undefined} The entry, or `undefined` if no entry identifies the id.
74
+ */
75
+ public static capabilityInvocationEntry(
76
+ document: DidDocument,
77
+ methodId: unknown
78
+ ): string | DidVerificationMethod | undefined {
79
+ const targetId = Appendix.relationshipMethodId(methodId, document.id);
80
+ if (targetId === undefined) return undefined;
81
+ return document.capabilityInvocation?.find(
82
+ entry => Appendix.relationshipMethodId(entry, document.id) === targetId
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Returns the verification method that a relationship entry denotes: the object itself
88
+ * when the entry embeds the method, else the member of `document.verificationMethod` whose
89
+ * `id` equals the reference. Both spellings of a DID URL compare equal. The caller raises
90
+ * `INVALID_DID_UPDATE` when a reference names no member.
91
+ *
92
+ * @param {DidDocument} document The DID document.
93
+ * @param {string | DidVerificationMethod} entry The relationship entry: a reference, or an embedded method.
94
+ * @returns {DidVerificationMethod | undefined} The method, or `undefined` if a reference names no member.
95
+ */
96
+ public static verificationMethodOfEntry(
97
+ document: DidDocument,
98
+ entry: string | DidVerificationMethod
99
+ ): DidVerificationMethod | undefined {
100
+ if (Appendix.isDidVerificationMethod(entry)) return entry;
101
+ const targetId = Appendix.absoluteDidUrl(entry, document.id);
102
+ if (targetId === undefined) return undefined;
103
+ return document.verificationMethod?.find(
104
+ method => Appendix.absoluteDidUrl(method?.id, document.id) === targetId
105
+ );
106
+ }
107
+
63
108
  /**
64
109
  * Validates that the given object is a DidVerificationMethod
65
110
  * @param {unknown} obj The object to validate
@@ -194,13 +239,14 @@ export class Appendix {
194
239
  const rootCapability = {} as RootCapability;
195
240
 
196
241
  // 2. Set components to the result of capabilityId.split(":").
197
- const [urn, zcap, root, did] = capabilityId.split(':') ?? [];
242
+ const components = capabilityId.split(':');
198
243
 
199
244
  // 3. Validate components:
200
245
  // 1. Assert length of components is 4.
201
- if ([urn, zcap, root, did].length !== 4) {
246
+ if (components.length !== 4) {
202
247
  throw new DidError(DidErrorCode.InvalidDid, `Invalid capabilityId: ${capabilityId}`);
203
248
  }
249
+ const [urn, zcap, root, did] = components;
204
250
 
205
251
  // 2. components[0] == urn.
206
252
  if (!urn || urn !== 'urn') {
@@ -0,0 +1,23 @@
1
+ import { DidMethodError } from '@did-btcr2/common';
2
+
3
+ /** The type and the message of an inner error, carried as `data.cause` by a wrapping error. */
4
+ export interface ErrorCause {
5
+ /** The `type` of a typed error, or the `name` of a plain `Error`. */
6
+ type: string;
7
+
8
+ /** The message of the inner error. */
9
+ message: string;
10
+ }
11
+
12
+ /**
13
+ * Describe an inner error for the `data.cause` of a wrapping typed error. The update paths
14
+ * raise `INVALID_DID_UPDATE` for every failure that the specification names; the inner error
15
+ * of the cryptosuite, the multikey, the hash decoder, or the common package rides along here.
16
+ * @param {unknown} error The inner error.
17
+ * @returns {ErrorCause} The type and the message of the inner error.
18
+ */
19
+ export function errorCause(error: unknown): ErrorCause {
20
+ if(error instanceof DidMethodError) return { type: error.type, message: error.message };
21
+ if(error instanceof Error) return { type: error.name, message: error.message };
22
+ return { type: 'unknown', message: String(error) };
23
+ }