Skip to content

Commit 6cf04b0

Browse files
sstonet-bast
andauthored
Add high-level types for musig2 primitives provided by secp256k1-kmp (#107)
* Add wrapper for secp256k1-kmp's musig2 methods * Add high-level helpers for using Musig2 with Taproot When using Musig2 for a taproot key path, we can provide simpler helper functions to collaboratively build a shared signature for the spending transaction. Those helper functions hide the low-level details of using an opaque key aggregation cache or signing session. This comes with a small performance penalty, as we recompute the key aggregation cache. We also document the exposed APIs, import more tests from the official test vectors, and make APIs safe: they should never throw exceptions, except when invalid public keys are provided as inputs (which should be verified by the caller beforehand). --------- Co-authored-by: Bastien Teinturier <[email protected]>
1 parent 44a4e57 commit 6cf04b0

File tree

8 files changed

+732
-710
lines changed

8 files changed

+732
-710
lines changed

build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ plugins {
1212
val currentOs = org.gradle.internal.os.OperatingSystem.current()
1313

1414
group = "fr.acinq.bitcoin"
15-
version = "0.17.0-SNAPSHOT"
15+
version = "0.17.0-MUSIG2-SNAPSHOT"
1616

1717
repositories {
1818
google()
@@ -45,7 +45,7 @@ kotlin {
4545
}
4646

4747
sourceSets {
48-
val secp256k1KmpVersion = "0.13.0"
48+
val secp256k1KmpVersion = "0.14.0"
4949

5050
val commonMain by getting {
5151
dependencies {

src/commonMain/kotlin/fr/acinq/bitcoin/Crypto.kt

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -197,24 +197,6 @@ public object Crypto {
197197
return sig
198198
}
199199

200-
/** Produce a signature that will be included in the witness of a taproot key path spend. */
201-
@JvmStatic
202-
public fun signTaprootKeyPath(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: List<TxOut>, sighashType: Int, scriptTree: ScriptTree?, annex: ByteVector? = null, auxrand32: ByteVector32? = null): ByteVector64 {
203-
val data = Transaction.hashForSigningTaprootKeyPath(tx, inputIndex, inputs, sighashType, annex)
204-
val tweak = when (scriptTree) {
205-
null -> TaprootTweak.NoScriptTweak
206-
else -> TaprootTweak.ScriptTweak(scriptTree.hash())
207-
}
208-
return signSchnorr(data, privateKey, tweak, auxrand32)
209-
}
210-
211-
/** Produce a signature that will be included in the witness of a taproot script path spend. */
212-
@JvmStatic
213-
public fun signTaprootScriptPath(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: List<TxOut>, sighashType: Int, tapleaf: ByteVector32, annex: ByteVector? = null, auxrand32: ByteVector32? = null): ByteVector64 {
214-
val data = Transaction.hashForSigningTaprootScriptPath(tx, inputIndex, inputs, sighashType, tapleaf, annex)
215-
return signSchnorr(data, privateKey, SchnorrTweak.NoTweak, auxrand32)
216-
}
217-
218200
@JvmStatic
219201
public fun verifySignatureSchnorr(data: ByteVector32, signature: ByteVector, publicKey: XonlyPublicKey): Boolean {
220202
return Secp256k1.verifySchnorr(signature.toByteArray(), data.toByteArray(), publicKey.value.toByteArray())

src/commonMain/kotlin/fr/acinq/bitcoin/Transaction.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,53 @@ public data class Transaction(
798798
return hashForSigningSchnorr(tx, inputIndex, inputs, sighashType, SigVersion.SIGVERSION_TAPSCRIPT, tapleaf, annex)
799799
}
800800

801+
/**
802+
* Sign a taproot tx input, using the internal key path.
803+
*
804+
* @param privateKey private key.
805+
* @param tx input transaction.
806+
* @param inputIndex index of the tx input that is being signed.
807+
* @param inputs list of all UTXOs spent by this transaction.
808+
* @param sighashType signature hash type, which will be appended to the signature (if not default).
809+
* @param scriptTree tapscript tree of the signed input, if it has script paths.
810+
* @return the schnorr signature of this tx for this specific tx input.
811+
*/
812+
@JvmStatic
813+
public fun signInputTaprootKeyPath(privateKey: PrivateKey, tx: Transaction, inputIndex: Int, inputs: List<TxOut>, sighashType: Int, scriptTree: ScriptTree?, annex: ByteVector? = null, auxrand32: ByteVector32? = null): ByteVector64 {
814+
val data = hashForSigningTaprootKeyPath(tx, inputIndex, inputs, sighashType, annex)
815+
val tweak = when (scriptTree) {
816+
null -> Crypto.TaprootTweak.NoScriptTweak
817+
else -> Crypto.TaprootTweak.ScriptTweak(scriptTree.hash())
818+
}
819+
return Crypto.signSchnorr(data, privateKey, tweak, auxrand32)
820+
}
821+
822+
/**
823+
* Sign a taproot tx input, using one of its script paths.
824+
*
825+
* @param privateKey private key.
826+
* @param tx input transaction.
827+
* @param inputIndex index of the tx input that is being signed.
828+
* @param inputs list of all UTXOs spent by this transaction.
829+
* @param sighashType signature hash type, which will be appended to the signature (if not default).
830+
* @param tapleaf tapscript leaf hash of the script that is being spent.
831+
* @return the schnorr signature of this tx for this specific tx input and the given script leaf.
832+
*/
833+
@JvmStatic
834+
public fun signInputTaprootScriptPath(
835+
privateKey: PrivateKey,
836+
tx: Transaction,
837+
inputIndex: Int,
838+
inputs: List<TxOut>,
839+
sighashType: Int,
840+
tapleaf: ByteVector32,
841+
annex: ByteVector? = null,
842+
auxrand32: ByteVector32? = null
843+
): ByteVector64 {
844+
val data = hashForSigningTaprootScriptPath(tx, inputIndex, inputs, sighashType, tapleaf, annex)
845+
return Crypto.signSchnorr(data, privateKey, Crypto.SchnorrTweak.NoTweak, auxrand32)
846+
}
847+
801848
@JvmStatic
802849
public fun correctlySpends(tx: Transaction, previousOutputs: Map<OutPoint, TxOut>, scriptFlags: Int) {
803850
val prevouts = tx.txIn.map { previousOutputs[it.outPoint]!! }
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package fr.acinq.bitcoin.crypto.musig2
2+
3+
import fr.acinq.bitcoin.*
4+
import fr.acinq.bitcoin.utils.Either
5+
import fr.acinq.bitcoin.utils.flatMap
6+
import fr.acinq.secp256k1.Hex
7+
import fr.acinq.secp256k1.Secp256k1
8+
import kotlin.jvm.JvmOverloads
9+
import kotlin.jvm.JvmStatic
10+
11+
/**
12+
* Musig2 key aggregation cache: keeps track of an aggregate of public keys, that can optionally be tweaked.
13+
* This should be treated as an opaque blob of data, that doesn't contain any sensitive data and thus can be stored.
14+
*/
15+
public data class KeyAggCache(private val data: ByteVector) {
16+
public constructor(data: ByteArray) : this(data.byteVector())
17+
18+
init {
19+
require(data.size() == Secp256k1.MUSIG2_PUBLIC_KEYAGG_CACHE_SIZE) { "musig2 keyagg cache must be ${Secp256k1.MUSIG2_PUBLIC_KEYAGG_CACHE_SIZE} bytes" }
20+
}
21+
22+
public fun toByteArray(): ByteArray = data.toByteArray()
23+
24+
override fun toString(): String = data.toHex()
25+
26+
/**
27+
* @param tweak tweak to apply.
28+
* @param isXonly true if the tweak is an x-only tweak.
29+
* @return an updated cache and the tweaked aggregated public key, or null if one of the tweaks is invalid.
30+
*/
31+
public fun tweak(tweak: ByteVector32, isXonly: Boolean): Either<Throwable, Pair<KeyAggCache, PublicKey>> = try {
32+
val localCache = toByteArray()
33+
val tweaked = if (isXonly) {
34+
Secp256k1.musigPubkeyXonlyTweakAdd(localCache, tweak.toByteArray())
35+
} else {
36+
Secp256k1.musigPubkeyTweakAdd(localCache, tweak.toByteArray())
37+
}
38+
Either.Right(Pair(KeyAggCache(localCache), PublicKey.parse(tweaked)))
39+
} catch (t: Throwable) {
40+
Either.Left(t)
41+
}
42+
43+
public companion object {
44+
/**
45+
* @param publicKeys public keys to aggregate: callers must verify that all public keys are valid.
46+
* @return an opaque key aggregation cache and the aggregated public key.
47+
*/
48+
@JvmStatic
49+
public fun create(publicKeys: List<PublicKey>): Pair<XonlyPublicKey, KeyAggCache> {
50+
require(publicKeys.all { it.isValid() }) { "some of the public keys provided are not valid" }
51+
val localCache = ByteArray(Secp256k1.MUSIG2_PUBLIC_KEYAGG_CACHE_SIZE)
52+
val aggkey = Secp256k1.musigPubkeyAgg(publicKeys.map { it.value.toByteArray() }.toTypedArray(), localCache)
53+
return Pair(XonlyPublicKey(aggkey.byteVector32()), KeyAggCache(localCache.byteVector()))
54+
}
55+
}
56+
}
57+
58+
/**
59+
* Musig2 signing session context that can be used to create partial signatures and aggregate them.
60+
*/
61+
public data class Session(private val data: ByteVector, private val keyAggCache: KeyAggCache) {
62+
init {
63+
require(data.size() == Secp256k1.MUSIG2_PUBLIC_SESSION_SIZE) { "musig2 session must be ${Secp256k1.MUSIG2_PUBLIC_SESSION_SIZE} bytes" }
64+
}
65+
66+
public fun toByteArray(): ByteArray = data.toByteArray()
67+
68+
/**
69+
* @param secretNonce signer's secret nonce (see [SecretNonce.generate]).
70+
* @param privateKey signer's private key.
71+
* @return a musig2 partial signature.
72+
*/
73+
public fun sign(secretNonce: SecretNonce, privateKey: PrivateKey): ByteVector32 {
74+
return Secp256k1.musigPartialSign(secretNonce.data.toByteArray(), privateKey.value.toByteArray(), keyAggCache.toByteArray(), this.toByteArray()).byteVector32()
75+
}
76+
77+
/**
78+
* @param partialSig musig2 partial signature.
79+
* @param publicNonce individual public nonce of the signing participant.
80+
* @param publicKey individual public key of the signing participant.
81+
* @return true if the partial signature is valid.
82+
*/
83+
public fun verify(partialSig: ByteVector32, publicNonce: IndividualNonce, publicKey: PublicKey): Boolean = try {
84+
Secp256k1.musigPartialSigVerify(partialSig.toByteArray(), publicNonce.toByteArray(), publicKey.value.toByteArray(), keyAggCache.toByteArray(), this.toByteArray()) == 1
85+
} catch (t: Throwable) {
86+
false
87+
}
88+
89+
/**
90+
* Aggregate partial signatures from all participants into a single schnorr signature. Callers should verify the
91+
* resulting signature, which may be invalid without raising an error here (for example if the set of partial
92+
* signatures is valid but incomplete).
93+
*
94+
* @param partialSigs partial signatures from all signing participants.
95+
* @return the aggregate signature of all input partial signatures or null if a partial signature is invalid.
96+
*/
97+
public fun aggregateSigs(partialSigs: List<ByteVector32>): Either<Throwable, ByteVector64> = try {
98+
Either.Right(Secp256k1.musigPartialSigAgg(this.toByteArray(), partialSigs.map { it.toByteArray() }.toTypedArray()).byteVector64())
99+
} catch (t: Throwable) {
100+
Either.Left(t)
101+
}
102+
103+
public companion object {
104+
/**
105+
* @param aggregatedNonce aggregated public nonce.
106+
* @param message message that will be signed.
107+
* @param keyAggCache key aggregation cache.
108+
* @return a musig2 signing session.
109+
*/
110+
@JvmStatic
111+
public fun create(aggregatedNonce: AggregatedNonce, message: ByteVector32, keyAggCache: KeyAggCache): Session {
112+
val session = Secp256k1.musigNonceProcess(aggregatedNonce.toByteArray(), message.toByteArray(), keyAggCache.toByteArray())
113+
return Session(session.byteVector(), keyAggCache)
114+
}
115+
}
116+
}
117+
118+
/**
119+
* Musig2 secret nonce, that should be treated as a private opaque blob.
120+
* This nonce must never be persisted or reused across signing sessions.
121+
*/
122+
public data class SecretNonce(internal val data: ByteVector) {
123+
public constructor(bin: ByteArray) : this(bin.byteVector())
124+
public constructor(hex: String) : this(Hex.decode(hex))
125+
126+
init {
127+
require(data.size() == Secp256k1.MUSIG2_SECRET_NONCE_SIZE) { "musig2 secret nonce must be ${Secp256k1.MUSIG2_SECRET_NONCE_SIZE} bytes" }
128+
}
129+
130+
override fun toString(): String = "<secret_nonce>"
131+
132+
public companion object {
133+
/**
134+
* Generate a secret nonce to be used in a musig2 signing session.
135+
* This nonce must never be persisted or reused across signing sessions.
136+
* All optional arguments exist to enrich the quality of the randomness used, which is critical for security.
137+
*
138+
* @param sessionId unique session ID.
139+
* @param privateKey (optional) signer's private key.
140+
* @param publicKey signer's public key.
141+
* @param message (optional) message that will be signed, if already known.
142+
* @param keyAggCache (optional) key aggregation cache data from the signing session.
143+
* @param extraInput (optional) additional random data.
144+
* @return secret nonce and the corresponding public nonce.
145+
*/
146+
@JvmStatic
147+
public fun generate(sessionId: ByteVector32, privateKey: PrivateKey?, publicKey: PublicKey, message: ByteVector32?, keyAggCache: KeyAggCache?, extraInput: ByteVector32?): Pair<SecretNonce, IndividualNonce> {
148+
privateKey?.let { require(it.publicKey() == publicKey) { "if the private key is provided, it must match the public key" } }
149+
val nonce = Secp256k1.musigNonceGen(sessionId.toByteArray(), privateKey?.value?.toByteArray(), publicKey.value.toByteArray(), message?.toByteArray(), keyAggCache?.toByteArray(), extraInput?.toByteArray())
150+
val secretNonce = SecretNonce(nonce.copyOfRange(0, Secp256k1.MUSIG2_SECRET_NONCE_SIZE))
151+
val publicNonce = IndividualNonce(nonce.copyOfRange(Secp256k1.MUSIG2_SECRET_NONCE_SIZE, Secp256k1.MUSIG2_SECRET_NONCE_SIZE + Secp256k1.MUSIG2_PUBLIC_NONCE_SIZE))
152+
return Pair(secretNonce, publicNonce)
153+
}
154+
}
155+
}
156+
157+
/**
158+
* Musig2 public nonce, that must be shared with other participants in the signing session.
159+
* It contains two elliptic curve points, but should be treated as an opaque blob.
160+
*/
161+
public data class IndividualNonce(val data: ByteVector) {
162+
public constructor(bin: ByteArray) : this(bin.byteVector())
163+
public constructor(hex: String) : this(Hex.decode(hex))
164+
165+
init {
166+
require(data.size() == Secp256k1.MUSIG2_PUBLIC_NONCE_SIZE) { "individual musig2 public nonce must be ${Secp256k1.MUSIG2_PUBLIC_NONCE_SIZE} bytes" }
167+
}
168+
169+
public fun toByteArray(): ByteArray = data.toByteArray()
170+
171+
override fun toString(): String = data.toHex()
172+
173+
public companion object {
174+
/**
175+
* Aggregate public nonces from all participants of a signing session.
176+
* Returns null if one of the nonces provided is invalid.
177+
*/
178+
@JvmStatic
179+
public fun aggregate(nonces: List<IndividualNonce>): Either<Throwable, AggregatedNonce> = try {
180+
val agg = Secp256k1.musigNonceAgg(nonces.map { it.toByteArray() }.toTypedArray())
181+
Either.Right(AggregatedNonce(agg))
182+
} catch (t: Throwable) {
183+
Either.Left(t)
184+
}
185+
}
186+
}
187+
188+
/**
189+
* Musig2 aggregate public nonce from all participants of a signing session.
190+
*/
191+
public data class AggregatedNonce(val data: ByteVector) {
192+
public constructor(bin: ByteArray) : this(bin.byteVector())
193+
public constructor(hex: String) : this(Hex.decode(hex))
194+
195+
init {
196+
require(data.size() == Secp256k1.MUSIG2_PUBLIC_NONCE_SIZE) { "aggregated musig2 public nonce must be ${Secp256k1.MUSIG2_PUBLIC_NONCE_SIZE} bytes" }
197+
}
198+
199+
public fun toByteArray(): ByteArray = data.toByteArray()
200+
201+
override fun toString(): String = data.toHex()
202+
}
203+
204+
/**
205+
* This object contain helper functions to use musig2 in the context of spending taproot outputs.
206+
* In order to provide a simpler API, some operations are internally duplicated: if performance is an issue, you should
207+
* consider using the lower-level APIs directly (see [Session] and [KeyAggCache]).
208+
*/
209+
public object Musig2 {
210+
/**
211+
* Aggregate the public keys of a musig2 session into a single public key.
212+
* Note that this function doesn't apply any tweak: when used for taproot, it computes the internal public key, not
213+
* the public key exposed in the script (which is tweaked with the script tree).
214+
*
215+
* @param publicKeys public keys of all participants: callers must verify that all public keys are valid.
216+
*/
217+
@JvmStatic
218+
public fun aggregateKeys(publicKeys: List<PublicKey>): XonlyPublicKey = KeyAggCache.create(publicKeys).first
219+
220+
/**
221+
* @param sessionId a random, unique session ID.
222+
* @param privateKey signer's private key.
223+
* @param publicKeys public keys of all participants: callers must verify that all public keys are valid.
224+
*/
225+
@JvmStatic
226+
public fun generateNonce(sessionId: ByteVector32, privateKey: PrivateKey, publicKeys: List<PublicKey>): Pair<SecretNonce, IndividualNonce> {
227+
val (_, keyAggCache) = KeyAggCache.create(publicKeys)
228+
return SecretNonce.generate(sessionId, privateKey, privateKey.publicKey(), message = null, keyAggCache, extraInput = null)
229+
}
230+
231+
private fun taprootSession(tx: Transaction, inputIndex: Int, inputs: List<TxOut>, publicKeys: List<PublicKey>, publicNonces: List<IndividualNonce>, scriptTree: ScriptTree?): Either<Throwable, Session> {
232+
return IndividualNonce.aggregate(publicNonces).flatMap { aggregateNonce ->
233+
val (aggregatePublicKey, keyAggCache) = KeyAggCache.create(publicKeys)
234+
val tweak = when (scriptTree) {
235+
null -> aggregatePublicKey.tweak(Crypto.TaprootTweak.NoScriptTweak)
236+
else -> aggregatePublicKey.tweak(Crypto.TaprootTweak.ScriptTweak(scriptTree))
237+
}
238+
keyAggCache.tweak(tweak, isXonly = true).map { tweakedKeyAggCache ->
239+
val txHash = Transaction.hashForSigningTaprootKeyPath(tx, inputIndex, inputs, SigHash.SIGHASH_DEFAULT)
240+
Session.create(aggregateNonce, txHash, tweakedKeyAggCache.first)
241+
}
242+
}
243+
}
244+
245+
/**
246+
* Create a partial musig2 signature for the given taproot input key path.
247+
*
248+
* @param privateKey private key of the signing participant.
249+
* @param tx transaction spending the target taproot input.
250+
* @param inputIndex index of the taproot input to spend.
251+
* @param inputs all inputs of the spending transaction.
252+
* @param publicKeys public keys of all participants of the musig2 session: callers must verify that all public keys are valid.
253+
* @param secretNonce secret nonce of the signing participant.
254+
* @param publicNonces public nonces of all participants of the musig2 session.
255+
* @param scriptTree tapscript tree of the taproot input, if it has script paths.
256+
*/
257+
@JvmStatic
258+
public fun signTaprootInput(
259+
privateKey: PrivateKey,
260+
tx: Transaction,
261+
inputIndex: Int,
262+
inputs: List<TxOut>,
263+
publicKeys: List<PublicKey>,
264+
secretNonce: SecretNonce,
265+
publicNonces: List<IndividualNonce>,
266+
scriptTree: ScriptTree?
267+
): Either<Throwable, ByteVector32> {
268+
return taprootSession(tx, inputIndex, inputs, publicKeys, publicNonces, scriptTree).map { it.sign(secretNonce, privateKey) }
269+
}
270+
271+
/**
272+
* Aggregate partial musig2 signatures into a valid schnorr signature for the given taproot input key path.
273+
*
274+
* @param partialSigs partial musig2 signatures of all participants of the musig2 session.
275+
* @param tx transaction spending the target taproot input.
276+
* @param inputIndex index of the taproot input to spend.
277+
* @param inputs all inputs of the spending transaction.
278+
* @param publicKeys public keys of all participants of the musig2 session: callers must verify that all public keys are valid.
279+
* @param publicNonces public nonces of all participants of the musig2 session.
280+
* @param scriptTree tapscript tree of the taproot input, if it has script paths.
281+
*/
282+
@JvmStatic
283+
public fun aggregateTaprootSignatures(
284+
partialSigs: List<ByteVector32>,
285+
tx: Transaction,
286+
inputIndex: Int,
287+
inputs: List<TxOut>,
288+
publicKeys: List<PublicKey>,
289+
publicNonces: List<IndividualNonce>,
290+
scriptTree: ScriptTree?
291+
): Either<Throwable, ByteVector64> {
292+
return taprootSession(tx, inputIndex, inputs, publicKeys, publicNonces, scriptTree).flatMap { it.aggregateSigs(partialSigs) }
293+
}
294+
295+
}

0 commit comments

Comments
 (0)