diff --git a/DotNut.Tests/Integration.cs b/DotNut.Tests/Integration.cs index fa7f4b2..ca07267 100644 --- a/DotNut.Tests/Integration.cs +++ b/DotNut.Tests/Integration.cs @@ -3,6 +3,8 @@ using DotNut.Abstractions.Websockets; using DotNut.Api; using DotNut.ApiModels; +using DotNut.NBitcoin.BIP39; +using DotNut.NUT13; namespace DotNut.Tests; @@ -433,6 +435,73 @@ await wallet.Swap().FromInputs(proofs).ProcessAsync() Assert.NotEmpty(swappedProofs); } + [Fact] + public async Task MintsBolt12WithDeterministicQuoteKey() + { + var wallet = Wallet.Create().WithMint(MintUrl).WithMnemonic(seed).WithCounter(counter); + + var derivationCounter = wallet.GetDerivationCounter()!; + var before = await derivationCounter.GetCounter(DerivationPurpose.MintQuoteLock); + + // No pubkey and no SignWithPrivkey — both come from the seed. + var mintQuote = await wallet + .CreateMintQuote() + .WithDeterministicPubkey() + .WithUnit("sat") + .WithAmount(1337) + .ProcessAsyncBolt12(); + + Assert.Equal( + new Mnemonic(seed).DeriveMintQuotePrivkey(before).Key.CreatePubKey().ToHex(), + mintQuote.GetQuote().Pubkey + ); + Assert.Equal( + before + 1, + await derivationCounter.GetCounter(DerivationPurpose.MintQuoteLock) + ); + + await PayInvoice(); + var proofs = await mintQuote.Mint(); + + Assert.Equal(1337UL, Utils.SumProofs(proofs)); + } + + [Fact] + public async Task SwapDeterministicP2Pk() + { + // Shares the counter with the other deterministic tests, otherwise restarting it from + // zero would re-derive secrets they already minted with this seed. + var wallet = Wallet.Create().WithMint(MintUrl).WithMnemonic(seed).WithCounter(counter); + + var derivationCounter = wallet.GetDerivationCounter()!; + var before = await derivationCounter.GetCounter(DerivationPurpose.P2Pk); + + var mintHandler = await wallet + .CreateMintQuote() + .WithAmount(1337) + .WithDeterministicP2PkLock() + .ProcessAsyncBolt11(); + + await PayInvoice(); + var proofs = await mintHandler.Mint(); + + // The lock key is the one at the counter we started from, and it moved past it. + var derived = new Mnemonic(seed).DeriveP2PkPrivkey(before); + Assert.Equal(before + 1, await derivationCounter.GetCounter(DerivationPurpose.P2Pk)); + + await Assert.ThrowsAsync(async () => + await wallet.Swap().FromInputs(proofs).ProcessAsync() + ); + + var swappedProofs = await wallet + .Swap() + .FromInputs(proofs) + .WithPrivkeys([derived]) + .ProcessAsync(); + + Assert.NotEmpty(swappedProofs); + } + [Fact] public async Task MintMeltP2PkMultisig() { diff --git a/DotNut.Tests/Unit/Nut13Tests.cs b/DotNut.Tests/Unit/Nut13Tests.cs index 9754756..ba75f0b 100644 --- a/DotNut.Tests/Unit/Nut13Tests.cs +++ b/DotNut.Tests/Unit/Nut13Tests.cs @@ -168,4 +168,41 @@ public void Nut13HMACTests() Convert.ToHexString(mnemonic.DeriveBlindingFactor(keysetId, 4)).ToLowerInvariant() ); } + + [Fact] + public void Nut13P2PkTests() + { + // Test vectors from tests/13-tests.md, "P2PK Derivation" (m/129373'/10'/0'/0'/{counter}) + var mnemonic = new Mnemonic( + "half depart obvious quality work element tank gorilla view sugar picture humble" + ); + string[] keys = + [ + "021693d45f4fdf610ae641fedb0944fb460fbb8264f21c19d2626c3da755fcbbcb", + "0395461ab678058c0ed6aa39f38dda490eaa163e9ad27070b23ec3d06b41e07535", + "02a05e4e593a633e9b4405f01c9632c8afde24cb613017a1aee56fd76291ad26d1", + "033addea25c3873b93d67d536c61c9d9c993f6efd8b9dfa657951b66b5001e51dd", + "03c964bdf42fc82b6c574615746eeca37527a24f1fdfc1b34a732c53843b5744a5", + ]; + for (var i = 0u; i < (uint)keys.Length; i++) + { + var privkey = mnemonic.DeriveP2PkPrivkey(i); + Assert.Equal(new PubKey(keys[i]), (PubKey)privkey.Key.CreatePubKey()); + } + } + + [Fact] + public void Nut13P2PkRejectsHardenedCounter() + { + var mnemonic = new Mnemonic( + "half depart obvious quality work element tank gorilla view sugar picture humble" + ); + + // The last child index is non-hardened, so 2^31 - 1 is the largest valid counter. + // Without the guard 2^31 silently derives the hardened index 0 instead. + Assert.NotNull(mnemonic.DeriveP2PkPrivkey(int.MaxValue)); + Assert.Throws(() => + mnemonic.DeriveP2PkPrivkey((uint)int.MaxValue + 1) + ); + } } \ No newline at end of file diff --git a/DotNut.Tests/Unit/Nut20DerivationTests.cs b/DotNut.Tests/Unit/Nut20DerivationTests.cs new file mode 100644 index 0000000..bc71a43 --- /dev/null +++ b/DotNut.Tests/Unit/Nut20DerivationTests.cs @@ -0,0 +1,54 @@ +using DotNut.NBitcoin.BIP39; +using DotNut.NUT13; + +namespace DotNut.Tests.Unit; + +public class Nut20DerivationTests +{ + private const string Mnemonic = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + + [Fact] + public void QuoteKeyDerivationTests() + { + // Test vectors from tests/20-test.md, "Deterministic quote locking key derivation" + // (m/129373'/20'/0'/0'/{counter}) + var mnemonic = new Mnemonic(Mnemonic); + string[] keys = + [ + "03062837166e56114b59a4d1fd3a5a812bf7aadc1dde758428cf943d80acd41539", + "02b47d9d41725f5ce6f08c874835cef25376cb1e95f6cb073fef52ca8fd986cf15", + "029acbd3a46fd75bc05ba0226d0b4d909b2fb6e96c80544a094a1a3567737e44d3", + "0373e4a42fbe0a4e18aadb57cf500b655f2446b4071ee579121d2ed8905bcc49c2", + "02b8709bfce17c10f1864f5218844533ae60930d52089669b317d8b5f474eec071", + ]; + for (var i = 0u; i < (uint)keys.Length; i++) + { + var privkey = mnemonic.DeriveMintQuotePrivkey(i); + Assert.Equal(new PubKey(keys[i]), (PubKey)privkey.Key.CreatePubKey()); + } + } + + [Fact] + public void QuoteKeysAreIndependentFromP2PkKeys() + { + var mnemonic = new Mnemonic(Mnemonic); + + // Same seed and counter, different account index, so the two must never collide. + Assert.NotEqual( + mnemonic.DeriveMintQuotePrivkey(0).Key.CreatePubKey().ToHex(), + mnemonic.DeriveP2PkPrivkey(0).Key.CreatePubKey().ToHex() + ); + } + + [Fact] + public void QuoteKeyRejectsHardenedCounter() + { + var mnemonic = new Mnemonic(Mnemonic); + + Assert.NotNull(mnemonic.DeriveMintQuotePrivkey(int.MaxValue)); + Assert.Throws(() => + mnemonic.DeriveMintQuotePrivkey((uint)int.MaxValue + 1) + ); + } +} diff --git a/DotNut.Tests/Unit/UnitTests2.cs b/DotNut.Tests/Unit/UnitTests2.cs index e374aa8..a9ab83f 100644 --- a/DotNut.Tests/Unit/UnitTests2.cs +++ b/DotNut.Tests/Unit/UnitTests2.cs @@ -102,6 +102,63 @@ public async Task InMemoryCounter() Assert.Equal((uint)1337, ctrNum); } + [Fact] + public async Task InMemoryCounter_DerivationPurpose() + { + var ctr = new InMemoryCounter(); + + Assert.Equal((uint)0, await ctr.GetCounter(DerivationPurpose.P2Pk)); + + var (old, @new) = await ctr.FetchAndIncrement(DerivationPurpose.P2Pk, 3); + Assert.Equal((uint)0, old); + Assert.Equal((uint)3, @new); + Assert.Equal((uint)3, await ctr.GetCounter(DerivationPurpose.P2Pk)); + + await ctr.SetCounter(DerivationPurpose.P2Pk, 1337); + Assert.Equal((uint)1337, await ctr.GetCounter(DerivationPurpose.P2Pk)); + + // Each purpose is its own counter, and neither touches the keyset counters. + Assert.Equal((uint)0, await ctr.GetCounter(DerivationPurpose.MintQuoteLock)); + Assert.Empty(await ctr.Export()); + } + + [Fact] + public void Wallet_ExposesDerivationCounterOnlyWhenSupported() + { + var supported = Wallet.Create().WithCounter(new InMemoryCounter()); + Assert.NotNull(supported.GetDerivationCounter()); + + // An ICounter that predates IDerivationCounter still works, it just has no + // keyset-independent counters. + var unsupported = Wallet.Create().WithCounter(new KeysetOnlyCounter()); + Assert.NotNull(unsupported.GetCounter()); + Assert.Null(unsupported.GetDerivationCounter()); + } + + private class KeysetOnlyCounter : ICounter + { + public Task GetCounterForId(KeysetId keysetId, CancellationToken ct = default) => + Task.FromResult(0u); + + public Task IncrementCounter( + KeysetId keysetId, + uint bumpBy = 1, + CancellationToken ct = default + ) => Task.FromResult(bumpBy); + + public Task<(uint oldValue, uint newValue)> FetchAndIncrement( + KeysetId keysetId, + uint bumpBy = 1, + CancellationToken ct = default + ) => Task.FromResult((0u, bumpBy)); + + public Task SetCounter(KeysetId keysetId, uint counter, CancellationToken ct = default) => + Task.CompletedTask; + + public Task> Export() => + Task.FromResult>(new Dictionary()); + } + [Fact] public void SplitAmountsForPayment_ExactAmount_ReturnsCorrectSplit() { diff --git a/DotNut.sln.DotSettings.user b/DotNut.sln.DotSettings.user index 2e01ac8..8790a79 100644 --- a/DotNut.sln.DotSettings.user +++ b/DotNut.sln.DotSettings.user @@ -38,7 +38,7 @@ <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNut.Tests.UnitTests2</TestId> </TestAncestor> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Integration" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="Integration" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNut.Tests.Integration</TestId> <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNut.Tests.UnitTests2</TestId> @@ -46,7 +46,7 @@ <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNut.Tests.Nut29Tests</TestId> </TestAncestor> </SessionState> - <SessionState ContinuousTestingMode="0" Name="Nut11_SIG_ALL" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Nut11_SIG_ALL" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNut.Tests.UnitTest1.Nut11_SIG_ALL</TestId> </TestAncestor> diff --git a/DotNut/Abstractions/DerivationPurpose.cs b/DotNut/Abstractions/DerivationPurpose.cs new file mode 100644 index 0000000..c4d60f0 --- /dev/null +++ b/DotNut/Abstractions/DerivationPurpose.cs @@ -0,0 +1,18 @@ +namespace DotNut.Abstractions; + +/// +/// A wallet-level derivation counter that is not tied to a keyset. Each purpose has its own +/// derivation path and its own counter, independent from the per-keyset NUT-13 counters. +/// +public enum DerivationPurpose +{ + /// + /// NUT-13 P2PK keys to lock proofs to: m/129373'/10'/0'/0'/{counter}. + /// + P2Pk, + + /// + /// NUT-20 mint quote locking keys: m/129373'/20'/0'/0'/{counter}. + /// + MintQuoteLock, +} diff --git a/DotNut/Abstractions/Handlers/MintHandlerBolt11.cs b/DotNut/Abstractions/Handlers/MintHandlerBolt11.cs index b8db212..060a7a8 100644 --- a/DotNut/Abstractions/Handlers/MintHandlerBolt11.cs +++ b/DotNut/Abstractions/Handlers/MintHandlerBolt11.cs @@ -6,7 +6,8 @@ public class MintHandlerBolt11( IWalletBuilder wallet, PostMintQuoteBolt11Response postMintQuoteBolt11Response, GetKeysResponse.KeysetItemResponse keyset, - List outputs + List outputs, + PrivKey? derivedQuoteKey = null ) : IMintHandler> { private string? _signature; @@ -37,6 +38,12 @@ public IMintHandler> SignWithPrivkey(Pr public async Task> Mint(CancellationToken ct = default) { + // The quote was locked to a key we derived ourselves, so sign without being asked. + if (this._signature is null && derivedQuoteKey is not null) + { + SignWithPrivkey(derivedQuoteKey); + } + if (postMintQuoteBolt11Response.PubKey is not null && this._signature is null) { throw new ArgumentNullException( diff --git a/DotNut/Abstractions/Handlers/MintHandlerBolt12.cs b/DotNut/Abstractions/Handlers/MintHandlerBolt12.cs index 1b66790..0f533ab 100644 --- a/DotNut/Abstractions/Handlers/MintHandlerBolt12.cs +++ b/DotNut/Abstractions/Handlers/MintHandlerBolt12.cs @@ -7,7 +7,8 @@ public class MintHandlerBolt12( IWalletBuilder wallet, PostMintQuoteBolt12Response quote, GetKeysResponse.KeysetItemResponse keyset, - List outputs + List outputs, + PrivKey? derivedQuoteKey = null ) : IMintHandler> { private string? _signature; @@ -38,6 +39,12 @@ public IMintHandler> SignWithPrivkey(Pr public async Task> Mint(CancellationToken ct = default) { + // The quote was locked to a key we derived ourselves, so sign without being asked. + if (this._signature is null && derivedQuoteKey is not null) + { + SignWithPrivkey(derivedQuoteKey); + } + if (this._signature is null) { throw new ArgumentNullException( diff --git a/DotNut/Abstractions/InMemoryCounter.cs b/DotNut/Abstractions/InMemoryCounter.cs index ed2a3c9..32c7e45 100644 --- a/DotNut/Abstractions/InMemoryCounter.cs +++ b/DotNut/Abstractions/InMemoryCounter.cs @@ -2,9 +2,10 @@ namespace DotNut.Abstractions; -public class InMemoryCounter : ICounter +public class InMemoryCounter : ICounter, IDerivationCounter { private readonly ConcurrentDictionary _counter; + private readonly ConcurrentDictionary _purposeCounter = new(); public InMemoryCounter(IDictionary counter) { @@ -61,4 +62,39 @@ public async Task> Export() { return new Dictionary(_counter); } + + public Task GetCounter(DerivationPurpose purpose, CancellationToken ct = default) + { + return Task.FromResult(_purposeCounter.GetOrAdd(purpose, 0u)); + } + + public Task<(uint oldValue, uint newValue)> FetchAndIncrement( + DerivationPurpose purpose, + uint bumpBy = 1, + CancellationToken ct = default + ) + { + uint oldValue = 0; + uint newValue = _purposeCounter.AddOrUpdate( + purpose, + bumpBy, + (_, current) => + { + oldValue = current; + return current + bumpBy; + } + ); + + return Task.FromResult((oldValue, newValue)); + } + + public Task SetCounter( + DerivationPurpose purpose, + uint counter, + CancellationToken ct = default + ) + { + _purposeCounter[purpose] = counter; + return Task.CompletedTask; + } } diff --git a/DotNut/Abstractions/Interfaces/IDerivationCounter.cs b/DotNut/Abstractions/Interfaces/IDerivationCounter.cs new file mode 100644 index 0000000..4b25b20 --- /dev/null +++ b/DotNut/Abstractions/Interfaces/IDerivationCounter.cs @@ -0,0 +1,29 @@ +namespace DotNut.Abstractions; + +/// +/// Counters for derivations that are not tied to a keyset, such as NUT-13 P2PK keys. +/// Kept separate from so that existing implementations keep compiling; +/// an that does not also implement this only lacks those derivations. +/// +public interface IDerivationCounter +{ + /// + /// Gets the counter for a derivation purpose. Like the keyset counters, this is the value to + /// use for the next derivation, so keep it at last used + 1. + /// + public Task GetCounter(DerivationPurpose purpose, CancellationToken ct = default); + + /// + public Task<(uint oldValue, uint newValue)> FetchAndIncrement( + DerivationPurpose purpose, + uint bumpBy = 1, + CancellationToken ct = default + ); + + /// + public Task SetCounter( + DerivationPurpose purpose, + uint counter, + CancellationToken ct = default + ); +} diff --git a/DotNut/Abstractions/Interfaces/IMintQuoteBuilder.cs b/DotNut/Abstractions/Interfaces/IMintQuoteBuilder.cs index 81a5341..02d9773 100644 --- a/DotNut/Abstractions/Interfaces/IMintQuoteBuilder.cs +++ b/DotNut/Abstractions/Interfaces/IMintQuoteBuilder.cs @@ -43,8 +43,24 @@ public interface IMintQuoteBuilder /// /// Optional. Allows providing a P2PK builder when a signature is required for minting. /// + /// + /// Optional. Locks the quote to a key derived from the wallet seed at the next NUT-20 + /// counter, instead of one supplied through . The quote is + /// then signed automatically when minting, and the key survives a restore. Requires a + /// mnemonic and a counter implementing . + /// + IMintQuoteBuilder WithDeterministicPubkey(); + IMintQuoteBuilder WithP2PkLock(P2PkBuilder p2pkBuilder); + /// + /// Optional. Like , but the key to lock to is derived from the + /// wallet seed at the next NUT-13 P2PK counter, so it can be recovered during a restore. + /// The derived key becomes the primary one; any pubkeys already on the builder are kept + /// after it. Requires a mnemonic and a counter implementing . + /// + IMintQuoteBuilder WithDeterministicP2PkLock(P2PkBuilder? p2pkBuilder = null); + /// /// Optional. When minting P2Pk / HTLC Proofs allows to blind the pubkeys. /// diff --git a/DotNut/Abstractions/Interfaces/IWalletBuilder.cs b/DotNut/Abstractions/Interfaces/IWalletBuilder.cs index b6faccd..0f8e587 100644 --- a/DotNut/Abstractions/Interfaces/IWalletBuilder.cs +++ b/DotNut/Abstractions/Interfaces/IWalletBuilder.cs @@ -267,6 +267,13 @@ Task SelectProofsToSend( /// ICounter? GetCounter(); + /// + /// Returns the current Counter instance as an , or null when + /// it does not support keyset-independent derivation counters. + /// + /// + IDerivationCounter? GetDerivationCounter(); + /// /// Create swap transaction builder. /// diff --git a/DotNut/Abstractions/MintQuoteBuilder.cs b/DotNut/Abstractions/MintQuoteBuilder.cs index 0c4257a..f084739 100644 --- a/DotNut/Abstractions/MintQuoteBuilder.cs +++ b/DotNut/Abstractions/MintQuoteBuilder.cs @@ -3,6 +3,8 @@ using DotNut.Api; using DotNut.ApiModels; using DotNut.ApiModels.Mint.bolt12; +using DotNut.NBitcoin.BIP39; +using DotNut.NUT13; namespace DotNut.Abstractions; @@ -18,12 +20,17 @@ class MintQuoteBuilder : IMintQuoteBuilder private string? _pubkey; + //for nut20 + private bool _deterministicQuoteKey = false; + private PrivKey? _quoteKey; + private KeysetId? _keysetId; private GetKeysResponse.KeysetItemResponse? _keyset; //for p2pk private P2PkBuilder? _builder; private bool _shouldBlind = false; + private bool _deterministicP2Pk = false; public MintQuoteBuilder(Wallet wallet) { @@ -54,6 +61,12 @@ public IMintQuoteBuilder WithPubkey(PubKey pubkey) return this; } + public IMintQuoteBuilder WithDeterministicPubkey() + { + this._deterministicQuoteKey = true; + return this; + } + public IMintQuoteBuilder WithKeyset(KeysetId keysetId) { this._keysetId = keysetId; @@ -82,6 +95,13 @@ public IMintQuoteBuilder WithP2PkLock(P2PkBuilder p2pkBuilder) return this; } + public IMintQuoteBuilder WithDeterministicP2PkLock(P2PkBuilder? p2pkBuilder = null) + { + this._builder = p2pkBuilder ?? new P2PkBuilder(); + this._deterministicP2Pk = true; + return this; + } + public IMintQuoteBuilder BlindPubkeys(bool withBlinding = true) { this._shouldBlind = withBlinding; @@ -115,6 +135,11 @@ public async Task>> Proces ); } + if (this._deterministicQuoteKey) + { + await _applyDeterministicQuoteKey(ct); + } + var api = await this._wallet.GetMintApi(ct); if (api is null) { @@ -132,7 +157,7 @@ await this._wallet.GetActiveKeysetId(this._unit, ct) await this._wallet.GetKeys(this._keysetId, true, false, ct) ?? throw new ArgumentException($"Cant get keys for keysetId: {_keysetId}"); - var outputs = await this._createOutputs(); + var outputs = await this._createOutputs(ct); var reqBolt11 = new PostMintQuoteBolt11Request() { @@ -145,7 +170,13 @@ await this._wallet.GetKeys(this._keysetId, true, false, ct) PostMintQuoteBolt11Response, PostMintQuoteBolt11Request >("bolt11", reqBolt11, ct); - return new MintHandlerBolt11(this._wallet, quoteBolt11, this._keyset, outputs); + return new MintHandlerBolt11( + this._wallet, + quoteBolt11, + this._keyset, + outputs, + this._quoteKey + ); } public async Task>> ProcessAsyncBolt12( @@ -163,6 +194,11 @@ public async Task>> Proces ); } + if (this._deterministicQuoteKey) + { + await _applyDeterministicQuoteKey(ct); + } + if (this._pubkey == null) { throw new ArgumentNullException( @@ -189,7 +225,7 @@ await this._wallet.GetKeys(this._keysetId, true, false, ct) ?? throw new ArgumentException($"Cant fetch keys for keysetId: {_keysetId}"); } - var outputs = await this._createOutputs(); + var outputs = await this._createOutputs(ct); var req = new PostMintQuoteBolt12Request() { @@ -202,11 +238,69 @@ await this._wallet.GetKeys(this._keysetId, true, false, ct) PostMintQuoteBolt12Response, PostMintQuoteBolt12Request >("bolt12", req, ct); - return new MintHandlerBolt12(this._wallet, mintQuote, this._keyset, outputs); + return new MintHandlerBolt12( + this._wallet, + mintQuote, + this._keyset, + outputs, + this._quoteKey + ); + } + + /// + /// Derives the P2PK key from the wallet seed and makes it the primary key on the builder. + /// Consumes one counter value, so this must happen exactly once per quote. + /// + async Task _applyDeterministicP2PkKey(CancellationToken ct) + { + var mnemonic = + this._wallet.GetMnemonic() + ?? throw new ArgumentNullException( + nameof(Mnemonic), + "Can't derive a P2PK lock without a mnemonic" + ); + + var counter = + this._wallet.GetDerivationCounter() + ?? throw new ArgumentNullException( + nameof(IDerivationCounter), + "Can't derive a P2PK lock without a counter implementing IDerivationCounter" + ); + + var (current, _) = await counter.FetchAndIncrement(DerivationPurpose.P2Pk, 1, ct); + var derived = mnemonic.DeriveP2PkPrivkey(current).Key.CreatePubKey(); + + this._builder!.Pubkeys = [derived, .. this._builder.Pubkeys ?? []]; + } + + /// + /// Derives the NUT-20 quote locking key from the wallet seed and locks the quote to it. + /// Consumes one counter value, so this must happen exactly once per quote. + /// + async Task _applyDeterministicQuoteKey(CancellationToken ct) + { + var mnemonic = + this._wallet.GetMnemonic() + ?? throw new ArgumentNullException( + nameof(Mnemonic), + "Can't derive a quote lock without a mnemonic" + ); + + var counter = + this._wallet.GetDerivationCounter() + ?? throw new ArgumentNullException( + nameof(IDerivationCounter), + "Can't derive a quote lock without a counter implementing IDerivationCounter" + ); + + var (current, _) = await counter.FetchAndIncrement(DerivationPurpose.MintQuoteLock, 1, ct); + + this._quoteKey = mnemonic.DeriveMintQuotePrivkey(current); + this._pubkey = ((PubKey)this._quoteKey.Key.CreatePubKey()).ToString(); } // skipped checks for keysetid and keys, since its validated before. make sure to remember about it. - async Task> _createOutputs() + async Task> _createOutputs(CancellationToken ct = default) { var outputs = new List(); @@ -235,6 +329,11 @@ async Task> _createOutputs() return await _wallet.CreateOutputs(_amounts, this._keysetId!); } + if (this._deterministicP2Pk) + { + await _applyDeterministicP2PkKey(ct); + } + if (this._shouldBlind) { if (this._builder.SigFlag == "SIG_ALL") diff --git a/DotNut/Abstractions/Wallet.cs b/DotNut/Abstractions/Wallet.cs index 64da6ab..2aaef98 100644 --- a/DotNut/Abstractions/Wallet.cs +++ b/DotNut/Abstractions/Wallet.cs @@ -19,6 +19,7 @@ public class Wallet : IWalletBuilder _keysets.ToDictionary(k => k.Id, k => k.InputFee ?? 0); private Mnemonic? _mnemonic; private ICounter? _counter; + private IDerivationCounter? _derivationCounter; private IWebsocketService? _wsService; @@ -118,12 +119,17 @@ public IWalletBuilder WithMnemonic(string mnemonic) public IWalletBuilder WithCounter(ICounter counter) { this._counter = counter; + // Derivation counters are opt-in: an ICounter that does not implement + // IDerivationCounter simply has no keyset-independent counters. + this._derivationCounter = counter as IDerivationCounter; return this; } public IWalletBuilder WithCounter(IDictionary counter) { - this._counter = new InMemoryCounter(counter); + var inMemory = new InMemoryCounter(counter); + this._counter = inMemory; + this._derivationCounter = inMemory; return this; } @@ -400,6 +406,8 @@ public async Task GetWebsocketService(CancellationToken ct = public ICounter? GetCounter() => _counter; + public IDerivationCounter? GetDerivationCounter() => _derivationCounter; + /* * Private helpers */ diff --git a/DotNut/NUT13/Nut13.cs b/DotNut/NUT13/Nut13.cs index 4dd7b6f..af5b746 100644 --- a/DotNut/NUT13/Nut13.cs +++ b/DotNut/NUT13/Nut13.cs @@ -1,12 +1,15 @@ -using System.Security.Cryptography; using DotNut.Abstractions; using DotNut.NBitcoin.BIP39; using NBip32Fast; +using NBitcoin.Secp256k1; +using HMACSHA256 = System.Security.Cryptography.HMACSHA256; namespace DotNut.NUT13; public static class Nut13 { + private const uint HardenedOffset = 0x80000000; + public static byte[] DeriveBlindingFactor( this Mnemonic mnemonic, KeysetId keysetId, @@ -126,4 +129,27 @@ public static long GetKeysetIdInt(KeysetId keysetId) var mod = (long)Math.Pow(2, 31) - 1; return keysetIdInt % mod; } + + /// + /// Derives a private key to lock proofs to, using the NUT-13 P2PK path + /// m/129373'/10'/0'/0'/{counter}. + /// + public static PrivKey DeriveP2PkPrivkey(this Mnemonic mnemonic, uint counter) + { + var seed = mnemonic.DeriveSeed(); + return seed.DeriveP2PkPrivkey(counter); + } + + /// + public static PrivKey DeriveP2PkPrivkey(this byte[] seed, uint counter) + { + // The counter is a non-hardened child index, so it must stay below 2^31. + // KeyPath would otherwise silently parse it as a hardened index. + ArgumentOutOfRangeException.ThrowIfGreaterThan(counter, HardenedOffset - 1, nameof(counter)); + + var path = (KeyPath)KeyPath.Parse($"m/129373'/10'/0'/0'/{counter}")!; + var pkBytes = BIP32.Instance.DerivePath(path, seed).PrivateKey; + + return ECPrivKey.Create(pkBytes); + } } diff --git a/DotNut/NUT20/MintQuoteKeyDerivation.cs b/DotNut/NUT20/MintQuoteKeyDerivation.cs new file mode 100644 index 0000000..15c93e0 --- /dev/null +++ b/DotNut/NUT20/MintQuoteKeyDerivation.cs @@ -0,0 +1,36 @@ +using DotNut.NBitcoin.BIP39; +using DotNut.NUT13; +using NBip32Fast; +using NBitcoin.Secp256k1; + +namespace DotNut; + +public static class MintQuoteKeyDerivation +{ + private const uint HardenedOffset = 0x80000000; + + /// + /// Derives a key to lock a mint quote to, using the NUT-20 path + /// m/129373'/20'/0'/0'/{counter}. Deriving it from the seed means the key can be + /// recovered during a restore, so quotes that were locked but not yet minted are not lost. + /// The counter is independent from the NUT-13 keyset counters. + /// + public static PrivKey DeriveMintQuotePrivkey(this Mnemonic mnemonic, uint counter) + { + var seed = mnemonic.DeriveSeed(); + return seed.DeriveMintQuotePrivkey(counter); + } + + /// + public static PrivKey DeriveMintQuotePrivkey(this byte[] seed, uint counter) + { + // The counter is a non-hardened child index, so it must stay below 2^31. + // KeyPath would otherwise silently parse it as a hardened index. + ArgumentOutOfRangeException.ThrowIfGreaterThan(counter, HardenedOffset - 1, nameof(counter)); + + var path = (KeyPath)KeyPath.Parse($"m/129373'/20'/0'/0'/{counter}")!; + var pkBytes = BIP32.Instance.DerivePath(path, seed).PrivateKey; + + return ECPrivKey.Create(pkBytes); + } +}