@did-btcr2/method 0.33.0 → 0.35.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.
Files changed (88) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/browser.js +371 -119
  3. package/dist/browser.mjs +371 -119
  4. package/dist/cjs/index.js +326 -81
  5. package/dist/esm/core/aggregation/cohort.js +5 -1
  6. package/dist/esm/core/aggregation/cohort.js.map +1 -1
  7. package/dist/esm/core/aggregation/participant.js +25 -8
  8. package/dist/esm/core/aggregation/participant.js.map +1 -1
  9. package/dist/esm/core/aggregation/runner/aggregation-runner.js +66 -0
  10. package/dist/esm/core/aggregation/runner/aggregation-runner.js.map +1 -0
  11. package/dist/esm/core/aggregation/runner/index.js +1 -0
  12. package/dist/esm/core/aggregation/runner/index.js.map +1 -1
  13. package/dist/esm/core/aggregation/runner/participant-runner.js +9 -1
  14. package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
  15. package/dist/esm/core/aggregation/runner/service-runner.js +4 -1
  16. package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
  17. package/dist/esm/core/aggregation/service.js +4 -4
  18. package/dist/esm/core/aggregation/service.js.map +1 -1
  19. package/dist/esm/core/aggregation/signer.js +33 -0
  20. package/dist/esm/core/aggregation/signer.js.map +1 -0
  21. package/dist/esm/core/aggregation/signing-session.js +34 -11
  22. package/dist/esm/core/aggregation/signing-session.js.map +1 -1
  23. package/dist/esm/core/aggregation/transport/in-memory.js +149 -0
  24. package/dist/esm/core/aggregation/transport/in-memory.js.map +1 -0
  25. package/dist/esm/core/aggregation/transport/index.js +1 -0
  26. package/dist/esm/core/aggregation/transport/index.js.map +1 -1
  27. package/dist/esm/core/beacon/beacon.js +10 -8
  28. package/dist/esm/core/beacon/beacon.js.map +1 -1
  29. package/dist/esm/core/beacon/cas-beacon.js +4 -4
  30. package/dist/esm/core/beacon/cas-beacon.js.map +1 -1
  31. package/dist/esm/core/beacon/factory.js +1 -1
  32. package/dist/esm/core/beacon/singleton-beacon.js +4 -4
  33. package/dist/esm/core/beacon/singleton-beacon.js.map +1 -1
  34. package/dist/esm/core/beacon/smt-beacon.js +4 -4
  35. package/dist/esm/core/beacon/smt-beacon.js.map +1 -1
  36. package/dist/esm/index.js +1 -0
  37. package/dist/esm/index.js.map +1 -1
  38. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  39. package/dist/types/core/aggregation/participant.d.ts +17 -4
  40. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  41. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts +56 -0
  42. package/dist/types/core/aggregation/runner/aggregation-runner.d.ts.map +1 -0
  43. package/dist/types/core/aggregation/runner/index.d.ts +1 -0
  44. package/dist/types/core/aggregation/runner/index.d.ts.map +1 -1
  45. package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
  46. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  47. package/dist/types/core/aggregation/service.d.ts +10 -4
  48. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  49. package/dist/types/core/aggregation/signer.d.ts +50 -0
  50. package/dist/types/core/aggregation/signer.d.ts.map +1 -0
  51. package/dist/types/core/aggregation/signing-session.d.ts +13 -5
  52. package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
  53. package/dist/types/core/aggregation/transport/in-memory.d.ts +64 -0
  54. package/dist/types/core/aggregation/transport/in-memory.d.ts.map +1 -0
  55. package/dist/types/core/aggregation/transport/index.d.ts +1 -0
  56. package/dist/types/core/aggregation/transport/index.d.ts.map +1 -1
  57. package/dist/types/core/beacon/beacon.d.ts +12 -10
  58. package/dist/types/core/beacon/beacon.d.ts.map +1 -1
  59. package/dist/types/core/beacon/cas-beacon.d.ts +4 -4
  60. package/dist/types/core/beacon/cas-beacon.d.ts.map +1 -1
  61. package/dist/types/core/beacon/factory.d.ts +3 -3
  62. package/dist/types/core/beacon/factory.d.ts.map +1 -1
  63. package/dist/types/core/beacon/singleton-beacon.d.ts +4 -4
  64. package/dist/types/core/beacon/singleton-beacon.d.ts.map +1 -1
  65. package/dist/types/core/beacon/smt-beacon.d.ts +4 -4
  66. package/dist/types/core/beacon/smt-beacon.d.ts.map +1 -1
  67. package/dist/types/core/resolver.d.ts +1 -1
  68. package/dist/types/index.d.ts +1 -0
  69. package/dist/types/index.d.ts.map +1 -1
  70. package/package.json +5 -5
  71. package/src/core/aggregation/cohort.ts +5 -1
  72. package/src/core/aggregation/participant.ts +38 -12
  73. package/src/core/aggregation/runner/aggregation-runner.ts +96 -0
  74. package/src/core/aggregation/runner/index.ts +1 -0
  75. package/src/core/aggregation/runner/participant-runner.ts +9 -1
  76. package/src/core/aggregation/runner/service-runner.ts +4 -1
  77. package/src/core/aggregation/service.ts +12 -6
  78. package/src/core/aggregation/signer.ts +67 -0
  79. package/src/core/aggregation/signing-session.ts +34 -11
  80. package/src/core/aggregation/transport/in-memory.ts +178 -0
  81. package/src/core/aggregation/transport/index.ts +1 -0
  82. package/src/core/beacon/beacon.ts +12 -10
  83. package/src/core/beacon/cas-beacon.ts +4 -4
  84. package/src/core/beacon/factory.ts +3 -3
  85. package/src/core/beacon/singleton-beacon.ts +4 -4
  86. package/src/core/beacon/smt-beacon.ts +4 -4
  87. package/src/core/resolver.ts +1 -1
  88. package/src/index.ts +1 -0
@@ -1,3 +1,4 @@
1
+ import { getNetwork } from '@did-btcr2/bitcoin';
1
2
  import { canonicalHash, canonicalize, hash } from '@did-btcr2/common';
2
3
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
3
4
  import type { SerializedSMTProof, TreeEntry } from '@did-btcr2/smt';
@@ -118,7 +119,10 @@ export class AggregationCohort {
118
119
  }
119
120
  const keyAggContext = keyAggregate(this.#cohortKeys);
120
121
  const aggPubkey = keyAggExport(keyAggContext);
121
- const payment = p2tr(aggPubkey);
122
+ // Derive the address for the cohort's network. Without the network arg
123
+ // p2tr defaults to mainnet, so a mutinynet/signet/regtest cohort would
124
+ // otherwise advertise a `bc1p...` address that no participant can fund.
125
+ const payment = p2tr(aggPubkey, undefined, getNetwork(this.network));
122
126
 
123
127
  // BIP-341: key-path-only P2TR has no script tree. Compute the tweak:
124
128
  // taggedHash("TapTweak", internalPubkey).
@@ -1,6 +1,5 @@
1
1
  import { canonicalHash } from '@did-btcr2/common';
2
2
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
3
- import type { SchnorrKeyPair } from '@did-btcr2/keypair';
4
3
  import type { SerializedSMTProof} from '@did-btcr2/smt';
5
4
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
6
5
  import { Transaction } from '@scure/btc-signer';
@@ -26,6 +25,7 @@ import {
26
25
  } from './messages/factories.js';
27
26
  import type { ParticipantCohortPhaseType } from './phases.js';
28
27
  import { ParticipantCohortPhase } from './phases.js';
28
+ import type { AggregationSigner } from './signer.js';
29
29
  import { BeaconSigningSession } from './signing-session.js';
30
30
 
31
31
  /** Cohort advert as discovered by the participant (UI: list of joinable cohorts). */
@@ -82,7 +82,12 @@ interface ParticipantCohortState {
82
82
 
83
83
  export interface AggregationParticipantParams {
84
84
  did: string;
85
- keys: SchnorrKeyPair;
85
+ /**
86
+ * The participant's MuSig2 signing capability. The raw secret is materialized
87
+ * only for the duration of a single nonce/partial-sign operation (see ADR 038);
88
+ * pass a {@link KeyPairAggregationSigner} to back it with an in-memory keypair.
89
+ */
90
+ signer: AggregationSigner;
86
91
  }
87
92
 
88
93
  /**
@@ -97,14 +102,21 @@ export interface AggregationParticipantParams {
97
102
  */
98
103
  export class AggregationParticipant {
99
104
  public readonly did: string;
100
- public readonly keys: SchnorrKeyPair;
105
+
106
+ /** MuSig2 signing capability. The raw secret never lives as a field here. */
107
+ readonly #signer: AggregationSigner;
101
108
 
102
109
  /** Per-cohort state, keyed by cohortId. */
103
110
  #cohortStates: Map<string, ParticipantCohortState> = new Map();
104
111
 
105
- constructor({ did, keys }: AggregationParticipantParams) {
112
+ constructor({ did, signer }: AggregationParticipantParams) {
106
113
  this.did = did;
107
- this.keys = keys;
114
+ this.#signer = signer;
115
+ }
116
+
117
+ /** The participant's compressed (33-byte) MuSig2 public key. Not secret. */
118
+ public get publicKey(): Uint8Array {
119
+ return this.#signer.publicKey;
108
120
  }
109
121
 
110
122
 
@@ -205,8 +217,8 @@ export class AggregationParticipant {
205
217
  from : this.did,
206
218
  to : state.serviceDid,
207
219
  cohortId,
208
- participantPk : this.keys.publicKey.compressed,
209
- communicationPk : this.keys.publicKey.compressed,
220
+ participantPk : this.publicKey,
221
+ communicationPk : this.publicKey,
210
222
  });
211
223
 
212
224
  return [optInMessage];
@@ -245,7 +257,7 @@ export class AggregationParticipant {
245
257
  const cohortKeys = message.body?.cohortKeys;
246
258
  if(!beaconAddress || !cohortKeys) return;
247
259
 
248
- const participantPkHex = bytesToHex(this.keys.publicKey.compressed);
260
+ const participantPkHex = bytesToHex(this.publicKey);
249
261
  const cohortKeysHex = cohortKeys.map(k => bytesToHex(new Uint8Array(k)));
250
262
 
251
263
  state.cohort.validateMembership(participantPkHex, cohortKeysHex, beaconAddress);
@@ -435,9 +447,8 @@ export class AggregationParticipant {
435
447
  });
436
448
  state.signingSession = session;
437
449
 
438
- const nonceContribution = session.generateNonceContribution(
439
- this.keys.publicKey.compressed,
440
- this.keys.secretKey.bytes
450
+ const nonceContribution = this.#signer.withSecret(
451
+ secretKey => session.generateNonceContribution(this.publicKey, secretKey)
441
452
  );
442
453
 
443
454
  state.phase = ParticipantCohortPhase.NonceSent;
@@ -485,7 +496,10 @@ export class AggregationParticipant {
485
496
  );
486
497
  }
487
498
 
488
- const partialSig = state.signingSession.generatePartialSignature(this.keys.secretKey.bytes);
499
+ const signingSession = state.signingSession;
500
+ const partialSig = this.#signer.withSecret(
501
+ secretKey => signingSession.generatePartialSignature(secretKey)
502
+ );
489
503
  state.phase = ParticipantCohortPhase.Complete;
490
504
 
491
505
  return [createSignatureAuthorizationMessage({
@@ -501,4 +515,16 @@ export class AggregationParticipant {
501
515
  public getCohortPhase(cohortId: string): ParticipantCohortPhaseType | undefined {
502
516
  return this.#cohortStates.get(cohortId)?.phase;
503
517
  }
518
+
519
+ /**
520
+ * Zeroize any retained MuSig2 secret nonces across all cohorts. The raw
521
+ * signing key is never held here (it lives behind the {@link AggregationSigner}
522
+ * and is wiped per-operation), but an abandoned signing session can still hold
523
+ * a secret nonce; call this on teardown to clear it deterministically.
524
+ */
525
+ public clearSecrets(): void {
526
+ for(const state of this.#cohortStates.values()) {
527
+ state.signingSession?.clearSecrets();
528
+ }
529
+ }
504
530
  }
@@ -0,0 +1,96 @@
1
+ import type { SchnorrKeyPair } from '@did-btcr2/keypair';
2
+ import type { AggregationResult } from '../service.js';
3
+ import { InMemoryBus, InMemoryTransport } from '../transport/in-memory.js';
4
+ import { AggregationParticipantRunner } from './participant-runner.js';
5
+ import type { OnProvideUpdate } from './participant-runner.js';
6
+ import { AggregationServiceRunner } from './service-runner.js';
7
+ import type { OnProvideTxData } from './service-runner.js';
8
+
9
+ /** Identity (DID + keys) for one actor in an {@link AggregationRunner.solo} run. */
10
+ export interface SoloActor {
11
+ did: string;
12
+ keys: SchnorrKeyPair;
13
+ }
14
+
15
+ /** Options for {@link AggregationRunner.solo}. */
16
+ export interface SoloCohortOptions {
17
+ /** The coordinating service identity. */
18
+ service: SoloActor;
19
+ /** The single participant identity (the lone signer of the cohort). */
20
+ participant: SoloActor;
21
+ /** Bitcoin network and beacon type (`'CASBeacon'` | `'SMTBeacon'`) for the cohort. */
22
+ config: { network: string; beaconType: string };
23
+ /** Provide the participant's signed BTCR2 update for the cohort. */
24
+ onProvideUpdate: OnProvideUpdate;
25
+ /** Provide the Bitcoin transaction data the cohort signs. */
26
+ onProvideTxData: OnProvideTxData;
27
+ /** Optional overall wall-clock budget for the run (ms). */
28
+ cohortTtlMs?: number;
29
+ /** Optional per-phase stall timeout (ms). */
30
+ phaseTimeoutMs?: number;
31
+ }
32
+
33
+ /**
34
+ * High-level facades for driving an aggregation cohort to completion.
35
+ *
36
+ * @class AggregationRunner
37
+ */
38
+ export class AggregationRunner {
39
+ /**
40
+ * Run a cohort of ONE participant entirely in-process and return the
41
+ * aggregated MuSig2 result.
42
+ *
43
+ * One party plays both the coordinating service and the lone participant,
44
+ * connected over an {@link InMemoryTransport} (no relay or HTTP server). This
45
+ * makes the single-participant aggregate-beacon path — the N=1 corner of the
46
+ * two-axis beacon matrix (see ADR 037) — first-class, useful for generating
47
+ * and reproducing single-participant aggregate test vectors.
48
+ *
49
+ * The service advertises a cohort with `minParticipants: 1`; the participant
50
+ * joins, submits its update, and the two complete keygen, data distribution,
51
+ * validation, and a one-signer MuSig2 P2TR key-path signing round.
52
+ *
53
+ * @param options Service + participant identities, cohort config, and the
54
+ * update / tx-data callbacks.
55
+ * @returns The {@link AggregationResult} (cohort id, aggregated signature, signed tx).
56
+ */
57
+ static async solo(options: SoloCohortOptions): Promise<AggregationResult> {
58
+ const transport = new InMemoryTransport(new InMemoryBus());
59
+ transport.registerActor(options.service.did, options.service.keys);
60
+ transport.registerActor(options.participant.did, options.participant.keys);
61
+ // Pre-register communication keys both ways. Production exchanges these via
62
+ // the protocol handshake; in-process we wire them directly.
63
+ transport.registerPeer(options.participant.did, options.participant.keys.publicKey.compressed);
64
+ transport.registerPeer(options.service.did, options.service.keys.publicKey.compressed);
65
+ transport.start();
66
+
67
+ const service = new AggregationServiceRunner({
68
+ transport,
69
+ did : options.service.did,
70
+ keys : options.service.keys,
71
+ config : { minParticipants: 1, network: options.config.network, beaconType: options.config.beaconType },
72
+ onProvideTxData : options.onProvideTxData,
73
+ cohortTtlMs : options.cohortTtlMs,
74
+ phaseTimeoutMs : options.phaseTimeoutMs,
75
+ // In-process bus with the participant already listening: a single advert
76
+ // suffices, so disable the republish loop (no dangling interval).
77
+ advertRepeatIntervalMs : 0,
78
+ });
79
+
80
+ const participant = new AggregationParticipantRunner({
81
+ transport,
82
+ did : options.participant.did,
83
+ keys : options.participant.keys,
84
+ shouldJoin : async () => true,
85
+ onProvideUpdate : options.onProvideUpdate,
86
+ });
87
+
88
+ await participant.start();
89
+ try {
90
+ return await service.run();
91
+ } finally {
92
+ participant.stop();
93
+ service.stop();
94
+ }
95
+ }
96
+ }
@@ -2,3 +2,4 @@ export * from './typed-emitter.js';
2
2
  export * from './events.js';
3
3
  export * from './service-runner.js';
4
4
  export * from './participant-runner.js';
5
+ export * from './aggregation-runner.js';
@@ -18,6 +18,7 @@ import {
18
18
  AggregationParticipant
19
19
  } from '../participant.js';
20
20
  import { ParticipantCohortPhase } from '../phases.js';
21
+ import { KeyPairAggregationSigner } from '../signer.js';
21
22
  import type { Transport } from '../transport/transport.js';
22
23
  import type { AggregationParticipantEvents } from './events.js';
23
24
  import { TypedEventEmitter } from './typed-emitter.js';
@@ -129,7 +130,10 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
129
130
  this.#onValidateData = options.onValidateData ?? (async (info) => ({ approved: info.matches }));
130
131
  this.#onApproveSigning = options.onApproveSigning ?? (async () => ({ approved: true }));
131
132
 
132
- this.session = new AggregationParticipant({ did: options.did, keys: options.keys });
133
+ this.session = new AggregationParticipant({
134
+ did : options.did,
135
+ signer : new KeyPairAggregationSigner(options.keys),
136
+ });
133
137
  }
134
138
 
135
139
  /**
@@ -144,6 +148,10 @@ export class AggregationParticipantRunner extends TypedEventEmitter<AggregationP
144
148
  stop(): void {
145
149
  this.#stopped = true;
146
150
  this.#unregisterHandlers();
151
+ // Deterministically wipe any secret nonce left on an abandoned signing
152
+ // session. The signing key itself lives behind the AggregationSigner and is
153
+ // never retained here.
154
+ this.session.clearSecrets();
147
155
  }
148
156
 
149
157
  /** Message types this runner listens for on the transport. */
@@ -184,8 +184,11 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
184
184
  this.#advertRepeatIntervalMs = options.advertRepeatIntervalMs ?? DEFAULT_ADVERT_REPEAT_INTERVAL_MS;
185
185
 
186
186
  this.session = new AggregationService({
187
+ // The coordinator never signs, so the state machine receives only the
188
+ // public half of the operator's keypair (see ADR 038). The full keypair
189
+ // remains the operator's transport/communication identity.
187
190
  did : options.did,
188
- keys : options.keys,
191
+ publicKey : options.keys.publicKey,
189
192
  maxUpdateSizeBytes : options.maxUpdateSizeBytes,
190
193
  });
191
194
  }
@@ -1,7 +1,7 @@
1
1
  import { canonicalize } from '@did-btcr2/common';
2
2
  import type { SignedBTCR2Update } from '@did-btcr2/cryptosuite';
3
3
  import { BIP340Cryptosuite, SchnorrMultikey } from '@did-btcr2/cryptosuite';
4
- import type { SchnorrKeyPair } from '@did-btcr2/keypair';
4
+ import type { CompressedSecp256k1PublicKey } from '@did-btcr2/keypair';
5
5
  import { bytesToHex } from '@noble/hashes/utils';
6
6
  import type { Transaction } from '@scure/btc-signer';
7
7
  import { getBeaconStrategy } from './beacon-strategy.js';
@@ -100,7 +100,13 @@ export const DEFAULT_MAX_UPDATE_SIZE_BYTES = 256 * 1024;
100
100
 
101
101
  export interface AggregationServiceParams {
102
102
  did: string;
103
- keys: SchnorrKeyPair;
103
+ /**
104
+ * The service's compressed communication public key (placed in cohort adverts).
105
+ * The coordinator never signs - it aggregates public nonces and partial
106
+ * signatures - so it is given a public key only, never a secret-bearing
107
+ * keypair (see ADR 038).
108
+ */
109
+ publicKey: CompressedSecp256k1PublicKey;
104
110
  /**
105
111
  * Maximum canonicalized byte-length of a signed update body accepted by the
106
112
  * service. Submissions above this cap are silently dropped and surfaced as
@@ -122,15 +128,15 @@ export interface AggregationServiceParams {
122
128
  */
123
129
  export class AggregationService {
124
130
  readonly did: string;
125
- readonly keys: SchnorrKeyPair;
131
+ readonly publicKey: CompressedSecp256k1PublicKey;
126
132
  readonly maxUpdateSizeBytes: number;
127
133
 
128
134
  /** Per-cohort state, keyed by cohortId. */
129
135
  #cohortStates: Map<string, ServiceCohortState> = new Map();
130
136
 
131
- constructor({ did, keys, maxUpdateSizeBytes }: AggregationServiceParams) {
137
+ constructor({ did, publicKey, maxUpdateSizeBytes }: AggregationServiceParams) {
132
138
  this.did = did;
133
- this.keys = keys;
139
+ this.publicKey = publicKey;
134
140
  this.maxUpdateSizeBytes = maxUpdateSizeBytes ?? DEFAULT_MAX_UPDATE_SIZE_BYTES;
135
141
  }
136
142
 
@@ -234,7 +240,7 @@ export class AggregationService {
234
240
  cohortSize : state.config.minParticipants,
235
241
  beaconType : state.config.beaconType,
236
242
  network : state.config.network,
237
- communicationPk : this.keys.publicKey.compressed,
243
+ communicationPk : this.publicKey.compressed,
238
244
  });
239
245
 
240
246
  state.phase = ServiceCohortPhase.Advertised;
@@ -0,0 +1,67 @@
1
+ import type { SchnorrKeyPair } from '@did-btcr2/keypair';
2
+ import { wipe } from '@did-btcr2/keypair';
3
+
4
+ /**
5
+ * A signing capability for MuSig2 aggregation.
6
+ *
7
+ * MuSig2 (BIP-327) cannot be driven through a generic `sign(message)` primitive:
8
+ * both nonce generation and partial signing need the raw 32-byte secret scalar
9
+ * (see ADR 038). Rather than hand that scalar around as a long-lived field, the
10
+ * participant holds an `AggregationSigner` and materializes the secret only for
11
+ * the duration of a single operation via {@link AggregationSigner.withSecret},
12
+ * which is responsible for wiping its working copy afterward.
13
+ *
14
+ * This is the seam a non-extractable / KMS-backed signer (or a session-scoped
15
+ * signer that destroys its key after the cohort completes) plugs into without
16
+ * changing the participant state machine.
17
+ *
18
+ * @interface AggregationSigner
19
+ */
20
+ export interface AggregationSigner {
21
+ /** Compressed 33-byte public key. Not secret; always available. */
22
+ readonly publicKey: Uint8Array;
23
+
24
+ /**
25
+ * Materialize the raw 32-byte secret key, pass it to `fn`, and zeroize the
26
+ * working copy before returning - even if `fn` throws. The secret must not
27
+ * escape the callback.
28
+ *
29
+ * @typeParam T The callback's return type (e.g. a nonce contribution or partial signature).
30
+ * @param {(secretKey: Uint8Array) => T} fn Operation that needs the raw secret for its duration.
31
+ * @returns {T} Whatever `fn` returns.
32
+ */
33
+ withSecret<T>(fn: (secretKey: Uint8Array) => T): T;
34
+ }
35
+
36
+ /**
37
+ * {@link AggregationSigner} backed by an in-memory {@link SchnorrKeyPair}.
38
+ *
39
+ * The keypair is held privately (never exposed as a public field) and each
40
+ * `withSecret` call pulls a fresh copy of the secret bytes, hands it to the
41
+ * callback, and wipes that copy on return. The underlying keypair is the
42
+ * caller's to own and destroy; this signer never mutates or destroys it.
43
+ *
44
+ * @class KeyPairAggregationSigner
45
+ * @implements {AggregationSigner}
46
+ */
47
+ export class KeyPairAggregationSigner implements AggregationSigner {
48
+ readonly publicKey: Uint8Array;
49
+ readonly #keys: SchnorrKeyPair;
50
+
51
+ /** @param {SchnorrKeyPair} keys The keypair whose secret backs this signer. */
52
+ constructor(keys: SchnorrKeyPair) {
53
+ this.#keys = keys;
54
+ this.publicKey = keys.publicKey.compressed;
55
+ }
56
+
57
+ withSecret<T>(fn: (secretKey: Uint8Array) => T): T {
58
+ // `.bytes` returns a fresh copy; wipe it once the operation completes so the
59
+ // raw scalar does not linger on the heap beyond a single MuSig2 step.
60
+ const secretKey = this.#keys.secretKey.bytes;
61
+ try {
62
+ return fn(secretKey);
63
+ } finally {
64
+ wipe(secretKey);
65
+ }
66
+ }
67
+ }
@@ -1,3 +1,4 @@
1
+ import { wipe } from '@did-btcr2/keypair';
1
2
  import type { Transaction } from '@scure/btc-signer';
2
3
  import { SigHash } from '@scure/btc-signer';
3
4
  import * as musig2 from '@scure/btc-signer/musig2';
@@ -58,8 +59,14 @@ export class BeaconSigningSession {
58
59
  /** Current signing session phase. */
59
60
  public phase: SigningSessionPhaseType;
60
61
 
61
- /** Participant's secret nonce (held only by the participant during signing). */
62
- public secretNonce?: Uint8Array;
62
+ /**
63
+ * Participant's MuSig2 secret nonce, held only on the participant side
64
+ * between {@link generateNonceContribution} and {@link generatePartialSignature}.
65
+ * Private and cleared on every terminal path (success, failure, or teardown
66
+ * via {@link clearSecrets}) so a spent or abandoned nonce cannot be reused or
67
+ * serialized.
68
+ */
69
+ #secretNonce?: Uint8Array;
63
70
 
64
71
  constructor({ id, cohort, pendingTx, prevOutScripts, prevOutValues }: SigningSessionParams) {
65
72
  this.id = id || crypto.randomUUID();
@@ -225,7 +232,7 @@ export class BeaconSigningSession {
225
232
  public generateNonceContribution(participantPublicKey: Uint8Array, participantSecretKey: Uint8Array): Uint8Array {
226
233
  const aggPublicKey = musig2.keyAggExport(musig2.keyAggregate(this.cohort.cohortKeys));
227
234
  const nonces = musig2.nonceGen(participantPublicKey, participantSecretKey, aggPublicKey);
228
- this.secretNonce = nonces.secret;
235
+ this.#secretNonce = nonces.secret;
229
236
  return nonces.public;
230
237
  }
231
238
 
@@ -233,15 +240,17 @@ export class BeaconSigningSession {
233
240
  * Generates a partial signature using the participant's secret key + secret nonce.
234
241
  * Requires the aggregated nonce to have been set first (via the service).
235
242
  *
236
- * Zeros the stored `secretNonce` after use. JS cannot truly erase memory (GC
237
- * and immutable strings), but overwriting the bytes shortens the exposure
238
- * window and prevents accidental reuse or serialization of a spent nonce.
243
+ * Clears the stored secret nonce after use on every path (success or throw)
244
+ * via {@link clearSecrets}. JS cannot truly erase memory (GC may relocate
245
+ * buffers), but overwriting the bytes shortens the exposure window and
246
+ * prevents accidental reuse or serialization of a spent nonce - reuse of a
247
+ * MuSig2 nonce leaks the secret key.
239
248
  */
240
249
  public generatePartialSignature(participantSecretKey: Uint8Array): Uint8Array {
241
250
  if(!this.aggregatedNonce) {
242
251
  throw new SigningSessionError('Aggregated nonce not available.', 'MISSING_AGGREGATED_NONCE');
243
252
  }
244
- if(!this.secretNonce) {
253
+ if(!this.#secretNonce) {
245
254
  throw new SigningSessionError('Secret nonce not available — generateNonceContribution() must be called first.', 'MISSING_SECRET_NONCE');
246
255
  }
247
256
  const session = new musig2.Session(
@@ -251,10 +260,24 @@ export class BeaconSigningSession {
251
260
  [this.cohort.tapTweak],
252
261
  [true]
253
262
  );
254
- const partialSig = session.sign(this.secretNonce, participantSecretKey);
255
- this.secretNonce.fill(0);
256
- this.secretNonce = undefined;
257
- return partialSig;
263
+ try {
264
+ return session.sign(this.#secretNonce, participantSecretKey);
265
+ } finally {
266
+ this.clearSecrets();
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Zeroize any retained secret nonce. Safe to call repeatedly and on any path
272
+ * (completion, failure, or teardown of an abandoned session). Callers that
273
+ * drop a session before it reaches a partial signature should invoke this so
274
+ * the secret nonce does not linger on the live object.
275
+ */
276
+ public clearSecrets(): void {
277
+ if(this.#secretNonce) {
278
+ wipe(this.#secretNonce);
279
+ this.#secretNonce = undefined;
280
+ }
258
281
  }
259
282
 
260
283
  public isComplete(): boolean {
@@ -0,0 +1,178 @@
1
+ import type { SchnorrKeyPair } from '@did-btcr2/keypair';
2
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
3
+ import type { BaseMessage } from '../messages/base.js';
4
+ import type { MessageHandler, Transport } from './transport.js';
5
+
6
+ /** Internal registration for a single actor sharing an {@link InMemoryTransport}. */
7
+ interface ActorEntry {
8
+ /** Compressed communication public key. The in-process bus does no encryption, so no secret is retained. */
9
+ publicKey: Uint8Array;
10
+ handlers: Map<string, MessageHandler>;
11
+ }
12
+
13
+ /**
14
+ * In-process message bus connecting one or more {@link InMemoryTransport}
15
+ * instances. Routes broadcasts to every registered actor and directed messages
16
+ * to the actor that owns the recipient DID — with no relay, server, or network.
17
+ *
18
+ * Each delivery does a JSON round-trip (Uint8Array preserved as `__bytes` hex)
19
+ * so handlers receive an isolated, serialization-faithful copy, exactly as a
20
+ * real transport would. The message `body` is merged to the top level to match
21
+ * the shape the {@link NostrTransport} dispatch produces.
22
+ *
23
+ * @class InMemoryBus
24
+ */
25
+ export class InMemoryBus {
26
+ #transports: Set<InMemoryTransport> = new Set();
27
+
28
+ /** Attach a transport to this bus. Called by the transport's constructor. */
29
+ register(transport: InMemoryTransport): void {
30
+ this.#transports.add(transport);
31
+ }
32
+
33
+ /** Detach a transport from this bus. */
34
+ unregister(transport: InMemoryTransport): void {
35
+ this.#transports.delete(transport);
36
+ }
37
+
38
+ /**
39
+ * Deliver a message. With no `recipient` the message is broadcast to every
40
+ * actor on the bus; otherwise it is routed to the single transport that owns
41
+ * the recipient DID.
42
+ */
43
+ async deliver(message: BaseMessage, _sender: string, recipient?: string): Promise<void> {
44
+ const type = (message as { type?: string }).type;
45
+ if(!type) return;
46
+
47
+ // JSON round-trip to mimic transport serialization, preserving Uint8Array.
48
+ const replacer = (_k: string, v: unknown): unknown => v instanceof Uint8Array ? { __bytes: bytesToHex(v) } : v;
49
+ const reviver = (_k: string, v: unknown): unknown =>
50
+ v && typeof v === 'object' && '__bytes' in (v as Record<string, unknown>)
51
+ ? hexToBytes((v as { __bytes: string }).__bytes)
52
+ : v;
53
+ const raw = JSON.parse(JSON.stringify(message, replacer), reviver) as Record<string, unknown>;
54
+ const serialized = { ...raw, ...((raw.body as Record<string, unknown> | undefined) ?? {}) };
55
+
56
+ if(!recipient) {
57
+ for(const t of this.#transports) {
58
+ await t.dispatchBroadcast(type, serialized);
59
+ }
60
+ return;
61
+ }
62
+ for(const t of this.#transports) {
63
+ if(t.hasActor(recipient)) {
64
+ await t.dispatchDirected(recipient, type, serialized);
65
+ return;
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * In-process {@link Transport} that routes aggregation messages through an
73
+ * {@link InMemoryBus} instead of a relay or HTTP server. Supports multiple
74
+ * actors per instance, so a single transport can host both a service and its
75
+ * participants (e.g. a cohort-of-one via {@link AggregationRunner.solo}).
76
+ *
77
+ * Encryption is a no-op (in-process, same trust domain); `registerPeer` /
78
+ * `getPeerPk` keep a registry so the contract matches the wire transports.
79
+ *
80
+ * @class InMemoryTransport
81
+ * @implements {Transport}
82
+ */
83
+ export class InMemoryTransport implements Transport {
84
+ name: string = 'in-memory';
85
+ readonly bus: InMemoryBus;
86
+
87
+ #actors: Map<string, ActorEntry> = new Map();
88
+ #peers: Map<string, Uint8Array> = new Map();
89
+
90
+ /** @param bus Shared bus. Pass the same bus to connect multiple transports. */
91
+ constructor(bus: InMemoryBus = new InMemoryBus()) {
92
+ this.bus = bus;
93
+ this.bus.register(this);
94
+ }
95
+
96
+ start(): void {
97
+ // No-op: there is no underlying connection to open.
98
+ }
99
+
100
+ registerActor(did: string, keys: SchnorrKeyPair): void {
101
+ // In-process delivery does no signing or encryption (same trust domain), so
102
+ // retain only the public key - the secret-bearing keypair is never stored in
103
+ // the transport registry (see ADR 038).
104
+ this.#actors.set(did, { publicKey: keys.publicKey.compressed, handlers: new Map() });
105
+ }
106
+
107
+ getActorPk(did: string): Uint8Array | undefined {
108
+ return this.#actors.get(did)?.publicKey;
109
+ }
110
+
111
+ /** True if `did` is registered on this transport. Used by the bus for routing. */
112
+ hasActor(did: string): boolean {
113
+ return this.#actors.has(did);
114
+ }
115
+
116
+ registerPeer(did: string, communicationPk: Uint8Array): void {
117
+ this.#peers.set(did, communicationPk);
118
+ }
119
+
120
+ getPeerPk(did: string): Uint8Array | undefined {
121
+ return this.#peers.get(did);
122
+ }
123
+
124
+ registerMessageHandler(actorDid: string, messageType: string, handler: MessageHandler): void {
125
+ const actor = this.#actors.get(actorDid);
126
+ if(actor) actor.handlers.set(messageType, handler);
127
+ }
128
+
129
+ unregisterMessageHandler(actorDid: string, messageType: string): void {
130
+ const actor = this.#actors.get(actorDid);
131
+ if(actor) actor.handlers.delete(messageType);
132
+ }
133
+
134
+ unregisterActor(did: string): void {
135
+ const actor = this.#actors.get(did);
136
+ if(!actor) return;
137
+ actor.handlers.clear();
138
+ this.#actors.delete(did);
139
+ this.#peers.delete(did);
140
+ }
141
+
142
+ async sendMessage(message: BaseMessage, sender: string, recipient?: string): Promise<void> {
143
+ await this.bus.deliver(message, sender, recipient);
144
+ }
145
+
146
+ publishRepeating(
147
+ message: BaseMessage,
148
+ sender: string,
149
+ intervalMs: number,
150
+ recipient?: string,
151
+ ): () => void {
152
+ let stopped = false;
153
+ void this.sendMessage(message, sender, recipient).catch(() => { /* in-process: no relay to reject */ });
154
+ const timer = setInterval(() => {
155
+ if(stopped) return;
156
+ void this.sendMessage(message, sender, recipient).catch(() => { /* ignore */ });
157
+ }, intervalMs);
158
+ return () => {
159
+ if(stopped) return;
160
+ stopped = true;
161
+ clearInterval(timer);
162
+ };
163
+ }
164
+
165
+ /** Deliver a broadcast message to every actor on this transport that handles `type`. */
166
+ async dispatchBroadcast(type: string, message: unknown): Promise<void> {
167
+ for(const actor of this.#actors.values()) {
168
+ const handler = actor.handlers.get(type);
169
+ if(handler) await handler(message);
170
+ }
171
+ }
172
+
173
+ /** Deliver a directed message to the recipient actor's handler for `type`. */
174
+ async dispatchDirected(recipientDid: string, type: string, message: unknown): Promise<void> {
175
+ const handler = this.#actors.get(recipientDid)?.handlers.get(type);
176
+ if(handler) await handler(message);
177
+ }
178
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './transport.js';
2
2
  export * from './error.js';
3
3
  export * from './factory.js';
4
+ export * from './in-memory.js';
4
5
  export * from './nostr.js';
5
6
  export * from './didcomm.js';
6
7
  export * from './http/index.js';