@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/browser.js +335 -167
- package/dist/browser.mjs +335 -167
- package/dist/cjs/index.js +275 -114
- package/dist/esm/core/aggregation/cohort.js +8 -2
- package/dist/esm/core/aggregation/cohort.js.map +1 -1
- package/dist/esm/core/aggregation/conditions.js +75 -0
- package/dist/esm/core/aggregation/conditions.js.map +1 -0
- package/dist/esm/core/aggregation/messages/base.js.map +1 -1
- package/dist/esm/core/aggregation/messages/bodies.js +16 -2
- package/dist/esm/core/aggregation/messages/bodies.js.map +1 -1
- package/dist/esm/core/aggregation/messages/factories.js.map +1 -1
- package/dist/esm/core/aggregation/participant.js +34 -15
- package/dist/esm/core/aggregation/participant.js.map +1 -1
- package/dist/esm/core/aggregation/runner/participant-runner.js +9 -1
- package/dist/esm/core/aggregation/runner/participant-runner.js.map +1 -1
- package/dist/esm/core/aggregation/runner/service-runner.js +12 -1
- package/dist/esm/core/aggregation/runner/service-runner.js.map +1 -1
- package/dist/esm/core/aggregation/service.js +27 -7
- package/dist/esm/core/aggregation/service.js.map +1 -1
- package/dist/esm/core/aggregation/signer.js +33 -0
- package/dist/esm/core/aggregation/signer.js.map +1 -0
- package/dist/esm/core/aggregation/signing-session.js +34 -11
- package/dist/esm/core/aggregation/signing-session.js.map +1 -1
- package/dist/esm/core/aggregation/transport/in-memory.js +5 -2
- package/dist/esm/core/aggregation/transport/in-memory.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/types/core/aggregation/cohort.d.ts.map +1 -1
- package/dist/types/core/aggregation/conditions.d.ts +58 -0
- package/dist/types/core/aggregation/conditions.d.ts.map +1 -0
- package/dist/types/core/aggregation/messages/base.d.ts +2 -3
- package/dist/types/core/aggregation/messages/base.d.ts.map +1 -1
- package/dist/types/core/aggregation/messages/bodies.d.ts +2 -3
- package/dist/types/core/aggregation/messages/bodies.d.ts.map +1 -1
- package/dist/types/core/aggregation/messages/factories.d.ts +2 -3
- package/dist/types/core/aggregation/messages/factories.d.ts.map +1 -1
- package/dist/types/core/aggregation/participant.d.ts +24 -8
- package/dist/types/core/aggregation/participant.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/participant-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/runner/service-runner.d.ts.map +1 -1
- package/dist/types/core/aggregation/service.d.ts +18 -8
- package/dist/types/core/aggregation/service.d.ts.map +1 -1
- package/dist/types/core/aggregation/signer.d.ts +50 -0
- package/dist/types/core/aggregation/signer.d.ts.map +1 -0
- package/dist/types/core/aggregation/signing-session.d.ts +13 -5
- package/dist/types/core/aggregation/signing-session.d.ts.map +1 -1
- package/dist/types/core/aggregation/transport/in-memory.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/aggregation/cohort.ts +8 -2
- package/src/core/aggregation/conditions.ts +116 -0
- package/src/core/aggregation/messages/base.ts +6 -3
- package/src/core/aggregation/messages/bodies.ts +18 -6
- package/src/core/aggregation/messages/factories.ts +2 -3
- package/src/core/aggregation/participant.ts +54 -23
- package/src/core/aggregation/runner/participant-runner.ts +9 -1
- package/src/core/aggregation/runner/service-runner.ts +13 -1
- package/src/core/aggregation/service.ts +51 -13
- package/src/core/aggregation/signer.ts +67 -0
- package/src/core/aggregation/signing-session.ts +34 -11
- package/src/core/aggregation/transport/in-memory.ts +7 -3
- 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
|
-
/**
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
116
|
+
constructor({ did, signer }: AggregationParticipantParams) {
|
|
106
117
|
this.did = did;
|
|
107
|
-
this
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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!.
|
|
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.
|
|
209
|
-
communicationPk : this.
|
|
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.
|
|
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 =
|
|
439
|
-
this.
|
|
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
|
|
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({
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
141
|
+
constructor({ did, publicKey, maxUpdateSizeBytes }: AggregationServiceParams) {
|
|
132
142
|
this.did = did;
|
|
133
|
-
this.
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
|
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
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)?.
|
|
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';
|