Skip to content

Commit fc1ff6b

Browse files
committed
fix(wallets): keep MuSig2 secret nonce inside the signer
Closes #111. The IArkadeWalletSigner / IRemoteSignerTransport contracts used to return MusigPrivNonce from GenerateNonces and require the caller to pass it back into SignMusig. That made the secret nonce cross the signer boundary — for the remote-signer transport that meant the "remote signer" wasn't actually one (secret material round-tripped over the wire), and for in-process signers it encouraged callers to hold a MuSig2 secret in their own data structures where accidental reuse leaks the private key (MuSig2 nonce reuse is catastrophic). Reshape the signer contract so: - GenerateNonces returns MusigPubNonce only. The signer derives the secret nonce internally, stores it indexed by MusigContext.AggregatePubKey (the tweaked aggregate is a content fingerprint of the cosigner set + taproot tweaks), and returns the public half. - SignMusig drops the MusigPrivNonce parameter. The signer looks the secret nonce back up by the same context and consumes it on use. Throws if no matching nonce was generated for this context. - GenerateNonces throws if a nonce is already stored for the same context (calling twice without an intervening SignMusig would orphan secret material; the user is almost certainly making a mistake). NSecWalletSigner / HierarchicalDeterministicWalletSigner / SimpleSeedWallet each hold a per-instance ConcurrentDictionary<string, MusigPrivNonce> keyed by the hex of MusigContext.AggregatePubKey.ToBytes(); SignMusig TryRemoves on consume so the store is self-evicting on the happy path. RemoteArkadeWalletSigner is a pure passthrough, so it just drops the nonce param. IRemoteSignerTransport now documents that long-lived transports need an eviction policy (TTL or bounded count) for abandoned nonces — in-process signers get away with remove-on-consume because their lifetime is the batch session, but a server-side transport could leak unconsumed entries if a client generates a nonce and never signs. TreeSignerSession's _myNonces collapses from Dictionary<uint256, (MusigPrivNonce, MusigPubNonce)> to Dictionary<uint256, MusigPubNonce> — the secret half no longer round-trips through the batch coordinator. docs/articles/wallets.md updates the remote-signer example and adds a section on the MuSig2 nonce lifecycle and eviction responsibility.
1 parent 7477931 commit fc1ff6b

9 files changed

Lines changed: 158 additions & 65 deletions

File tree

NArk.Abstractions/Wallets/IArkadeWalletSigner.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,38 @@
55

66
namespace NArk.Abstractions.Wallets;
77

8+
/// <summary>
9+
/// Wallet-side signer used during MuSig2 batch participation. The MuSig2 nonce flow is
10+
/// designed so the secret half never leaves the signer:
11+
/// <list type="number">
12+
/// <item><description><see cref="GenerateNonces"/> derives the secret nonce internally and returns only the public half.</description></item>
13+
/// <item><description>The signer keeps the secret nonce indexed by <paramref name="context"/>'s aggregate pubkey.</description></item>
14+
/// <item><description><see cref="SignMusig"/> looks the secret nonce back up by the same context, uses it, and consumes it.</description></item>
15+
/// </list>
16+
/// Callers pass the same <see cref="MusigContext"/> instance (or one with an identical
17+
/// <see cref="MusigContext.AggregatePubKey"/>) to both calls. Calling <see cref="SignMusig"/>
18+
/// without a prior matching <see cref="GenerateNonces"/> on the same signer throws.
19+
/// </summary>
820
public interface IArkadeWalletSigner
921
{
1022
/// <summary>
1123
/// Gets the compressed public key for the given descriptor, preserving parity.
1224
/// </summary>
1325
Task<ECPubKey> GetPubKey(OutputDescriptor descriptor, CancellationToken cancellationToken = default);
1426

27+
/// <summary>
28+
/// Produces a MuSig2 partial signature for the given context using the descriptor's
29+
/// private key and the secret nonce generated for the same context by a prior call
30+
/// to <see cref="GenerateNonces"/>. The secret nonce is consumed and cannot be reused
31+
/// for another <see cref="SignMusig"/> call (MuSig2 nonce reuse leaks the private key).
32+
/// </summary>
33+
/// <exception cref="InvalidOperationException">
34+
/// No secret nonce is stored for <paramref name="context"/> — <see cref="GenerateNonces"/>
35+
/// was not called for this context on this signer instance, or the nonce was already consumed.
36+
/// </exception>
1537
Task<MusigPartialSignature> SignMusig(
1638
OutputDescriptor descriptor,
1739
MusigContext context,
18-
MusigPrivNonce nonce,
1940
CancellationToken cancellationToken = default);
2041

2142

@@ -24,7 +45,14 @@ Task<MusigPartialSignature> SignMusig(
2445
uint256 hash,
2546
CancellationToken cancellationToken = default);
2647

27-
Task<MusigPrivNonce> GenerateNonces(
48+
/// <summary>
49+
/// Generates a fresh MuSig2 nonce pair for <paramref name="context"/>, retains the
50+
/// secret half indexed by the context's aggregate pubkey, and returns the public half.
51+
/// Calling twice for the same context (without an intervening <see cref="SignMusig"/>
52+
/// to consume the prior nonce) throws — generating a fresh nonce on top of an unused
53+
/// one would orphan secret material in the signer's store.
54+
/// </summary>
55+
Task<MusigPubNonce> GenerateNonces(
2856
OutputDescriptor descriptor,
2957
MusigContext context,
3058
CancellationToken cancellationToken = default

NArk.Abstractions/Wallets/IRemoteSignerTransport.cs

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,12 @@ namespace NArk.Abstractions.Wallets;
1919
/// watch-only (<see cref="IWalletProvider.GetSignerAsync"/> returns <c>null</c>). Capability is
2020
/// answered by this interface, not by a flag on the wallet record.
2121
/// <para>
22-
/// <b>SECURITY LIMITATION (phase 1):</b> <see cref="GenerateNoncesAsync"/> currently returns
23-
/// the MuSig2 secret nonce to the SDK and <see cref="SignMusigAsync"/> accepts it back across
24-
/// the transport. The cryptographic argument for remote signing — that private material never
25-
/// leaves the signer — does <i>not</i> hold under this interface. The intended phase-2 shape
26-
/// has <see cref="GenerateNoncesAsync"/> returning only the public nonce and
27-
/// <see cref="SignMusigAsync"/> taking no nonce parameter (the signer remembers it indexed by
28-
/// context). Tracked in <see href="https://github.com/arkade-os/dotnet-sdk/issues/111">#111</see>;
29-
/// do not rely on nonce confidentiality of an <see cref="IRemoteSignerTransport"/>
30-
/// implementation until that is fixed.
22+
/// The MuSig2 nonce flow keeps the secret half on the transport side:
23+
/// <see cref="GenerateNoncesAsync"/> returns only the public nonce, the implementation retains
24+
/// the secret half indexed by the context, and <see cref="SignMusigAsync"/> consumes it on use.
25+
/// Implementations need an eviction policy for abandoned nonces (TTL or bounded count) so the
26+
/// store does not grow without bound — the SDK-side <see cref="IArkadeWalletSigner"/> contract
27+
/// requires SignMusig to throw if no matching nonce is found.
3128
/// </para>
3229
/// </remarks>
3330
public interface IRemoteSignerTransport
@@ -53,22 +50,23 @@ Task<ECPubKey> GetPubKeyAsync(
5350
CancellationToken cancellationToken = default);
5451

5552
/// <summary>
56-
/// Produces a MuSig2 partial signature for the given context and nonce
57-
/// using the descriptor's private key.
53+
/// Produces a MuSig2 partial signature for the given context using the descriptor's
54+
/// private key and the secret nonce the transport retained when <see cref="GenerateNoncesAsync"/>
55+
/// was called for the same context. The secret nonce is consumed on this call.
5856
/// </summary>
5957
/// <param name="walletId">The wallet whose key signs.</param>
6058
/// <param name="descriptor">The descriptor identifying the signing key.</param>
61-
/// <param name="context">The MuSig2 context (cosigner set + sighash).</param>
62-
/// <param name="nonce">The secret nonce produced earlier by <see cref="GenerateNoncesAsync"/>.</param>
59+
/// <param name="context">The MuSig2 context (cosigner set + sighash) the nonce was generated for.</param>
6360
/// <param name="cancellationToken">Cancellation token.</param>
64-
// TODO: SECURITY — drop the `nonce` parameter and have the signer dereference
65-
// the private nonce it kept after GenerateNoncesAsync via a context key. Phase-2,
66-
// tracked at https://github.com/arkade-os/dotnet-sdk/issues/111.
61+
/// <exception cref="InvalidOperationException">
62+
/// No secret nonce is stored for <paramref name="walletId"/> + <paramref name="context"/>
63+
/// (it was never generated, was already consumed, or has been evicted by the transport's
64+
/// eviction policy).
65+
/// </exception>
6766
Task<MusigPartialSignature> SignMusigAsync(
6867
string walletId,
6968
OutputDescriptor descriptor,
7069
MusigContext context,
71-
MusigPrivNonce nonce,
7270
CancellationToken cancellationToken = default);
7371

7472
/// <summary>
@@ -87,19 +85,17 @@ Task<MusigPartialSignature> SignMusigAsync(
8785
CancellationToken cancellationToken = default);
8886

8987
/// <summary>
90-
/// Generates the secret nonce for the supplied MuSig2 context. The returned
91-
/// nonce is bound to <paramref name="context"/> and must be passed back
92-
/// into <see cref="SignMusigAsync"/> on the same context.
88+
/// Generates a fresh MuSig2 nonce pair for the supplied context, retains the secret
89+
/// half on the transport side indexed by <paramref name="walletId"/> +
90+
/// <paramref name="context"/>, and returns the public half so the caller can complete
91+
/// nonce aggregation with cosigners. The secret half never crosses the transport
92+
/// boundary — that is the cryptographic claim of remote signing.
9393
/// </summary>
9494
/// <param name="walletId">The wallet whose key contributes the nonce.</param>
9595
/// <param name="descriptor">The descriptor identifying the signing key.</param>
9696
/// <param name="context">The MuSig2 context the nonce is generated for.</param>
9797
/// <param name="cancellationToken">Cancellation token.</param>
98-
// TODO: SECURITY — return MusigPubNonce instead and keep the private nonce on
99-
// the signer indexed by context. Today the SDK receives the secret half, which
100-
// breaks the cryptographic argument for remote signing. Phase-2, tracked at
101-
// https://github.com/arkade-os/dotnet-sdk/issues/111.
102-
Task<MusigPrivNonce> GenerateNoncesAsync(
98+
Task<MusigPubNonce> GenerateNoncesAsync(
10399
string walletId,
104100
OutputDescriptor descriptor,
105101
MusigContext context,

NArk.Core/Batches/TreeSignerSession.cs

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ namespace NArk.Core.Batches;
1616
/// </summary>
1717
public class TreeSignerSession
1818
{
19-
private Dictionary<uint256, (MusigPrivNonce secNonce, MusigPubNonce pubNonce)>? _myNonces;
19+
// Public nonces only — the secret half stays inside the signer and is consumed
20+
// when SignMusig is called for the same MusigContext.
21+
private Dictionary<uint256, MusigPubNonce>? _myNonces;
2022
private Dictionary<uint256, MusigContext>? _musigContexts;
2123
private readonly string _walletId;
2224
private readonly IWalletProvider _walletProvider;
@@ -113,7 +115,7 @@ public async Task<Dictionary<uint256, MusigPubNonce>> GetNoncesAsync(Cancellatio
113115
{
114116
_myNonces ??= await GenerateNoncesAsync(cancellationToken);
115117

116-
return _myNonces.ToDictionary(pair => pair.Key, pair => pair.Value.pubNonce);
118+
return new Dictionary<uint256, MusigPubNonce>(_myNonces);
117119
}
118120

119121
public void VerifyAggregatedNonces(Dictionary<uint256, MusigPubNonce> expectedAggregateNonces,
@@ -150,7 +152,7 @@ public async Task<Dictionary<uint256, MusigPartialSignature>> SignAsync(Cancella
150152
return sigs;
151153
}
152154

153-
private async Task<Dictionary<uint256, (MusigPrivNonce secNonce, MusigPubNonce pubNonce)>> GenerateNoncesAsync(CancellationToken cancellationToken = default)
155+
private async Task<Dictionary<uint256, MusigPubNonce>> GenerateNoncesAsync(CancellationToken cancellationToken = default)
154156
{
155157
if (_musigContexts == null)
156158
await CreateMusigContexts(cancellationToken);
@@ -163,12 +165,12 @@ public async Task<Dictionary<uint256, MusigPartialSignature>> SignAsync(Cancella
163165
?? throw new InvalidOperationException(
164166
$"Wallet '{_walletId}' has no signer; watch-only wallets cannot participate in batch signing.");
165167

166-
var res = new Dictionary<uint256, (MusigPrivNonce secNonce, MusigPubNonce pubNonce)>();
168+
var res = new Dictionary<uint256, MusigPubNonce>();
167169
foreach (var (txid, musigContext) in _musigContexts!)
168170
{
169-
// Generate nonce tied to this specific context
170-
var nonce = await signer.GenerateNonces(_descriptor, musigContext, cancellationToken);
171-
res[txid] = (nonce, nonce.CreatePubNonce());
171+
// Signer stores the secret half indexed by musigContext.AggregatePubKey;
172+
// SignPartialAsync passes the same context back so the signer can look it up.
173+
res[txid] = await signer.GenerateNonces(_descriptor, musigContext, cancellationToken);
172174
}
173175

174176
return res;
@@ -182,8 +184,8 @@ private async Task<MusigPartialSignature> SignPartialAsync(TxTree g, Cancellatio
182184

183185
var txid = g.Root.GetGlobalTransaction().GetHash();
184186

185-
if (!_myNonces.TryGetValue(txid, out var myNonce))
186-
throw new InvalidOperationException("missing private nonce");
187+
if (!_myNonces.ContainsKey(txid))
188+
throw new InvalidOperationException("missing nonce for this txid — GetNoncesAsync must run first");
187189

188190
if (!_musigContexts.TryGetValue(txid, out var musigContext))
189191
throw new InvalidOperationException("missing musig context");
@@ -196,9 +198,9 @@ private async Task<MusigPartialSignature> SignPartialAsync(TxTree g, Cancellatio
196198
?? throw new InvalidOperationException(
197199
$"Wallet '{_walletId}' has no signer; watch-only wallets cannot participate in batch signing.");
198200

199-
// Use the wallet signer to create a MUSIG2 partial signature
200-
// The context already has the correct sighash from nonce generation
201-
var partialSig = await signer.SignMusig(_descriptor, musigContext, myNonce.secNonce, cancellationToken);
201+
// Signer looks up the secret nonce internally by musigContext.AggregatePubKey
202+
// (set when GenerateNonces was called for the same context) and consumes it.
203+
var partialSig = await signer.SignMusig(_descriptor, musigContext, cancellationToken);
202204

203205
return partialSig;
204206
}
@@ -258,10 +260,10 @@ public Task AggregateNonces(uint256 txid, MusigPubNonce[] nonces, CancellationTo
258260
if (!_musigContexts.TryGetValue(txid, out var musigContext))
259261
return Task.CompletedTask;
260262

261-
if (_myNonces is null || !_myNonces.TryGetValue(txid, out var myNonce))
262-
throw new InvalidOperationException("missing private nonce");
263+
if (_myNonces is null || !_myNonces.TryGetValue(txid, out var myPubNonce))
264+
throw new InvalidOperationException("missing my public nonce");
263265

264-
if (!nonces.Any(nonce => nonce.ToBytes().SequenceEqual(myNonce.pubNonce.ToBytes())))
266+
if (!nonces.Any(nonce => nonce.ToBytes().SequenceEqual(myPubNonce.ToBytes())))
265267
{
266268
throw new InvalidOperationException("missing my nonce");
267269
}

NArk.Core/Wallet/HierarchicalDeterministicWalletSigner.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public class HierarchicalDeterministicWalletSigner(ArkWalletInfo wallet) : IArka
2121
// wallet object, so this introduces no new exposure surface.
2222
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ExtKey> _extKeyCache = new();
2323

24+
// Per-instance secret nonce store; see NSecWalletSigner for the rationale.
25+
// Keyed by the (tweaked) aggregate pubkey hex of the MuSig2 context.
26+
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, MusigPrivNonce> _secNonces = new();
27+
2428
private Task<ECPrivKey> DerivePrivateKey(OutputDescriptor descriptor)
2529
{
2630
var fullPath = descriptor.Extract().FullPath ?? throw new InvalidOperationException();
@@ -36,9 +40,14 @@ public async Task<ECPubKey> GetPubKey(OutputDescriptor descriptor, CancellationT
3640
return privKey.CreatePubKey();
3741
}
3842

39-
public async Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor, MusigContext context, MusigPrivNonce nonce,
43+
public async Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor, MusigContext context,
4044
CancellationToken cancellationToken = default)
4145
{
46+
var key = ContextKey(context);
47+
if (!_secNonces.TryRemove(key, out var nonce))
48+
throw new InvalidOperationException(
49+
$"No secret nonce stored for the given MuSig2 context (aggregate pubkey {key}). " +
50+
"Call GenerateNonces for this context first; nonces are consumed on use and cannot be replayed.");
4251
var privKey = await DerivePrivateKey(descriptor);
4352
return context.Sign(privKey, nonce);
4453
}
@@ -49,9 +58,19 @@ public async Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor,
4958
return (privKey.CreateXOnlyPubKey(), privKey.SignBIP340(hash.ToBytes()));
5059
}
5160

52-
public async Task<MusigPrivNonce> GenerateNonces(OutputDescriptor descriptor, MusigContext context, CancellationToken cancellationToken = default)
61+
public async Task<MusigPubNonce> GenerateNonces(OutputDescriptor descriptor, MusigContext context, CancellationToken cancellationToken = default)
5362
{
5463
var privKey = await DerivePrivateKey(descriptor);
55-
return context.GenerateNonce(privKey);
64+
var nonce = context.GenerateNonce(privKey);
65+
var key = ContextKey(context);
66+
if (!_secNonces.TryAdd(key, nonce))
67+
throw new InvalidOperationException(
68+
$"A secret nonce is already stored for this MuSig2 context (aggregate pubkey {key}). " +
69+
"Call SignMusig to consume it before generating a fresh nonce for the same context; " +
70+
"MuSig2 nonce reuse leaks the private key.");
71+
return nonce.CreatePubNonce();
5672
}
73+
74+
private static string ContextKey(MusigContext context) =>
75+
Convert.ToHexString(context.AggregatePubKey.ToBytes()).ToLowerInvariant();
5776
}

NArk.Core/Wallet/NSecWalletSigner.cs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using Microsoft.Extensions.Logging;
23
using NArk.Abstractions.Extensions;
34
using NArk.Abstractions.Wallets;
@@ -14,6 +15,12 @@ public class NSecWalletSigner(ECPrivKey privateKey, ILogger? logger = null) : IA
1415
private readonly ECPubKey _publicKey = privateKey.CreatePubKey();
1516
private readonly ECXOnlyPubKey _xOnlyPubKey = privateKey.CreateXOnlyPubKey();
1617

18+
// Per-context secret nonce store. Keyed by the (tweaked) aggregate pubkey hex —
19+
// a content fingerprint of the cosigner set + taproot tweaks. ConcurrentDictionary
20+
// lets GenerateNonces / SignMusig race safely if a caller parallelises across
21+
// contexts; each entry is removed on SignMusig consumption so the store doesn't grow.
22+
private readonly ConcurrentDictionary<string, MusigPrivNonce> _secNonces = new();
23+
1724
public static NSecWalletSigner FromNsec(string nsec, ILogger? logger = null)
1825
{
1926
var encoder2 = Bech32Encoder.ExtractEncoderFromString(nsec);
@@ -56,9 +63,15 @@ public Task<ECPubKey> GetPubKey(OutputDescriptor descriptor, CancellationToken c
5663
return Task.FromResult(_publicKey);
5764
}
5865

59-
public Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor, MusigContext context, MusigPrivNonce nonce,
66+
public Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor, MusigContext context,
6067
CancellationToken cancellationToken = default)
6168
{
69+
var key = ContextKey(context);
70+
if (!_secNonces.TryRemove(key, out var nonce))
71+
throw new InvalidOperationException(
72+
$"No secret nonce stored for the given MuSig2 context (aggregate pubkey {key}). " +
73+
"Call GenerateNonces for this context first; nonces are consumed on use and cannot be replayed.");
74+
6275
logger?.LogInformation(
6376
"SignMusig called. Descriptor={Descriptor}, SignerCompressed={SignerCompressed}",
6477
descriptor.ToString(),
@@ -105,14 +118,24 @@ public Task<MusigPartialSignature> SignMusig(OutputDescriptor descriptor, MusigC
105118
return Task.FromResult((_xOnlyPubKey, sig));
106119
}
107120

108-
public Task<MusigPrivNonce> GenerateNonces(OutputDescriptor descriptor, MusigContext context, CancellationToken cancellationToken = default)
121+
public Task<MusigPubNonce> GenerateNonces(OutputDescriptor descriptor, MusigContext context, CancellationToken cancellationToken = default)
109122
{
123+
var key = ContextKey(context);
110124
logger?.LogInformation(
111-
"GenerateNonces called. Descriptor={Descriptor}, SignerCompressed={SignerCompressed}",
125+
"GenerateNonces called. Descriptor={Descriptor}, SignerCompressed={SignerCompressed}, ContextKey={ContextKey}",
112126
descriptor.ToString(),
113-
Convert.ToHexString(_publicKey.ToBytes()).ToLowerInvariant());
127+
Convert.ToHexString(_publicKey.ToBytes()).ToLowerInvariant(),
128+
key);
114129
var nonce = context.GenerateNonce(privateKey);
130+
if (!_secNonces.TryAdd(key, nonce))
131+
throw new InvalidOperationException(
132+
$"A secret nonce is already stored for this MuSig2 context (aggregate pubkey {key}). " +
133+
"Call SignMusig to consume it before generating a fresh nonce for the same context; " +
134+
"MuSig2 nonce reuse leaks the private key.");
115135
logger?.LogInformation("GenerateNonces produced nonce successfully");
116-
return Task.FromResult(nonce);
136+
return Task.FromResult(nonce.CreatePubNonce());
117137
}
138+
139+
private static string ContextKey(MusigContext context) =>
140+
Convert.ToHexString(context.AggregatePubKey.ToBytes()).ToLowerInvariant();
118141
}

0 commit comments

Comments
 (0)