Skip to content

Commit d643a3b

Browse files
committed
refactor(swaps): bind preimage scheme to (tag || descriptor || index)
Two small but meaningful changes to the v1 derivation message: - Descriptor folded into the signed message in addition to the tag. Domain separation now defends against accidental signature reuse even within this codebase (a future feature that signs a descriptor-derived hash for some unrelated proof can't repurpose the same signature). The descriptor is also what would scope the preimage if the tag ever got reused outside our control. - u32 little-endian index baked into the message (always 0 today). Forward-compatibility for multi-preimage-per-descriptor flows without a scheme bump: when a caller wants two preimages from one descriptor, they pass index=1 and recovery iterates 0..N. So the v1 scheme is now: message = SHA-256( "NArk-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) ) sig = BIP-340-Schnorr(descriptor_key, message, aux_rand=null) preimage = SHA-256(sig) DerivePreimageAsync gains an explicit `uint index` parameter; all four call sites (three Initiate*Swap + the RestoreSwaps recovery branch) pass index=0. The restore path leaves a comment noting that a future multi-preimage variant should iterate index=0..MAX here. Also documents the determinism requirement on IRemoteSignerTransport.SignAsync: implementations MUST sign with aux_rand=null for remote-signed wallets to recover outstanding preimages from seed. The contract was implicit before — implementations that randomise (hardware wallet side-channel hardening) would silently break recovery; now it's explicit in XML doc. docs/articles/swaps.md updated to describe the bundled message construction and the remote-signer caveat.
1 parent f00b948 commit d643a3b

3 files changed

Lines changed: 60 additions & 16 deletions

File tree

NArk.Abstractions/Wallets/IRemoteSignerTransport.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ Task<MusigPartialSignature> SignMusigAsync(
7878
/// the descriptor's private key, returning the x-only pubkey alongside the
7979
/// signature.
8080
/// </summary>
81+
/// <remarks>
82+
/// Implementations <b>SHOULD</b> sign with <c>aux_rand</c> set to null (or all-zero) so the
83+
/// signature is deterministic per <c>(key, hash)</c>. The SDK relies on this for the
84+
/// swap-preimage recovery scheme in <c>SwapsManagementService.DerivePreimageAsync</c>:
85+
/// same descriptor + same wallet must produce the same signature, otherwise a restored
86+
/// wallet that rediscovers an outstanding swap will re-derive a different preimage and
87+
/// fail to claim the VHTLC. Implementations that randomise <c>aux_rand</c> (e.g. for
88+
/// side-channel resistance on a hardware signer) break that contract, and remote-signed
89+
/// wallets on such transports will not recover outstanding swap preimages from seed.
90+
/// </remarks>
8191
/// <param name="walletId">The wallet whose key signs.</param>
8292
/// <param name="descriptor">The descriptor identifying the signing key.</param>
8393
/// <param name="hash">The 32-byte hash to sign.</param>

NArk.Swaps/Services/SwapsManagementService.cs

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,21 +103,42 @@ public ISwapProvider ResolveProvider(SwapRoute route, string? preferredProviderI
103103
public IReadOnlyList<ISwapProvider> Providers => _providers;
104104

105105
// BIP-340 sign+hash gives us a deterministic preimage rooted in the wallet's secret
106-
// material without leaking the key (signatures reveal nothing about the key). Same
107-
// descriptor + same wallet → same signature → same preimage, so a restored wallet that
108-
// rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive the preimage
109-
// and claim the VHTLC. Watch-only and remote-signer-less wallets fall through to a
110-
// random preimage (recovery gap, but the swap still works at create time).
111-
private static readonly uint256 PreimageSignTagHash =
112-
new uint256(SHA256.HashData(Encoding.UTF8.GetBytes("NArk-Boltz-Preimage-v1")));
106+
// material without leaking the key (signatures reveal nothing about the key). The signed
107+
// message bundles:
108+
// tag: domain-separates this signature from any other use of the signing key
109+
// (versioned so a future scheme bump can coexist on recovery)
110+
// descriptor: scopes the preimage to the specific swap descriptor
111+
// index: lets the caller derive multiple preimages from the same descriptor;
112+
// always 0 today, but baked into v1 so recovery iteration is forward-
113+
// compatible without a scheme bump
114+
// Same (wallet, descriptor, index) → same signature → same preimage, so a restored
115+
// wallet that rediscovers an outstanding swap via Boltz /v2/swap/restore can re-derive
116+
// the preimage and claim the VHTLC. Watch-only and remote-signer-less wallets fall
117+
// through to a random preimage.
118+
//
119+
// Local signing sources pass aux_rand=null to BIP-340, so signatures are deterministic.
120+
// Remote-signer transports MUST honour the same convention or the preimage will rotate
121+
// per call and recovery will silently fail — see IRemoteSignerTransport.SignAsync.
122+
private const string PreimageTag = "NArk-Boltz-Preimage-v1";
123+
private static readonly byte[] PreimageTagBytes = Encoding.UTF8.GetBytes(PreimageTag);
113124

114125
private async Task<byte[]> DerivePreimageAsync(
115-
string walletId, OutputDescriptor descriptor, CancellationToken cancellationToken)
126+
string walletId, OutputDescriptor descriptor, uint index, CancellationToken cancellationToken)
116127
{
117128
var signer = await _walletProvider.GetSignerAsync(walletId, cancellationToken);
118129
if (signer is null)
119130
return RandomUtils.GetBytes(32); // watch-only — no entropy floor to draw from
120-
var (_, sig) = await signer.Sign(descriptor, PreimageSignTagHash, cancellationToken);
131+
132+
var descriptorBytes = Encoding.UTF8.GetBytes(descriptor.ToString());
133+
var indexBytes = BitConverter.GetBytes(index);
134+
if (!BitConverter.IsLittleEndian) Array.Reverse(indexBytes); // canonical u32 LE
135+
var message = new byte[PreimageTagBytes.Length + descriptorBytes.Length + indexBytes.Length];
136+
Buffer.BlockCopy(PreimageTagBytes, 0, message, 0, PreimageTagBytes.Length);
137+
Buffer.BlockCopy(descriptorBytes, 0, message, PreimageTagBytes.Length, descriptorBytes.Length);
138+
Buffer.BlockCopy(indexBytes, 0, message, PreimageTagBytes.Length + descriptorBytes.Length, indexBytes.Length);
139+
var messageHash = new uint256(SHA256.HashData(message));
140+
141+
var (_, sig) = await signer.Sign(descriptor, messageHash, cancellationToken);
121142
return SHA256.HashData(sig.ToBytes());
122143
}
123144

@@ -294,7 +315,7 @@ public async Task<string> InitiateReverseSwap(string walletId, CreateInvoicePara
294315
walletId, invoiceParams.Amount);
295316
var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken);
296317
var destinationDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken);
297-
var preimage = await DerivePreimageAsync(walletId, destinationDescriptor, cancellationToken);
318+
var preimage = await DerivePreimageAsync(walletId, destinationDescriptor, index: 0, cancellationToken);
298319
var revSwap =
299320
await boltz.BoltzService.CreateReverseSwap(
300321
invoiceParams,
@@ -349,7 +370,7 @@ await _swapsStorage.SaveSwap(
349370

350371
var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken);
351372
var claimDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken);
352-
var preimage = await DerivePreimageAsync(walletId, claimDescriptor, cancellationToken);
373+
var preimage = await DerivePreimageAsync(walletId, claimDescriptor, index: 0, cancellationToken);
353374

354375
var result = await boltz.BoltzService.CreateBtcToArkSwapAsync(
355376
amountSats, claimDescriptor, preimage, cancellationToken);
@@ -417,7 +438,7 @@ public async Task<string> InitiateArkToBtcChainSwap(
417438

418439
var addressProvider = await _walletProvider.GetAddressProviderAsync(walletId, cancellationToken);
419440
var refundDescriptor = await addressProvider!.GetNextSigningDescriptor(cancellationToken);
420-
var preimage = await DerivePreimageAsync(walletId, refundDescriptor, cancellationToken);
441+
var preimage = await DerivePreimageAsync(walletId, refundDescriptor, index: 0, cancellationToken);
421442

422443
// Extract pub key hex for Boltz API
423444
var extractedRefund = OutputDescriptorHelpers.Extract(refundDescriptor);
@@ -653,7 +674,10 @@ await _contractService.ImportContract(
653674
// before it expires. Submarine swaps don't apply — Boltz controls that preimage.
654675
if (restored.IsReverseSwap && !string.IsNullOrEmpty(restored.PreimageHash))
655676
{
656-
var derived = await DerivePreimageAsync(walletId, contract.Receiver, cancellationToken);
677+
// index=0 is the only value used at create-time today. If a future scheme
678+
// bump ships multi-preimage-per-descriptor, recovery should iterate
679+
// 0..MAX_INDEX here looking for a hash match.
680+
var derived = await DerivePreimageAsync(walletId, contract.Receiver, index: 0, cancellationToken);
657681
var derivedHashHex = Convert.ToHexString(Hashes.SHA256(derived)).ToLowerInvariant();
658682
if (string.Equals(derivedHashHex, restored.PreimageHash, StringComparison.OrdinalIgnoreCase))
659683
{

docs/articles/swaps.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,26 @@ Typical states recorded against each `ArkSwap`:
107107

108108
### Deterministic preimages (for recoverable claims)
109109

110-
The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme is:
110+
The preimages we generate for reverse and chain swaps are derived deterministically from the wallet's signing material so that a restored wallet can re-derive them and claim outstanding VHTLCs. The scheme:
111111

112112
```
113-
preimage = SHA-256( BIP-340-Schnorr( descriptor_key, SHA-256("NArk-Boltz-Preimage-v1") ) )
113+
message = SHA-256( "NArk-Boltz-Preimage-v1" || descriptor.ToString() || u32_le(index) )
114+
sig = BIP-340-Schnorr( descriptor_key, message, aux_rand=null )
115+
preimage = SHA-256( sig )
114116
```
115117

116-
— signing the constant tag-hash with the receiver descriptor's key. BIP-340 with `aux_rand=null` is deterministic, so the same descriptor + same wallet always produces the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong descriptor) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback.
118+
The signed input bundles three things:
119+
120+
- **Tag** — domain-separates this signature from any other use of the descriptor's key. Versioned (`-v1`) so a future scheme bump can ship as `-v2` while recovery still tries v1 for older swaps.
121+
- **Descriptor** — scopes the preimage to the specific swap descriptor.
122+
- **Index** — lets a caller derive multiple preimages from the same descriptor. Always `0` today; baked into v1 so recovery iteration is forward-compatible without a scheme bump.
123+
124+
BIP-340 with `aux_rand=null` is deterministic per `(key, message)`, so same `(wallet, descriptor, index)` always yields the same preimage. Recovery: when `RestoreSwaps` rediscovers a reverse swap, it re-derives the candidate preimage with `index=0`, verifies `SHA-256(candidate) == restored.PreimageHash`, and attaches it to the swap's metadata for the sweeper to claim. Hash mismatch (legacy random preimage, or wrong descriptor) leaves the preimage out; `EnrichReverseSwapPreimage` remains the manual fallback.
117125

118126
Watch-only wallets (no signer) fall back to a random preimage on create — they don't get the recovery story but they can still execute swaps until they pair a signer.
119127

128+
> **Remote signers and determinism.** `IRemoteSignerTransport.SignAsync` MUST use `aux_rand=null` for the recovery scheme to work end-to-end on remote-signed wallets. Implementations that randomise `aux_rand` (e.g. hardware-wallet side-channel hardening) will produce a different signature each call → different preimage → recovery silently fails. See the XML doc on `SignAsync`. Local sources (`Bip39SigningSource`, `NsecSigningSource`) already satisfy this.
129+
120130
## Chain Swap Recovery (Renegotiation + Cooperative Refund)
121131

122132
Chain swaps can fail to settle when the user funds the lockup with an amount that doesn't match Boltz's original quote, when an LN invoice times out, or when the swap window expires. The SDK handles these cases automatically inside the routine status-poll loop in `BoltzSwapProvider.PollSwapState`:

0 commit comments

Comments
 (0)