@did-btcr2/method 0.34.0 → 0.36.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 (64) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/browser.js +335 -167
  3. package/dist/browser.mjs +335 -167
  4. package/dist/cjs/index.js +275 -114
  5. package/dist/esm/core/aggregation/cohort.js +8 -2
  6. package/dist/esm/core/aggregation/cohort.js.map +1 -1
  7. package/dist/esm/core/aggregation/conditions.js +75 -0
  8. package/dist/esm/core/aggregation/conditions.js.map +1 -0
  9. package/dist/esm/core/aggregation/messages/base.js.map +1 -1
  10. package/dist/esm/core/aggregation/messages/bodies.js +16 -2
  11. package/dist/esm/core/aggregation/messages/bodies.js.map +1 -1
  12. package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
  13. package/dist/esm/core/aggregation/participant.js +34 -15
  14. package/dist/esm/core/aggregation/participant.js.map +1 -1
  15. package/dist/esm/core/aggregation/runner/participant-runner.js +9 -1
  16. package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
  17. package/dist/esm/core/aggregation/runner/service-runner.js +12 -1
  18. package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
  19. package/dist/esm/core/aggregation/service.js +27 -7
  20. package/dist/esm/core/aggregation/service.js.map +1 -1
  21. package/dist/esm/core/aggregation/signer.js +33 -0
  22. package/dist/esm/core/aggregation/signer.js.map +1 -0
  23. package/dist/esm/core/aggregation/signing-session.js +34 -11
  24. package/dist/esm/core/aggregation/signing-session.js.map +1 -1
  25. package/dist/esm/core/aggregation/transport/in-memory.js +5 -2
  26. package/dist/esm/core/aggregation/transport/in-memory.js.map +1 -1
  27. package/dist/esm/index.js +2 -0
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
  30. package/dist/types/core/aggregation/conditions.d.ts +58 -0
  31. package/dist/types/core/aggregation/conditions.d.ts.map +1 -0
  32. package/dist/types/core/aggregation/messages/base.d.ts +2 -3
  33. package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
  34. package/dist/types/core/aggregation/messages/bodies.d.ts +2 -3
  35. package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
  36. package/dist/types/core/aggregation/messages/factories.d.ts +2 -3
  37. package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
  38. package/dist/types/core/aggregation/participant.d.ts +24 -8
  39. package/dist/types/core/aggregation/participant.d.ts.map +1 -1
  40. package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
  41. package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
  42. package/dist/types/core/aggregation/service.d.ts +18 -8
  43. package/dist/types/core/aggregation/service.d.ts.map +1 -1
  44. package/dist/types/core/aggregation/signer.d.ts +50 -0
  45. package/dist/types/core/aggregation/signer.d.ts.map +1 -0
  46. package/dist/types/core/aggregation/signing-session.d.ts +13 -5
  47. package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
  48. package/dist/types/core/aggregation/transport/in-memory.d.ts.map +1 -1
  49. package/dist/types/index.d.ts +2 -0
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/package.json +4 -4
  52. package/src/core/aggregation/cohort.ts +8 -2
  53. package/src/core/aggregation/conditions.ts +116 -0
  54. package/src/core/aggregation/messages/base.ts +6 -3
  55. package/src/core/aggregation/messages/bodies.ts +18 -6
  56. package/src/core/aggregation/messages/factories.ts +2 -3
  57. package/src/core/aggregation/participant.ts +54 -23
  58. package/src/core/aggregation/runner/participant-runner.ts +9 -1
  59. package/src/core/aggregation/runner/service-runner.ts +13 -1
  60. package/src/core/aggregation/service.ts +51 -13
  61. package/src/core/aggregation/signer.ts +67 -0
  62. package/src/core/aggregation/signing-session.ts +34 -11
  63. package/src/core/aggregation/transport/in-memory.ts +7 -3
  64. package/src/index.ts +2 -0
@@ -1,13 +1,14 @@
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';
7
6
  import { getBeaconStrategy } from './beacon-strategy.js';
8
7
  import { AggregationCohort } from './cohort.js';
8
+ import type { CohortConditions } from './conditions.js';
9
9
  import { AggregationParticipantError } from './errors.js';
10
10
  import type { BaseMessage } from './messages/base.js';
11
+ import { isCohortAdvertMessage } from './messages/bodies.js';
11
12
  import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
12
13
  import {
13
14
  AGGREGATED_NONCE,
@@ -26,15 +27,18 @@ import {
26
27
  } from './messages/factories.js';
27
28
  import type { ParticipantCohortPhaseType } from './phases.js';
28
29
  import { ParticipantCohortPhase } from './phases.js';
30
+ import type { AggregationSigner } from './signer.js';
29
31
  import { BeaconSigningSession } from './signing-session.js';
30
32
 
31
- /** Cohort advert as discovered by the participant (UI: list of joinable cohorts). */
32
- export interface CohortAdvert {
33
+ /**
34
+ * Cohort advert as discovered by the participant (UI: list of joinable cohorts).
35
+ * Carries the advertised {@link CohortConditions} (beaconType, minParticipants,
36
+ * maxParticipants, costs, ...) so a `shouldJoin` decision can inspect them.
37
+ */
38
+ export interface CohortAdvert extends CohortConditions {
33
39
  cohortId: string;
34
40
  serviceDid: string;
35
- cohortSize: number;
36
41
  network: string;
37
- beaconType: string;
38
42
  serviceCommunicationPk: Uint8Array;
39
43
  }
40
44
 
@@ -82,7 +86,12 @@ interface ParticipantCohortState {
82
86
 
83
87
  export interface AggregationParticipantParams {
84
88
  did: string;
85
- keys: SchnorrKeyPair;
89
+ /**
90
+ * The participant's MuSig2 signing capability. The raw secret is materialized
91
+ * only for the duration of a single nonce/partial-sign operation (see ADR 038);
92
+ * pass a {@link KeyPairAggregationSigner} to back it with an in-memory keypair.
93
+ */
94
+ signer: AggregationSigner;
86
95
  }
87
96
 
88
97
  /**
@@ -97,14 +106,21 @@ export interface AggregationParticipantParams {
97
106
  */
98
107
  export class AggregationParticipant {
99
108
  public readonly did: string;
100
- public readonly keys: SchnorrKeyPair;
109
+
110
+ /** MuSig2 signing capability. The raw secret never lives as a field here. */
111
+ readonly #signer: AggregationSigner;
101
112
 
102
113
  /** Per-cohort state, keyed by cohortId. */
103
114
  #cohortStates: Map<string, ParticipantCohortState> = new Map();
104
115
 
105
- constructor({ did, keys }: AggregationParticipantParams) {
116
+ constructor({ did, signer }: AggregationParticipantParams) {
106
117
  this.did = did;
107
- this.keys = keys;
118
+ this.#signer = signer;
119
+ }
120
+
121
+ /** The participant's compressed (33-byte) MuSig2 public key. Not secret. */
122
+ public get publicKey(): Uint8Array {
123
+ return this.#signer.publicKey;
108
124
  }
109
125
 
110
126
 
@@ -156,17 +172,18 @@ export class AggregationParticipant {
156
172
  }
157
173
 
158
174
  #handleCohortAdvert(message: BaseMessage): void {
159
- const cohortId = message.body?.cohortId;
160
- if(!cohortId) return;
175
+ // Validate the wire shape (incl. minParticipants range) before trusting it,
176
+ // rather than reading fields with `?? 0` fallbacks (see ADR 039).
177
+ if(!isCohortAdvertMessage(message)) return;
178
+ const { cohortId, network, communicationPk, ...conditions } = message.body;
161
179
  if(this.#cohortStates.has(cohortId)) return; // Already known
162
180
 
163
181
  const advert: CohortAdvert = {
164
182
  cohortId,
165
183
  serviceDid : message.from,
166
- cohortSize : message.body?.cohortSize ?? 0,
167
- network : message.body?.network ?? '',
168
- beaconType : message.body?.beaconType ?? 'CASBeacon',
169
- serviceCommunicationPk : message.body?.communicationPk ?? new Uint8Array(),
184
+ network,
185
+ serviceCommunicationPk : communicationPk,
186
+ ...conditions,
170
187
  };
171
188
 
172
189
  this.#cohortStates.set(cohortId, {
@@ -194,7 +211,7 @@ export class AggregationParticipant {
194
211
  const cohort = new AggregationCohort({
195
212
  id : cohortId,
196
213
  serviceDid : state.serviceDid,
197
- minParticipants : state.advert!.cohortSize,
214
+ minParticipants : state.advert!.minParticipants,
198
215
  network : state.advert!.network,
199
216
  beaconType : state.advert!.beaconType,
200
217
  });
@@ -205,8 +222,8 @@ export class AggregationParticipant {
205
222
  from : this.did,
206
223
  to : state.serviceDid,
207
224
  cohortId,
208
- participantPk : this.keys.publicKey.compressed,
209
- communicationPk : this.keys.publicKey.compressed,
225
+ participantPk : this.publicKey,
226
+ communicationPk : this.publicKey,
210
227
  });
211
228
 
212
229
  return [optInMessage];
@@ -245,7 +262,7 @@ export class AggregationParticipant {
245
262
  const cohortKeys = message.body?.cohortKeys;
246
263
  if(!beaconAddress || !cohortKeys) return;
247
264
 
248
- const participantPkHex = bytesToHex(this.keys.publicKey.compressed);
265
+ const participantPkHex = bytesToHex(this.publicKey);
249
266
  const cohortKeysHex = cohortKeys.map(k => bytesToHex(new Uint8Array(k)));
250
267
 
251
268
  state.cohort.validateMembership(participantPkHex, cohortKeysHex, beaconAddress);
@@ -435,9 +452,8 @@ export class AggregationParticipant {
435
452
  });
436
453
  state.signingSession = session;
437
454
 
438
- const nonceContribution = session.generateNonceContribution(
439
- this.keys.publicKey.compressed,
440
- this.keys.secretKey.bytes
455
+ const nonceContribution = this.#signer.withSecret(
456
+ secretKey => session.generateNonceContribution(this.publicKey, secretKey)
441
457
  );
442
458
 
443
459
  state.phase = ParticipantCohortPhase.NonceSent;
@@ -485,7 +501,10 @@ export class AggregationParticipant {
485
501
  );
486
502
  }
487
503
 
488
- const partialSig = state.signingSession.generatePartialSignature(this.keys.secretKey.bytes);
504
+ const signingSession = state.signingSession;
505
+ const partialSig = this.#signer.withSecret(
506
+ secretKey => signingSession.generatePartialSignature(secretKey)
507
+ );
489
508
  state.phase = ParticipantCohortPhase.Complete;
490
509
 
491
510
  return [createSignatureAuthorizationMessage({
@@ -501,4 +520,16 @@ export class AggregationParticipant {
501
520
  public getCohortPhase(cohortId: string): ParticipantCohortPhaseType | undefined {
502
521
  return this.#cohortStates.get(cohortId)?.phase;
503
522
  }
523
+
524
+ /**
525
+ * Zeroize any retained MuSig2 secret nonces across all cohorts. The raw
526
+ * signing key is never held here (it lives behind the {@link AggregationSigner}
527
+ * and is wiped per-operation), but an abandoned signing session can still hold
528
+ * a secret nonce; call this on teardown to clear it deterministically.
529
+ */
530
+ public clearSecrets(): void {
531
+ for(const state of this.#cohortStates.values()) {
532
+ state.signingSession?.clearSecrets();
533
+ }
534
+ }
504
535
  }
@@ -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
  }
@@ -380,6 +383,15 @@ export class AggregationServiceRunner extends TypedEventEmitter<AggregationServi
380
383
  const decision = await this.#onOptInReceived(optIn);
381
384
  if(!decision.accepted) return;
382
385
 
386
+ // Don't accept past the advertised maxParticipants: acceptParticipant
387
+ // would throw COHORT_FULL and fail the run. Silently ignore the surplus
388
+ // opt-in (the cohort is full).
389
+ const maxParticipants = this.#config.maxParticipants;
390
+ const cohortNow = this.session.getCohort(this.#cohortId!);
391
+ if(maxParticipants !== undefined && cohortNow && cohortNow.participants.length >= maxParticipants) {
392
+ return;
393
+ }
394
+
383
395
  await this.#sendAll(this.session.acceptParticipant(this.#cohortId!, msg.from));
384
396
  this.emit('participant-accepted', { participantDid: msg.from });
385
397
 
@@ -1,11 +1,12 @@
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';
8
8
  import { AggregationCohort } from './cohort.js';
9
+ import { validateCohortConditions, type CohortConditions } from './conditions.js';
9
10
  import { AggregationServiceError } from './errors.js';
10
11
  import type { BaseMessage } from './messages/base.js';
11
12
  import { AGGREGATION_WIRE_VERSION } from './messages/base.js';
@@ -28,11 +29,14 @@ import type { ServiceCohortPhaseType } from './phases.js';
28
29
  import { ServiceCohortPhase } from './phases.js';
29
30
  import { BeaconSigningSession } from './signing-session.js';
30
31
 
31
- /** Cohort configuration set by the service operator. */
32
- export interface CohortConfig {
33
- minParticipants: number;
32
+ /**
33
+ * Cohort configuration set by the service operator: the advertised cohort
34
+ * {@link CohortConditions} plus the Bitcoin network. `beaconType` and
35
+ * `minParticipants` are required; the other conditions are optional (absent =
36
+ * unconstrained). See ADR 039.
37
+ */
38
+ export interface CohortConfig extends CohortConditions {
34
39
  network: string;
35
- beaconType: string;
36
40
  }
37
41
 
38
42
  /** Pending opt-in awaiting service operator approval. */
@@ -100,7 +104,13 @@ export const DEFAULT_MAX_UPDATE_SIZE_BYTES = 256 * 1024;
100
104
 
101
105
  export interface AggregationServiceParams {
102
106
  did: string;
103
- keys: SchnorrKeyPair;
107
+ /**
108
+ * The service's compressed communication public key (placed in cohort adverts).
109
+ * The coordinator never signs - it aggregates public nonces and partial
110
+ * signatures - so it is given a public key only, never a secret-bearing
111
+ * keypair (see ADR 038).
112
+ */
113
+ publicKey: CompressedSecp256k1PublicKey;
104
114
  /**
105
115
  * Maximum canonicalized byte-length of a signed update body accepted by the
106
116
  * service. Submissions above this cap are silently dropped and surfaced as
@@ -122,15 +132,15 @@ export interface AggregationServiceParams {
122
132
  */
123
133
  export class AggregationService {
124
134
  readonly did: string;
125
- readonly keys: SchnorrKeyPair;
135
+ readonly publicKey: CompressedSecp256k1PublicKey;
126
136
  readonly maxUpdateSizeBytes: number;
127
137
 
128
138
  /** Per-cohort state, keyed by cohortId. */
129
139
  #cohortStates: Map<string, ServiceCohortState> = new Map();
130
140
 
131
- constructor({ did, keys, maxUpdateSizeBytes }: AggregationServiceParams) {
141
+ constructor({ did, publicKey, maxUpdateSizeBytes }: AggregationServiceParams) {
132
142
  this.did = did;
133
- this.keys = keys;
143
+ this.publicKey = publicKey;
134
144
  this.maxUpdateSizeBytes = maxUpdateSizeBytes ?? DEFAULT_MAX_UPDATE_SIZE_BYTES;
135
145
  }
136
146
 
@@ -195,6 +205,14 @@ export class AggregationService {
195
205
  * Cohort starts in `Created` phase — call `advertise()` to broadcast.
196
206
  */
197
207
  createCohort(config: CohortConfig): string {
208
+ // Fail fast on invalid conditions rather than discovering them at finalize.
209
+ const problems = validateCohortConditions(config);
210
+ if(problems.length > 0) {
211
+ throw new AggregationServiceError(
212
+ `Invalid cohort conditions: ${problems.join('; ')}`,
213
+ 'INVALID_COHORT_CONDITIONS', { problems }
214
+ );
215
+ }
198
216
  const cohort = new AggregationCohort({
199
217
  serviceDid : this.did,
200
218
  minParticipants : config.minParticipants,
@@ -228,13 +246,15 @@ export class AggregationService {
228
246
  );
229
247
  }
230
248
 
249
+ // Advertise the full condition set (flat fields, per ADR 039). network is
250
+ // a separate cohort parameter; everything else in config is a condition.
251
+ const { network, ...conditions } = state.config;
231
252
  const message = createCohortAdvertMessage({
232
253
  from : this.did,
233
254
  cohortId,
234
- cohortSize : state.config.minParticipants,
235
- beaconType : state.config.beaconType,
236
- network : state.config.network,
237
- communicationPk : this.keys.publicKey.compressed,
255
+ network,
256
+ communicationPk : this.publicKey.compressed,
257
+ ...conditions,
238
258
  });
239
259
 
240
260
  state.phase = ServiceCohortPhase.Advertised;
@@ -304,6 +324,15 @@ export class AggregationService {
304
324
  'ALREADY_ACCEPTED', { cohortId, participantDid }
305
325
  );
306
326
  }
327
+ // Enforce the maxParticipants condition: a cohort cannot grow past its
328
+ // advertised ceiling (closes the unbounded-growth path; see ADR 039).
329
+ const maxParticipants = state.config.maxParticipants;
330
+ if(maxParticipants !== undefined && state.acceptedParticipants.size >= maxParticipants) {
331
+ throw new AggregationServiceError(
332
+ `Cohort ${cohortId} is full: ${maxParticipants} participants already accepted.`,
333
+ 'COHORT_FULL', { cohortId, maxParticipants }
334
+ );
335
+ }
307
336
 
308
337
  state.acceptedParticipants.add(participantDid);
309
338
  state.cohort.participants.push(participantDid);
@@ -338,6 +367,15 @@ export class AggregationService {
338
367
  'NOT_ENOUGH_PARTICIPANTS', { cohortId }
339
368
  );
340
369
  }
370
+ // Ceiling defense: acceptParticipant already rejects past max, so reaching
371
+ // here over max means state was mutated out-of-band.
372
+ const maxParticipants = state.config.maxParticipants;
373
+ if(maxParticipants !== undefined && state.acceptedParticipants.size > maxParticipants) {
374
+ throw new AggregationServiceError(
375
+ `Cohort ${cohortId} has ${state.acceptedParticipants.size} accepted participants, exceeds max ${maxParticipants}.`,
376
+ 'TOO_MANY_PARTICIPANTS', { cohortId, maxParticipants }
377
+ );
378
+ }
341
379
 
342
380
  const beaconAddress = state.cohort.computeBeaconAddress();
343
381
  state.phase = ServiceCohortPhase.CohortSet;
@@ -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 {
@@ -5,7 +5,8 @@ import type { MessageHandler, Transport } from './transport.js';
5
5
 
6
6
  /** Internal registration for a single actor sharing an {@link InMemoryTransport}. */
7
7
  interface ActorEntry {
8
- keys: SchnorrKeyPair;
8
+ /** Compressed communication public key. The in-process bus does no encryption, so no secret is retained. */
9
+ publicKey: Uint8Array;
9
10
  handlers: Map<string, MessageHandler>;
10
11
  }
11
12
 
@@ -97,11 +98,14 @@ export class InMemoryTransport implements Transport {
97
98
  }
98
99
 
99
100
  registerActor(did: string, keys: SchnorrKeyPair): void {
100
- this.#actors.set(did, { keys, handlers: new Map() });
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() });
101
105
  }
102
106
 
103
107
  getActorPk(did: string): Uint8Array | undefined {
104
- return this.#actors.get(did)?.keys.publicKey.compressed;
108
+ return this.#actors.get(did)?.publicKey;
105
109
  }
106
110
 
107
111
  /** True if `did` is registered on this transport. Used by the bus for routing. */
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Aggregation
2
2
  export * from './core/aggregation/service.js';
3
3
  export * from './core/aggregation/participant.js';
4
+ export * from './core/aggregation/signer.js';
5
+ export * from './core/aggregation/conditions.js';
4
6
  export * from './core/aggregation/cohort.js';
5
7
  export * from './core/aggregation/signing-session.js';
6
8
  export * from './core/aggregation/phases.js';