@did-btcr2/method 0.63.0 → 0.65.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.
- package/README.md +10 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.js +3 -3
- package/dist/browser.mjs +3 -3
- package/dist/cjs/index.js +1092 -849
- package/dist/esm/core/btcr2-update.js +11 -0
- package/dist/esm/core/btcr2-update.js.map +1 -1
- package/dist/esm/core/resolver.js +167 -70
- package/dist/esm/core/resolver.js.map +1 -1
- package/dist/esm/core/updater.js +33 -3
- package/dist/esm/core/updater.js.map +1 -1
- package/dist/esm/did-btcr2.js +53 -20
- package/dist/esm/did-btcr2.js.map +1 -1
- package/dist/esm/utils/appendix.js +39 -2
- package/dist/esm/utils/appendix.js.map +1 -1
- package/dist/esm/utils/error-cause.js +16 -0
- package/dist/esm/utils/error-cause.js.map +1 -0
- package/dist/types/core/btcr2-update.d.ts +15 -8
- package/dist/types/core/btcr2-update.d.ts.map +1 -1
- package/dist/types/core/resolver.d.ts +34 -0
- package/dist/types/core/resolver.d.ts.map +1 -1
- package/dist/types/core/updater.d.ts.map +1 -1
- package/dist/types/did-btcr2.d.ts +24 -3
- package/dist/types/did-btcr2.d.ts.map +1 -1
- package/dist/types/utils/appendix.d.ts +24 -0
- package/dist/types/utils/appendix.d.ts.map +1 -1
- package/dist/types/utils/error-cause.d.ts +16 -0
- package/dist/types/utils/error-cause.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/core/btcr2-update.ts +20 -8
- package/src/core/resolver.ts +211 -86
- package/src/core/updater.ts +41 -3
- package/src/did-btcr2.ts +70 -25
- package/src/utils/appendix.ts +48 -2
- package/src/utils/error-cause.ts +23 -0
package/src/core/resolver.ts
CHANGED
|
@@ -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';
|
|
@@ -136,6 +137,13 @@ export interface BeaconProcessResult {
|
|
|
136
137
|
needs: Array<DataNeed>;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* One entry of the `updates` list of the specification: the signed update, the block
|
|
142
|
+
* that announced it, and the beacon address of the signal. The address feeds the
|
|
143
|
+
* removed-beacon test of "Process Next Update", step 4.
|
|
144
|
+
*/
|
|
145
|
+
type UpdateTuple = [update: SignedBTCR2Update, block: BlockMetadata, address: string];
|
|
146
|
+
|
|
139
147
|
// ─── provide() payload guards ────────────────────────────────────────────────
|
|
140
148
|
// Runtime shape checks so a malformed payload fails fast at the provide()
|
|
141
149
|
// boundary rather than flowing downstream as an unchecked `as` cast.
|
|
@@ -217,6 +225,9 @@ function validateVersionId(value: unknown): number | undefined {
|
|
|
217
225
|
/** An XML Datetime in UTC with the `Z` designator and no fraction, for example `2026-07-01T00:00:00Z`. */
|
|
218
226
|
const UTC_XSD_DATETIME = /^-?\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
219
227
|
|
|
228
|
+
/** The timezone part that an XML Schema `dateTimeStamp` requires: `Z` or an offset. */
|
|
229
|
+
const XSD_TIMEZONE = /(Z|[+-]\d{2}:\d{2})$/;
|
|
230
|
+
|
|
220
231
|
/**
|
|
221
232
|
* Parse `ResolutionOptions.versionTime`. DID Resolution v1 requires an XML Datetime
|
|
222
233
|
* normalized to UTC without sub-second precision. The specification raises
|
|
@@ -294,11 +305,12 @@ export class Resolver {
|
|
|
294
305
|
/** The beacon addresses the resolver requested signals for: `scanned_beacons` of the specification. */
|
|
295
306
|
#requestCache: Set<string> = new Set();
|
|
296
307
|
/**
|
|
297
|
-
* The tuples of the specification's `updates` list: a signed update
|
|
298
|
-
* the block that announced it
|
|
299
|
-
* removes one tuple per step. A tuple that
|
|
308
|
+
* The tuples of the specification's `updates` list: a signed update, the metadata of
|
|
309
|
+
* the block that announced it, and the beacon address of the signal. BeaconProcess
|
|
310
|
+
* appends; ProcessUpdate sorts the list and removes one tuple per step. A tuple that
|
|
311
|
+
* one pass does not reach waits for the next.
|
|
300
312
|
*/
|
|
301
|
-
#unsortedUpdates: Array<
|
|
313
|
+
#unsortedUpdates: Array<UpdateTuple> = [];
|
|
302
314
|
#resolvedResponse: DidResolutionResponse | null = null;
|
|
303
315
|
|
|
304
316
|
/**
|
|
@@ -541,22 +553,104 @@ export class Resolver {
|
|
|
541
553
|
}
|
|
542
554
|
}
|
|
543
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Decode a hash of a BTCR2 Update (`sourceHash` or `targetHash`). The specification encodes
|
|
558
|
+
* both with base64url without padding.
|
|
559
|
+
* @param {unknown} value The encoded hash.
|
|
560
|
+
* @param {'sourceHash' | 'targetHash'} field The name of the field, for the error.
|
|
561
|
+
* @returns {HashBytes} The decoded bytes.
|
|
562
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` if the value is not a string or does not decode.
|
|
563
|
+
*/
|
|
564
|
+
private static decodeUpdateHash(value: unknown, field: 'sourceHash' | 'targetHash'): HashBytes {
|
|
565
|
+
if(typeof value === 'string') {
|
|
566
|
+
try {
|
|
567
|
+
return decodeHash(value, 'base64urlnopad');
|
|
568
|
+
} catch(error) {
|
|
569
|
+
throw new ResolveError(
|
|
570
|
+
`Invalid update: ${field} does not decode as base64url: ${errorCause(error).message}`,
|
|
571
|
+
INVALID_DID_UPDATE, { [field]: value, cause: errorCause(error) }
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
throw new ResolveError(`Invalid update: ${field} is not a string`, INVALID_DID_UPDATE, { [field]: value });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Parse a `created` or `expires` value of an update proof. Data Integrity types both as an
|
|
580
|
+
* XML Schema `dateTimeStamp`: an XML Datetime with a timezone. A value without a timezone
|
|
581
|
+
* names no fixed instant, so two resolvers would read two instants; it is rejected.
|
|
582
|
+
* @param {Btcr2DataIntegrityProof} proof The update proof.
|
|
583
|
+
* @param {'created' | 'expires'} field The field to parse.
|
|
584
|
+
* @returns {number | undefined} The instant in milliseconds since the Unix epoch, or `undefined` when the field is absent.
|
|
585
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` for a value that is not an XML Datetime with a timezone.
|
|
586
|
+
*/
|
|
587
|
+
private static proofInstant(proof: Btcr2DataIntegrityProof, field: 'created' | 'expires'): number | undefined {
|
|
588
|
+
const value = proof[field];
|
|
589
|
+
if(value === undefined) return undefined;
|
|
590
|
+
if(typeof value === 'string' && XSD_TIMEZONE.test(value) && DateUtils.isValidXsdDateTime(value)) {
|
|
591
|
+
const ms = Date.parse(value);
|
|
592
|
+
if(Number.isFinite(ms)) return ms;
|
|
593
|
+
}
|
|
594
|
+
throw new ResolveError(
|
|
595
|
+
`Invalid update: proof.${field} is not an XML Datetime with a timezone`,
|
|
596
|
+
INVALID_DID_UPDATE, { [field]: value }
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Spec "Check `update.proof`": the proof time window against the block that contains the
|
|
602
|
+
* Beacon Signal. `created` must not be after the header time of the block: a controller
|
|
603
|
+
* signs a short time before the block, and on mainnet the header time is about one hour
|
|
604
|
+
* after the `mediantime`. `expires` must not be before the block `mediantime`: it limits a
|
|
605
|
+
* replay, and a single miner cannot change `mediantime`. `expires` must not be before
|
|
606
|
+
* `created`. Each comparison has no tolerance.
|
|
607
|
+
* @param {Btcr2DataIntegrityProof} proof The update proof.
|
|
608
|
+
* @param {BlockMetadata} block The block of the Beacon Signal.
|
|
609
|
+
* @throws {ResolveError} `INVALID_DID_UPDATE` if a value is outside the window.
|
|
610
|
+
*/
|
|
611
|
+
private static checkProofWindow(proof: Btcr2DataIntegrityProof, block: BlockMetadata): void {
|
|
612
|
+
const created = Resolver.proofInstant(proof, 'created');
|
|
613
|
+
const expires = Resolver.proofInstant(proof, 'expires');
|
|
614
|
+
if(created !== undefined && created > block.time * 1000) {
|
|
615
|
+
throw new ResolveError(
|
|
616
|
+
'Invalid update: proof.created is after the header time of the block that contains the Beacon Signal',
|
|
617
|
+
INVALID_DID_UPDATE, { created: proof.created, blockTime: block.time }
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if(expires !== undefined && expires < block.mediantime * 1000) {
|
|
621
|
+
throw new ResolveError(
|
|
622
|
+
'Invalid update: proof.expires is before the mediantime of the block that contains the Beacon Signal',
|
|
623
|
+
INVALID_DID_UPDATE, { expires: proof.expires, mediantime: block.mediantime }
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if(created !== undefined && expires !== undefined && expires < created) {
|
|
627
|
+
throw new ResolveError(
|
|
628
|
+
'Invalid update: proof.expires is before proof.created',
|
|
629
|
+
INVALID_DID_UPDATE, { created: proof.created, expires: proof.expires }
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
544
634
|
/**
|
|
545
635
|
* Implements subsection {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#apply-update | Apply update}
|
|
546
636
|
* and its step {@link https://dcdpr.github.io/did-btcr2/operations/resolve.html#check-update-proof | Check update.proof}.
|
|
637
|
+
* Every failure that the specification names raises `INVALID_DID_UPDATE`. An error of the
|
|
638
|
+
* cryptosuite, the multikey, the hash decoder, or the patch rides along as `data.cause`.
|
|
547
639
|
* @param {DidDocument} currentDocument The current DID Document to apply the update to.
|
|
548
640
|
* @param {SignedBTCR2Update} update The BTCR2 Signed Update to apply.
|
|
641
|
+
* @param {BlockMetadata} block The block that contains the Beacon Signal that announced the update.
|
|
549
642
|
* @returns {DidDocument} The updated DID Document after applying the update.
|
|
550
643
|
* @throws {ResolveError} `INVALID_DID_UPDATE` if the update is invalid or cannot be applied.
|
|
551
644
|
*/
|
|
552
645
|
private static applyUpdate(
|
|
553
646
|
currentDocument: DidDocument,
|
|
554
|
-
update: SignedBTCR2Update
|
|
647
|
+
update: SignedBTCR2Update,
|
|
648
|
+
block: BlockMetadata
|
|
555
649
|
): DidDocument {
|
|
556
650
|
// Spec "Apply update": the hash of the current document must be the decoded
|
|
557
651
|
// update.sourceHash (byte comparison).
|
|
558
652
|
const currentDocumentHash = canonicalHashBytes(currentDocument);
|
|
559
|
-
const sourceHashBytes =
|
|
653
|
+
const sourceHashBytes = Resolver.decodeUpdateHash(update.sourceHash, 'sourceHash');
|
|
560
654
|
if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
|
|
561
655
|
throw new ResolveError(
|
|
562
656
|
`Hash mismatch: update.sourceHash !== currentDocumentHash`,
|
|
@@ -585,50 +679,37 @@ export class Resolver {
|
|
|
585
679
|
);
|
|
586
680
|
}
|
|
587
681
|
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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);
|
|
682
|
+
// Spec "Check update.proof": each proof field by string equality, before the method
|
|
683
|
+
// lookup and before signature verification, so that a failure names the field. The
|
|
684
|
+
// capability is the URN that the Data Integrity Config specifies for this DID; the root
|
|
685
|
+
// capability is not derived, the specification makes that optional.
|
|
686
|
+
const proof = update.proof;
|
|
687
|
+
const expectedFields: Array<[ string, string ]> = [
|
|
688
|
+
[ 'type', 'DataIntegrityProof' ],
|
|
689
|
+
[ 'cryptosuite', 'bip340-jcs-2025' ],
|
|
690
|
+
[ 'proofPurpose', 'capabilityInvocation' ],
|
|
691
|
+
[ 'capabilityAction', 'Write' ],
|
|
692
|
+
[ 'capability', `urn:zcap:root:${encodeURIComponent(currentDocument.id)}` ],
|
|
693
|
+
];
|
|
694
|
+
for(const [ field, expected ] of expectedFields) {
|
|
695
|
+
const actual = (proof as Record<string, unknown>)[field];
|
|
696
|
+
if(actual !== expected) {
|
|
697
|
+
throw new ResolveError(
|
|
698
|
+
`Invalid update: proof.${field} must equal "${expected}"`,
|
|
699
|
+
INVALID_DID_UPDATE, { field, expected, actual }
|
|
700
|
+
);
|
|
701
|
+
}
|
|
616
702
|
}
|
|
617
703
|
|
|
618
|
-
// Spec "Check update.proof":
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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) {
|
|
704
|
+
// Spec "Check update.proof": the entry of currentDocument.capabilityInvocation that
|
|
705
|
+
// identifies update.proof.verificationMethod, in the reference form or the embedded
|
|
706
|
+
// form. A key the controller published for authentication only, or for no relationship
|
|
707
|
+
// at all, must not authorize an update; the membership test runs before the method
|
|
708
|
+
// lookup so that such a key always fails with this typed error. The method is the
|
|
709
|
+
// entry itself when embedded, else the member of verificationMethod[] with that id.
|
|
710
|
+
const verificationMethodId = proof.verificationMethod;
|
|
711
|
+
const entry = Appendix.capabilityInvocationEntry(currentDocument, verificationMethodId);
|
|
712
|
+
if(entry === undefined) {
|
|
632
713
|
throw new ResolveError(
|
|
633
714
|
'Invalid update: verificationMethod is not authorized for capabilityInvocation',
|
|
634
715
|
INVALID_DID_UPDATE, {
|
|
@@ -637,48 +718,75 @@ export class Resolver {
|
|
|
637
718
|
}
|
|
638
719
|
);
|
|
639
720
|
}
|
|
721
|
+
const vm = Appendix.verificationMethodOfEntry(currentDocument, entry);
|
|
722
|
+
if(vm === undefined) {
|
|
723
|
+
throw new ResolveError(
|
|
724
|
+
'Invalid update: verificationMethod is not found in the verificationMethod of the current document',
|
|
725
|
+
INVALID_DID_UPDATE, { verificationMethodId }
|
|
726
|
+
);
|
|
727
|
+
}
|
|
640
728
|
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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) {
|
|
729
|
+
// Spec "Check update.proof": the proof time window against the block of the signal.
|
|
730
|
+
Resolver.checkProofWindow(proof, block);
|
|
731
|
+
|
|
732
|
+
// Verify the proof with the public key that the verification method publishes. The
|
|
733
|
+
// multikey names the method by its absolute DID URL, as the proof does. An error of the
|
|
734
|
+
// multikey or the cryptosuite (a key that does not decode, a proof value that does not
|
|
735
|
+
// decode, a created value the suite rejects) is an invalid update.
|
|
736
|
+
let verified: boolean;
|
|
737
|
+
try {
|
|
738
|
+
const multikey = SchnorrMultikey.fromVerificationMethod({
|
|
739
|
+
...vm, id : Appendix.absoluteDidUrl(vm.id, currentDocument.id) ?? vm.id
|
|
740
|
+
});
|
|
741
|
+
const diProof = new BIP340DataIntegrityProof(new BIP340Cryptosuite(multikey));
|
|
742
|
+
verified = diProof.verifyProof(canonicalize(update), 'capabilityInvocation').verified;
|
|
743
|
+
} catch(error) {
|
|
661
744
|
throw new ResolveError(
|
|
662
|
-
|
|
663
|
-
INVALID_DID_UPDATE,
|
|
745
|
+
`Invalid update: proof verification failed: ${errorCause(error).message}`,
|
|
746
|
+
INVALID_DID_UPDATE, { verificationMethodId, cause: errorCause(error) }
|
|
664
747
|
);
|
|
665
748
|
}
|
|
749
|
+
if(!verified) {
|
|
750
|
+
throw new ResolveError('Invalid update: proof not verified', INVALID_DID_UPDATE, { verificationMethodId });
|
|
751
|
+
}
|
|
666
752
|
|
|
667
|
-
// Apply
|
|
668
|
-
|
|
753
|
+
// Spec "Apply update": apply update.patch strictly. The first operation that fails,
|
|
754
|
+
// including a failed test, fails the whole patch.
|
|
755
|
+
let updatedDocument: DidDocument;
|
|
756
|
+
try {
|
|
757
|
+
updatedDocument = JSONPatch.apply(currentDocument, update.patch, { strict: true }) as DidDocument;
|
|
758
|
+
} catch(error) {
|
|
759
|
+
throw new ResolveError(
|
|
760
|
+
`Invalid update: ${errorCause(error).message}`,
|
|
761
|
+
INVALID_DID_UPDATE, { cause: errorCause(error) }
|
|
762
|
+
);
|
|
763
|
+
}
|
|
669
764
|
|
|
670
|
-
//
|
|
671
|
-
|
|
765
|
+
// Spec "Apply update": the patched document keeps the DID as its id and conforms to
|
|
766
|
+
// DID Core v1.1.
|
|
767
|
+
if(updatedDocument?.id !== currentDocument.id) {
|
|
768
|
+
throw new ResolveError(
|
|
769
|
+
`Invalid update: the patch changes the document id (from "${currentDocument.id}" to "${String(updatedDocument?.id)}")`,
|
|
770
|
+
INVALID_DID_UPDATE, { sourceId: currentDocument.id, targetId: updatedDocument?.id }
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
DidDocument.validate(updatedDocument);
|
|
775
|
+
} catch(error) {
|
|
776
|
+
throw new ResolveError(
|
|
777
|
+
`Invalid update: the patched document does not conform to DID Core: ${errorCause(error).message}`,
|
|
778
|
+
INVALID_DID_UPDATE, { cause: errorCause(error) }
|
|
779
|
+
);
|
|
780
|
+
}
|
|
672
781
|
|
|
673
782
|
// Canonicalize and hash the updatedDocument (raw bytes).
|
|
674
783
|
const updatedDocumentHash = canonicalHashBytes(updatedDocument);
|
|
675
784
|
|
|
676
785
|
// Prepare the update targetHash for comparison with updatedDocumentHash.
|
|
677
|
-
const updateTargetHash =
|
|
786
|
+
const updateTargetHash = Resolver.decodeUpdateHash(update.targetHash, 'targetHash');
|
|
678
787
|
|
|
679
788
|
// Make sure the update.targetHash equals updatedDocumentHash.
|
|
680
789
|
if (!equalBytes(updateTargetHash, updatedDocumentHash)) {
|
|
681
|
-
// If they do not match, throw INVALID_DID_UPDATE error.
|
|
682
790
|
throw new ResolveError(
|
|
683
791
|
`Invalid update: update.targetHash !== updatedDocumentHash`,
|
|
684
792
|
INVALID_DID_UPDATE, { updateTargetHash, updatedDocumentHash }
|
|
@@ -776,8 +884,12 @@ export class Resolver {
|
|
|
776
884
|
// This service has unmet data needs, collect them
|
|
777
885
|
allNeeds.push(...result.needs);
|
|
778
886
|
} else {
|
|
779
|
-
// All signals for this service resolved
|
|
780
|
-
|
|
887
|
+
// All signals for this service resolved: collect the updates with the
|
|
888
|
+
// beacon address of the service, mark the service processed.
|
|
889
|
+
const address = BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string);
|
|
890
|
+
this.#unsortedUpdates.push(...result.updates.map(
|
|
891
|
+
([update, block]): UpdateTuple => [update, block, address]
|
|
892
|
+
));
|
|
781
893
|
this.#processedServices.add(service.id);
|
|
782
894
|
}
|
|
783
895
|
}
|
|
@@ -794,6 +906,8 @@ export class Resolver {
|
|
|
794
906
|
// Spec "Process Next Update": one tuple per step. The phase repeats until
|
|
795
907
|
// the document resolves, or until an applied update adds a beacon address
|
|
796
908
|
// that the resolver did not scan (then the pass returns to BeaconDiscovery).
|
|
909
|
+
// A tuple whose beacon address the current document no longer carries is
|
|
910
|
+
// ignored (step 4).
|
|
797
911
|
case ResolverPhase.ProcessUpdate: {
|
|
798
912
|
const document = this.#currentDocument!;
|
|
799
913
|
|
|
@@ -826,9 +940,20 @@ export class Resolver {
|
|
|
826
940
|
this.#unsortedUpdates.sort(([upd0, blk0], [upd1, blk1]) =>
|
|
827
941
|
upd0.targetVersionId - upd1.targetVersionId || blk0.height - blk1.height
|
|
828
942
|
);
|
|
829
|
-
const [update, block] = this.#unsortedUpdates.shift()!;
|
|
943
|
+
const [update, block, address] = this.#unsortedUpdates.shift()!;
|
|
944
|
+
|
|
945
|
+
// Step 4: the current document has no beacon with the address of the signal:
|
|
946
|
+
// an applied update removed the beacon. Ignore the tuple (ADR 114). It stamps
|
|
947
|
+
// nothing, it enters no history, and it does not reach the duplicate check
|
|
948
|
+
// below: a conflicting re-announcement at a removed address is not an
|
|
949
|
+
// equivocation of the DID. The test reads the document before Apply, so an
|
|
950
|
+
// update announced at the address that it removes itself applies.
|
|
951
|
+
const removed = !BeaconUtils.getBeaconServices(document).some(service =>
|
|
952
|
+
BeaconUtils.parseBitcoinAddress(service.serviceEndpoint as string) === address
|
|
953
|
+
);
|
|
954
|
+
if(removed) continue;
|
|
830
955
|
|
|
831
|
-
// Check targetVersionId, first arm:
|
|
956
|
+
// Step 7, "Check targetVersionId", first arm: targetVersionId <= currentVersionId
|
|
832
957
|
// re-announces an applied version. Confirm that it is a true duplicate, then
|
|
833
958
|
// skip it. A duplicate does not advance the version counter, does not append
|
|
834
959
|
// to the history (the slot already holds the applied update, ADR 067), and
|
|
@@ -841,7 +966,7 @@ export class Resolver {
|
|
|
841
966
|
continue;
|
|
842
967
|
}
|
|
843
968
|
|
|
844
|
-
// Step
|
|
969
|
+
// Step 5: the versionTime stop. The block mediantime of the tuple is after
|
|
845
970
|
// versionTime: resolve the current document. The boundary is inclusive, so a
|
|
846
971
|
// tuple whose mediantime equals versionTime applies. The stopped tuple stamps
|
|
847
972
|
// nothing: the metadata reports the last applied update.
|
|
@@ -850,7 +975,7 @@ export class Resolver {
|
|
|
850
975
|
continue;
|
|
851
976
|
}
|
|
852
977
|
|
|
853
|
-
//
|
|
978
|
+
// Step 7, third arm: a version was skipped, so raise LATE_PUBLISHING.
|
|
854
979
|
if(update.targetVersionId !== this.#currentVersionId + 1) {
|
|
855
980
|
throw new ResolveError(
|
|
856
981
|
`Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`,
|
|
@@ -861,14 +986,14 @@ export class Resolver {
|
|
|
861
986
|
);
|
|
862
987
|
}
|
|
863
988
|
|
|
864
|
-
//
|
|
989
|
+
// Step 7, second arm: targetVersionId == currentVersionId + 1. Apply the update,
|
|
865
990
|
// append the unsigned update hash to the history, increment the version.
|
|
866
|
-
this.#currentDocument = Resolver.applyUpdate(document, update);
|
|
991
|
+
this.#currentDocument = Resolver.applyUpdate(document, update, block);
|
|
867
992
|
const unsignedUpdate = JSONUtils.deleteKeys(update, ['proof']) as UnsignedBTCR2Update;
|
|
868
993
|
this.#updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
|
|
869
994
|
this.#currentVersionId++;
|
|
870
995
|
|
|
871
|
-
// Step
|
|
996
|
+
// Step 6: block_confirmations, and the header time as `updated`. On the apply
|
|
872
997
|
// path only: the stop above and the duplicate branch stamp nothing.
|
|
873
998
|
this.#blockConfirmations = block.confirmations;
|
|
874
999
|
this.#updated = DateUtils.toISOStringNonFractional(DateUtils.blocktimeToTimestamp(block.time));
|
package/src/core/updater.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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}
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
192
|
-
|
|
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
|
-
//
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|