@did-btcr2/method 0.50.0 → 0.52.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,25 +1,106 @@
1
+ import { DidDocumentError, INVALID_DID_DOCUMENT } from '@did-btcr2/common';
2
+ import { SchnorrMultikey } from '@did-btcr2/cryptosuite';
1
3
  import { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
4
+ import type { DidDocument, DidVerificationMethod } from '../utils/did-document.js';
2
5
  import { Identifier } from './identifier.js';
6
+ import { Resolver } from './resolver.js';
7
+
8
+ /**
9
+ * Derive the aggregation communication public key from a resolved did:btcr2 DID Document.
10
+ *
11
+ * The communication key is the verification method referenced by
12
+ * `capabilityInvocation[0]`, resolved to its public key. This is the exact relationship
13
+ * the method already enforces for DID updates (construct and sign require the signing
14
+ * method to be in `capabilityInvocation`, the update proof is built and verified with
15
+ * `proofPurpose: 'capabilityInvocation'`), so binding the transport communication key to
16
+ * it yields the invariant "transport-authenticated as D implies authorized to update D."
17
+ * For a KEY (`k1`) document the single deterministic key is already the sole
18
+ * `capabilityInvocation` entry, so this is a no-op for KEY DIDs.
19
+ *
20
+ * There is deliberately no `verificationMethod[0]` fallback: a document without
21
+ * `capabilityInvocation` cannot be updated at all, so it is useless for aggregation and is
22
+ * rejected here rather than bound to an unrelated key.
23
+ *
24
+ * @param {DidDocument} document The resolved DID Document (placeholder id already replaced).
25
+ * @returns {CompressedSecp256k1PublicKey} The compressed public key of the communication method.
26
+ * @throws {DidDocumentError} If `capabilityInvocation` is absent or its first entry does not
27
+ * resolve to a verification method in the document.
28
+ */
29
+ export function getAggregationCommunicationKey(
30
+ document: DidDocument,
31
+ ): CompressedSecp256k1PublicKey {
32
+ const invocation = document.capabilityInvocation?.[0];
33
+ if(invocation === undefined) {
34
+ throw new DidDocumentError(
35
+ 'Cannot derive aggregation communication key: capabilityInvocation is absent',
36
+ INVALID_DID_DOCUMENT, { id: document.id }
37
+ );
38
+ }
39
+
40
+ // Resolve capabilityInvocation[0] to a verification method: a string reference is
41
+ // dereferenced by id against verificationMethod; an embedded method is used directly.
42
+ // A local id lookup is used rather than getSigningMethod, which defaults to #initialKey
43
+ // and does not resolve embedded methods.
44
+ const vm: DidVerificationMethod | undefined = typeof invocation === 'string'
45
+ ? document.verificationMethod?.find(method => method.id === invocation)
46
+ : invocation;
47
+
48
+ if(!vm) {
49
+ throw new DidDocumentError(
50
+ `Cannot derive aggregation communication key: capabilityInvocation[0] "${invocation}" `
51
+ + 'does not resolve to a verification method',
52
+ INVALID_DID_DOCUMENT, { id: document.id, invocation }
53
+ );
54
+ }
55
+
56
+ return SchnorrMultikey.fromVerificationMethod(vm).publicKey;
57
+ }
3
58
 
4
59
  /**
5
60
  * Resolve a did:btcr2 sender's communication public key from its DID, for the
6
- * aggregation HTTP transport's `resolveSenderPk` option: a KEY identifier decodes to
7
- * its genesis public key. The aggregation transport is DID-method-agnostic and does
8
- * not name `Identifier`; method supplies this resolver when it wires the transport so
9
- * a sender that is not a pre-registered peer can still be authenticated from its DID.
61
+ * aggregation HTTP transport's `resolveSenderPk` option.
10
62
  *
11
- * @param did The sender's DID.
12
- * @returns The sender's compressed public key, or `undefined` when the DID is not a
13
- * decodable did:btcr2 KEY identifier (resolution then falls back to registered peers).
63
+ * A KEY (`k1`) identifier decodes directly to its genesis public key: the DID string is
64
+ * the key. An EXTERNAL (`x1`) identifier is a commitment to the hash of a genesis
65
+ * document, so there is no key in the DID string. When the genesis document is supplied
66
+ * in-band (via `opts.genesisDocument`), it is self-verifying against the DID: `Resolver`'s
67
+ * external path recomputes its canonical hash, compares it to the identifier's genesis
68
+ * bytes (throwing on mismatch), and resolves it, after which the communication key is
69
+ * derived from `capabilityInvocation[0]` ({@link getAggregationCommunicationKey}). Without
70
+ * a genesis document, an `x1` DID still resolves to `undefined`, so callers that pass no
71
+ * second argument behave exactly as before.
72
+ *
73
+ * The aggregation transport is DID-method-agnostic and does not name `Identifier`; method
74
+ * supplies this resolver when it wires the transport so a sender that is not a
75
+ * pre-registered peer can still be authenticated from its DID.
76
+ *
77
+ * @param {string} did The sender's DID.
78
+ * @param {object} [opts] Optional resolution inputs.
79
+ * @param {object} [opts.genesisDocument] The `x1` sender's genesis document, carried in-band
80
+ * on the bootstrap opt-in. Ignored for KEY identifiers.
81
+ * @returns {CompressedSecp256k1PublicKey | undefined} The sender's compressed public key, or
82
+ * `undefined` when the DID is not a decodable did:btcr2 identifier, is an `x1` identifier
83
+ * with no (or a non-matching) genesis document, or has no usable communication key.
14
84
  */
15
- export function resolveBtcr2SenderPk(did: string): CompressedSecp256k1PublicKey | undefined {
85
+ export function resolveBtcr2SenderPk(
86
+ did: string,
87
+ opts?: { genesisDocument?: object },
88
+ ): CompressedSecp256k1PublicKey | undefined {
16
89
  try {
17
90
  const components = Identifier.decode(did);
18
91
  if(components.idType === 'KEY') {
19
92
  return new CompressedSecp256k1PublicKey(components.genesisBytes);
20
93
  }
94
+ // EXTERNAL (x1): the DID commits to the hash of a genesis document. With the genesis
95
+ // supplied in-band, verify it hashes to the DID and derive the communication key from
96
+ // it; without it, there is no key to return.
97
+ if(opts?.genesisDocument) {
98
+ const document = Resolver.external(components, opts.genesisDocument);
99
+ return getAggregationCommunicationKey(document);
100
+ }
21
101
  } catch {
22
- // Not a decodable did:btcr2 KEY identifier.
102
+ // Not a decodable did:btcr2 identifier, the genesis does not hash to the DID, or the
103
+ // document has no usable capabilityInvocation communication key.
23
104
  }
24
105
  return undefined;
25
106
  }
@@ -419,15 +419,23 @@ export class Resolver {
419
419
  }
420
420
  }
421
421
 
422
- // Check update.targetVersionId against currentVersionId
423
- // If update.targetVersionId <= currentVersionId, confirm duplicate update
422
+ // Check update.targetVersionId against currentVersionId.
423
+ // If update.targetVersionId <= currentVersionId, this update re-announces a version
424
+ // that has already been applied. Confirm it is a true duplicate, then skip it: a
425
+ // duplicate does not advance the version counter (the increment and the
426
+ // metadata.versionId it sets run only on the apply path below), and confirmation
427
+ // compares against the update-hash history without appending to it, because the
428
+ // history already holds the applied update at updateHashHistory[targetVersionId - 2].
429
+ // Holding the increment off the duplicate path is the deliberate did:btcr2 deviation
430
+ // recorded in ADR 067: the read algorithm's "Increment current_version_id" belongs
431
+ // to the apply branch, not to every tuple.
424
432
  if(update.targetVersionId <= currentVersionId) {
425
- updateHashHistory.push(currentDocumentHash);
426
433
  this.confirmDuplicate(update, updateHashHistory);
434
+ continue;
427
435
  }
428
436
 
429
437
  // If update.targetVersionId == currentVersionId + 1, apply the update
430
- else if (update.targetVersionId === currentVersionId + 1) {
438
+ if (update.targetVersionId === currentVersionId + 1) {
431
439
  // Check if update.sourceHash !== currentDocumentHash (byte comparison)
432
440
  const sourceHashBytes = decodeHash(update.sourceHash, 'base64urlnopad');
433
441
  if (!equalBytes(sourceHashBytes, currentDocumentHash)) {
@@ -448,11 +456,12 @@ export class Resolver {
448
456
  updateHashHistory.push(canonicalHashBytes(unsignedUpdate));
449
457
  }
450
458
 
451
- // If update.targetVersionId > currentVersionId + 1, throw LATE_PUBLISHING error
452
- else if(update.targetVersionId > currentVersionId + 1) {
459
+ // Otherwise update.targetVersionId > currentVersionId + 1: a version was skipped,
460
+ // so throw LATE_PUBLISHING error. The duplicate case already continued above.
461
+ else {
453
462
  throw new ResolveError(
454
463
  `Version Id Mismatch: targetVersionId cannot be > currentVersionId + 1`,
455
- 'LATE_PUBLISHING_ERROR', {
464
+ LATE_PUBLISHING_ERROR, {
456
465
  targetVersionId : update.targetVersionId,
457
466
  currentVersionId : currentVersionId + 1
458
467
  }
@@ -462,7 +471,7 @@ export class Resolver {
462
471
  // Increment currentVersionId
463
472
  currentVersionId++;
464
473
 
465
- // Set response.versionId to be the new currentVersionId
474
+ // Set response.versionId to be the new currentVersionId
466
475
  response.metadata.versionId = `${currentVersionId}`;
467
476
 
468
477
  // If resolutionOptions.versionId is defined and <= currentVersionId, return currentDocument