diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2915a39 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [kukks] +custom: ['https://donate.kukks.org'] \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a78458 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: 'Publish application' +on: push + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Checkout the code + - uses: actions/checkout@v2 + + # Install .NET Core SDK + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.0.x + + - name: Test + run: dotnet test + + - name: Publish NuGet + if: ${{ github.ref == 'refs/heads/master' }} # Publish only when the push is on master + uses: Rebel028/publish-nuget@v2.7.0 + with: + PROJECT_FILE_PATH: DotNut.Client/DotNut.csproj + NUGET_KEY: ${{secrets.NUGET_KEY}} + PACKAGE_NAME: DotNut + INCLUDE_SYMBOLS: false + VERSION_REGEX: ^\s*(.*)<\/PackageVersion>\s*$ + TAG_COMMIT: true + TAG_FORMAT: DotNut/v* + - name: Publish Github Package Registry + if: ${{ github.ref == 'refs/heads/master' }} # Publish only when the push is on master + uses: Rebel028/publish-nuget@v2.7.0 + with: + PROJECT_FILE_PATH: DotNut.Client/DotNut.Client.csproj + NUGET_SOURCE: "https://nuget.pkg.github.com/Kukks" + NUGET_KEY: ${{secrets.GH_TOKEN}} + PACKAGE_NAME: DotNut + INCLUDE_SYMBOLS: false + VERSION_REGEX: ^\s*(.*)<\/PackageVersion>\s*$ + TAG_COMMIT: true + TAG_FORMAT: DotNut/v* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8c8e282..a941ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ **/bin **/obj +.idea +.idea diff --git a/.idea/.idea.DotNuts/.idea/.gitignore b/.idea/.idea.DotNuts/.idea/.gitignore deleted file mode 100644 index 2ed7900..0000000 --- a/.idea/.idea.DotNuts/.idea/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Rider ignored files -/modules.xml -/contentModel.xml -/projectSettingsUpdater.xml -/.idea.DotNuts.iml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.idea.DotNuts/.idea/encodings.xml b/.idea/.idea.DotNuts/.idea/encodings.xml deleted file mode 100644 index df87cf9..0000000 --- a/.idea/.idea.DotNuts/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/.idea.DotNuts/.idea/indexLayout.xml b/.idea/.idea.DotNuts/.idea/indexLayout.xml deleted file mode 100644 index 7b08163..0000000 --- a/.idea/.idea.DotNuts/.idea/indexLayout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.DotNuts/.idea/vcs.xml b/.idea/.idea.DotNuts/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/.idea.DotNuts/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/DotNuts.Tests/DotNuts.Tests.csproj b/DotNut.Tests/DotNut.Tests.csproj similarity index 94% rename from DotNuts.Tests/DotNuts.Tests.csproj rename to DotNut.Tests/DotNut.Tests.csproj index e0fc6ce..122cff3 100644 --- a/DotNuts.Tests/DotNuts.Tests.csproj +++ b/DotNut.Tests/DotNut.Tests.csproj @@ -23,7 +23,7 @@ - + diff --git a/DotNuts.Tests/GlobalUsings.cs b/DotNut.Tests/GlobalUsings.cs similarity index 100% rename from DotNuts.Tests/GlobalUsings.cs rename to DotNut.Tests/GlobalUsings.cs diff --git a/DotNuts.Tests/UnitTest1.cs b/DotNut.Tests/UnitTest1.cs similarity index 59% rename from DotNuts.Tests/UnitTest1.cs rename to DotNut.Tests/UnitTest1.cs index 6d50f16..6b2bc41 100644 --- a/DotNuts.Tests/UnitTest1.cs +++ b/DotNut.Tests/UnitTest1.cs @@ -1,7 +1,8 @@ using System.Text.Json; +using DotNut; using NBitcoin.Secp256k1; -namespace DotNuts.Tests; +namespace DotNut.Tests; public class UnitTest1 { @@ -15,7 +16,7 @@ public class UnitTest1 public void Nut00Tests_HashToCurve(string message, string point) { var result = Cashu.HexToCurve(message); - Assert.Equal(point, Convert.ToHexString(result.ToBytes(true)).ToLower()); + Assert.Equal(point, result.ToHex()); } @@ -32,7 +33,7 @@ public void Nut00Tests_BlindedMessages(string x, string r, string b) var blindingFactor = ECPrivKey.Create(Convert.FromHexString(r)); var computedB = Cashu.ComputeB_(y, blindingFactor); - Assert.Equal(b, Convert.ToHexString(computedB.ToBytes()).ToLower()); + Assert.Equal(b, computedB.ToHex()); } [InlineData("0000000000000000000000000000000000000000000000000000000000000001", @@ -49,7 +50,7 @@ public void Nut00Tests_BlindedSignatures(string k, string b_, string blindedKey) ECPubKey.Create(Convert.FromHexString(blindedKey)); var computedC = Cashu.ComputeC_(B_, mintKey); - Assert.Equal(blindedKey, Convert.ToHexString(computedC.ToBytes()).ToLower()); + Assert.Equal(blindedKey, computedC.ToHex()); } [Fact] @@ -70,14 +71,17 @@ public void Nut00Tests_TokenSerialization() { Assert.Equal(2, proof.Amount); Assert.Equal(new KeysetId("009a1f293253e41e"), proof.Id); - Assert.Equal("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", proof.Secret); + + Assert.Equal("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", + Assert.IsType(proof.Secret).Secret); Assert.Equal("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea".ToPubKey(), (ECPubKey) proof.C); }, proof => { Assert.Equal(8, proof.Amount); Assert.Equal(new KeysetId("009a1f293253e41e"), proof.Id); - Assert.Equal("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", proof.Secret); + Assert.Equal("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", + Assert.IsType(proof.Secret).Secret); Assert.Equal("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059".ToPubKey(), (ECPubKey) proof.C); } @@ -125,7 +129,7 @@ public void Nut02Tests_KeysetIdMatch(string keysetId, string keyset) Assert.Equal(keysetIdParsed, keysetParsed.GetKeysetId()); } -[Fact] + [Fact] public void Nut04Tests_Proofs_1() { var a = "0000000000000000000000000000000000000000000000000000000000000001".ToPrivKey(); @@ -139,16 +143,16 @@ public void Nut04Tests_Proofs_1() var proof = Cashu.ComputeProof(B_, a, blindingFactor); Cashu.VerifyProof(B_, C_, proof.e, proof.s, A); var C = Cashu.ComputeC(C_, blindingFactor, A); - + Cashu.VerifyProof(Y, blindingFactor, C, proof.e, proof.s, A); } - - + + [Fact] public void Nut04Tests_Proofs_2() { var A = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".ToPubKey(); - var proof = JsonSerializer.Deserialize(@" + var proof = JsonSerializer.Deserialize(@" { ""amount"": 1, @@ -163,8 +167,156 @@ public void Nut04Tests_Proofs_2() } "); - - Assert.NotNull(proof.DLEQ); - Cashu.VerifyProof(Cashu.HexToCurve(proof.Secret), proof.DLEQ.R, proof.C, proof.DLEQ.E, proof.DLEQ.S, A); + + Assert.NotNull(proof?.DLEQ); + Cashu.VerifyProof(Cashu.HexToCurve(Assert.IsType(proof.Secret).Secret), proof.DLEQ.R, proof.C, + proof.DLEQ.E, proof.DLEQ.S, A); + } + + [Fact] + public void Nut11_Signatures() + { + var secretKey = + ECPrivKey.Create(Convert.FromHexString("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37")); + + var signing_key_two = + ECPrivKey.Create(Convert.FromHexString("0000000000000000000000000000000000000000000000000000000000000001")); + + var signing_key_three = + ECPrivKey.Create(Convert.FromHexString("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f")); + + var conditions = new P2PkBuilder() + { + Lock = DateTimeOffset.FromUnixTimeSeconds(21000000000), + Pubkeys = new[] {signing_key_two.CreatePubKey(), signing_key_three.CreatePubKey()}, + RefundPubkeys = new[] {secretKey.CreatePubKey()}, + SignatureThreshold = 2, + SigFlag = "SIG_INPUTS" + }; + var p2pkProofSecret = conditions.Build(); + + var secret = new Nut10Secret(P2PKProofSecret.Key, p2pkProofSecret); + + var proof = new Proof() + { + Id = new KeysetId("009a1f293253e41e"), + Amount = 0, + Secret = secret, + C = "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904".ToPubKey(), + }; + var witness = p2pkProofSecret.GenerateWitness(proof, new[] {signing_key_two, signing_key_three}); + proof.Witness = JsonSerializer.Serialize(witness); + Assert.True(p2pkProofSecret.VerifyWitness(proof.Secret, witness)); + + + var valid1 = + "{\"amount\":1,\"secret\":\"[\\\"P2PK\\\",{\\\"nonce\\\":\\\"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\\\",\\\"data\\\":\\\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\\\",\\\"tags\\\":[[\\\"sigflag\\\",\\\"SIG_INPUTS\\\"]]}]\",\"C\":\"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904\",\"id\":\"009a1f293253e41e\",\"witness\":\"{\\\"signatures\\\":[\\\"60f3c9b766770b46caac1d27e1ae6b77c8866ebaeba0b9489fe6a15a837eaa6fcd6eaa825499c72ac342983983fd3ba3a8a41f56677cc99ffd73da68b59e1383\\\"]}\"}"; + var valid1Proof = JsonSerializer.Deserialize(valid1); + var valid1ProofSecret = Assert.IsType(valid1Proof.Secret); + Assert.Equal(P2PKProofSecret.Key, valid1ProofSecret!.Key); + var valid1ProofSecretp2pkValue = Assert.IsType(valid1ProofSecret.ProofSecret); + var valid1ProofWitnessP2pk = JsonSerializer.Deserialize(valid1Proof.Witness); + Assert.True(valid1ProofSecretp2pkValue.VerifyWitness(valid1Proof.Secret, valid1ProofWitnessP2pk)); + + var invalid1 = + @"{""amount"":1,""secret"":""[\""P2PK\"",{\""nonce\"":\""0ed3fcb22c649dd7bbbdcca36e0c52d4f0187dd3b6a19efcc2bfbebb5f85b2a1\"",\""data\"":\""0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\"",\""tags\"":[[\""pubkeys\"",\""0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\"",\""02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\""],[\""n_sigs\"",\""2\""],[\""sigflag\"",\""SIG_INPUTS\""]]}]"",""C"":""02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904"",""id"":""009a1f293253e41e"",""witness"":""{\""signatures\"":[\""83564aca48c668f50d022a426ce0ed19d3a9bdcffeeaee0dc1e7ea7e98e9eff1840fcc821724f623468c94f72a8b0a7280fa9ef5a54a1b130ef3055217f467b3\""]}""}"; + + var invalid1Proof = JsonSerializer.Deserialize(invalid1); + var invalid1ProofSecret = Assert.IsType(invalid1Proof.Secret); + Assert.Equal(P2PKProofSecret.Key, invalid1ProofSecret!.Key); + var invalid1ProofSecretp2pkValue = Assert.IsType(invalid1ProofSecret.ProofSecret); + var invalid1ProofWitnessP2pk = JsonSerializer.Deserialize(invalid1Proof.Witness); + Assert.False(invalid1ProofSecretp2pkValue.VerifyWitness(invalid1Proof.Secret, invalid1ProofWitnessP2pk)); + + var validMultisig = + "{\"amount\":1,\"secret\":\"[\\\"P2PK\\\",{\\\"nonce\\\":\\\"0ed3fcb22c649dd7bbbdcca36e0c52d4f0187dd3b6a19efcc2bfbebb5f85b2a1\\\",\\\"data\\\":\\\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\\\",\\\"tags\\\":[[\\\"pubkeys\\\",\\\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\\\",\\\"02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\\\"],[\\\"n_sigs\\\",\\\"2\\\"],[\\\"sigflag\\\",\\\"SIG_INPUTS\\\"]]}]\",\"C\":\"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904\",\"id\":\"009a1f293253e41e\",\"witness\":\"{\\\"signatures\\\":[\\\"83564aca48c668f50d022a426ce0ed19d3a9bdcffeeaee0dc1e7ea7e98e9eff1840fcc821724f623468c94f72a8b0a7280fa9ef5a54a1b130ef3055217f467b3\\\",\\\"9a72ca2d4d5075be5b511ee48dbc5e45f259bcf4a4e8bf18587f433098a9cd61ff9737dc6e8022de57c76560214c4568377792d4c2c6432886cc7050487a1f22\\\"]}\"}"; + var validMultisigProof = JsonSerializer.Deserialize(validMultisig); + var validMultisigProofSecret = Assert.IsType(validMultisigProof.Secret); + Assert.Equal(P2PKProofSecret.Key, validMultisigProofSecret!.Key); + var validMultisigProofSecretp2pkValue = Assert.IsType(validMultisigProofSecret.ProofSecret); + var validMultisigProofWitnessP2pk = JsonSerializer.Deserialize(validMultisigProof.Witness); + Assert.True( + validMultisigProofSecretp2pkValue.VerifyWitness(validMultisigProof.Secret, validMultisigProofWitnessP2pk)); + + var invalidMultisig = + "{\"amount\":1,\"secret\":\"[\\\"P2PK\\\",{\\\"nonce\\\":\\\"0ed3fcb22c649dd7bbbdcca36e0c52d4f0187dd3b6a19efcc2bfbebb5f85b2a1\\\",\\\"data\\\":\\\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\\\",\\\"tags\\\":[[\\\"pubkeys\\\",\\\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\\\",\\\"02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\\\"],[\\\"n_sigs\\\",\\\"2\\\"],[\\\"sigflag\\\",\\\"SIG_INPUTS\\\"]]}]\",\"C\":\"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904\",\"id\":\"009a1f293253e41e\",\"witness\":\"{\\\"signatures\\\":[\\\"83564aca48c668f50d022a426ce0ed19d3a9bdcffeeaee0dc1e7ea7e98e9eff1840fcc821724f623468c94f72a8b0a7280fa9ef5a54a1b130ef3055217f467b3\\\"]}\"}"; + var invalidMultisigProof = JsonSerializer.Deserialize(invalidMultisig); + var invalidMultisigProofSecret = Assert.IsType(invalidMultisigProof.Secret); + Assert.Equal(P2PKProofSecret.Key, invalidMultisigProofSecret!.Key); + var invalidMultisigProofSecretp2pkValue = + Assert.IsType(invalidMultisigProofSecret.ProofSecret); + var invalidMultisigProofWitnessP2pk = JsonSerializer.Deserialize(invalidMultisigProof.Witness); + Assert.False(invalidMultisigProofSecretp2pkValue.VerifyWitness(invalidMultisigProof.Secret, + invalidMultisigProofWitnessP2pk)); + + var validProofRefund = + "{\"amount\":1,\"id\":\"009a1f293253e41e\",\"secret\":\"[\\\"P2PK\\\",{\\\"nonce\\\":\\\"902685f492ef3bb2ca35a47ddbba484a3365d143b9776d453947dcbf1ddf9689\\\",\\\"data\\\":\\\"026f6a2b1d709dbca78124a9f30a742985f7eddd894e72f637f7085bf69b997b9a\\\",\\\"tags\\\":[[\\\"pubkeys\\\",\\\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\\\",\\\"03142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\\\"],[\\\"locktime\\\",\\\"21\\\"],[\\\"n_sigs\\\",\\\"2\\\"],[\\\"refund\\\",\\\"026f6a2b1d709dbca78124a9f30a742985f7eddd894e72f637f7085bf69b997b9a\\\"],[\\\"sigflag\\\",\\\"SIG_INPUTS\\\"]]}]\",\"C\":\"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904\",\"witness\":\"{\\\"signatures\\\":[\\\"710507b4bc202355c91ea3c147c0d0189c75e179d995e566336afd759cb342bcad9a593345f559d9b9e108ac2c9b5bd9f0b4b6a295028a98606a0a2e95eb54f7\\\"]}\"}"; + var validProofRefundParsed = JsonSerializer.Deserialize(validProofRefund); + var validProofRefundSecret = Assert.IsType(validProofRefundParsed.Secret); + Assert.Equal(P2PKProofSecret.Key, validProofRefundSecret!.Key); + var validProofRefundSecretp2pkValue = Assert.IsType(validProofRefundSecret.ProofSecret); + var validProofRefundWitnessP2pk = JsonSerializer.Deserialize(validProofRefundParsed.Witness); + Assert.True( + validProofRefundSecretp2pkValue.VerifyWitness(validProofRefundParsed.Secret, validProofRefundWitnessP2pk)); + + + var invalidProofRefund = + "{\"amount\":1,\"id\":\"009a1f293253e41e\",\"secret\":\"[\\\"P2PK\\\",{\\\"nonce\\\":\\\"64c46e5d30df27286166814b71b5d69801704f23a7ad626b05688fbdb48dcc98\\\",\\\"data\\\":\\\"026f6a2b1d709dbca78124a9f30a742985f7eddd894e72f637f7085bf69b997b9a\\\",\\\"tags\\\":[[\\\"pubkeys\\\",\\\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\\\",\\\"03142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\\\"],[\\\"locktime\\\",\\\"21\\\"],[\\\"n_sigs\\\",\\\"2\\\"],[\\\"refund\\\",\\\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\\\"],[\\\"sigflag\\\",\\\"SIG_INPUTS\\\"]]}]\",\"C\":\"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904\",\"witness\":\"{\\\"signatures\\\":[\\\"f661d3dc046d636d47cb3d06586da42c498f0300373d1c2a4f417a44252cdf3809bce207c8888f934dba0d2b1671f1b8622d526840f2d5883e571b462630c1ff\\\"]}\"}"; + var invalidProofRefundParsed = JsonSerializer.Deserialize(invalidProofRefund); + var invalidProofRefundSecret = Assert.IsType(invalidProofRefundParsed.Secret); + Assert.Equal(P2PKProofSecret.Key, invalidProofRefundSecret!.Key); + var invalidProofRefundSecretp2pkValue = Assert.IsType(invalidProofRefundSecret.ProofSecret); + var invalidProofRefundWitnessP2pk = JsonSerializer.Deserialize(invalidProofRefundParsed.Witness); + Assert.False(invalidProofRefundSecretp2pkValue.VerifyWitness(invalidProofRefundParsed.Secret, + invalidProofRefundWitnessP2pk)); + } + + [Fact] + public void Nut12Tests_Hash_e() + { + var r1 = "020000000000000000000000000000000000000000000000000000000000000001".ToPubKey(); + var r2 = "020000000000000000000000000000000000000000000000000000000000000001".ToPubKey(); + var k = "020000000000000000000000000000000000000000000000000000000000000001".ToPubKey(); + var c = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2".ToPubKey(); + var e = Cashu.ComputeE(r1, r2, k, c).ToHex(); + Assert.Equal("a4dc034b74338c28c6bc3ea49731f2a24440fc7c4affc08b31a93fc9fbe6401e", e); + } + + [Fact] + public void Nut12Tests_BlindSignaturesDLEQ() + { + var A = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".ToPubKey(); + var B_ = "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2".ToPubKey(); + var blindSig = JsonSerializer.Deserialize( + "{\n \"amount\": 8,\n \"id\": \"00882760bfa2eb41\",\n \"C_\": \"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2\",\n \"dleq\": {\n \"e\": \"9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9\",\n \"s\": \"9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da\"\n }\n}"); + + Assert.NotNull(blindSig?.DLEQ); + blindSig.Verify(A, B_); + } + + [Fact] + public void Nut12Tests_ProofDLEQ() + { + var A = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".ToPubKey(); + var proof = JsonSerializer.Deserialize("{\"amount\": 1,\"id\": \"00882760bfa2eb41\",\"secret\": \"daf4dd00a2b68a0858a80450f52c8a7d2ccf87d375e43e216e0c571f089f63e9\",\"C\": \"024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc\",\"dleq\": {\"e\": \"b31e58ac6527f34975ffab13e70a48b6d2b0d35abc4b03f0151f09ee1a9763d4\",\"s\": \"8fbae004c59e754d71df67e392b6ae4e29293113ddc2ec86592a0431d16306d8\",\"r\": \"a6d13fcd7a18442e6076f5e1e7c887ad5de40a019824bdfa9fe740d302e8d861\"}}"); + Assert.NotNull(proof?.DLEQ); + Assert.Equal("024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc".ToPubKey(), proof.Secret.ToCurve()); + Assert.True(proof.Verify(A)); + } + + [Fact] + public void Nut14Tests_HTLCSecret() + { + var htlcSecretStr = "[\n \"HTLC\",\n {\n \"nonce\": \"da62796403af76c80cd6ce9153ed3746\",\n \"data\": \"023192200a0cfd3867e48eb63b03ff599c7e46c8f4e41146b2d281173ca6c50c54\",\n \"tags\": [\n [\n \"pubkeys\",\n \"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904\"\n ],\n [\n \"locktime\",\n \"1689418329\"\n ], \n [\n \"refund\",\n \"033281c37677ea273eb7183b783067f5244933ef78d8c3f15b1a77cb246099c26e\"\n ]\n ]\n }\n]"; + var secret = JsonSerializer.Deserialize(htlcSecretStr); + var nut10Secret = Assert.IsType(secret); + Assert.Equal(HTLCProofSecret.Key, nut10Secret.Key); + var htlcSecret = Assert.IsType(nut10Secret.ProofSecret); + Assert.Single(htlcSecret.GetAllowedPubkeys(out var requiredSignatures)); + Assert.Equal(1, requiredSignatures); + var rebuiltHtlcSecret = htlcSecret.Builder.Build(); + var rebuiltNut10 = new Nut10Secret(HTLCProofSecret.Key, rebuiltHtlcSecret); + Assert.Equal(JsonSerializer.Serialize(nut10Secret), JsonSerializer.Serialize(rebuiltNut10)); } + } \ No newline at end of file diff --git a/DotNuts.sln b/DotNut.sln similarity index 77% rename from DotNuts.sln rename to DotNut.sln index 7b45f1e..9acedf2 100644 --- a/DotNuts.sln +++ b/DotNut.sln @@ -1,8 +1,8 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNuts", "DotNuts\DotNuts.csproj", "{997966AE-DC46-4473-9C82-A0F1750B2481}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNut", "DotNut\DotNut.csproj", "{997966AE-DC46-4473-9C82-A0F1750B2481}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNuts.Tests", "DotNuts.Tests\DotNuts.Tests.csproj", "{0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNut.Tests", "DotNut.Tests\DotNut.Tests.csproj", "{0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/DotNut.sln.DotSettings.user b/DotNut.sln.DotSettings.user new file mode 100644 index 0000000..beea517 --- /dev/null +++ b/DotNut.sln.DotSettings.user @@ -0,0 +1,12 @@ + + <SessionState ContinuousTestingMode="0" Name="Test1" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <TestAncestor> + <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNuts.Tests.UnitTest1</TestId> + </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Nut11_Signatures" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <And> + <Namespace>DotNuts.Tests</Namespace> + <Project Location="C:\Git\DotNuts\DotNuts.Tests" Presentation="&lt;DotNuts.Tests&gt;" /> + </And> +</SessionState> \ No newline at end of file diff --git a/DotNuts/CashuHttpClient.cs b/DotNut/Api/CashuHttpClient.cs similarity index 79% rename from DotNuts/CashuHttpClient.cs rename to DotNut/Api/CashuHttpClient.cs index e68c79b..bd23b26 100644 --- a/DotNuts/CashuHttpClient.cs +++ b/DotNut/Api/CashuHttpClient.cs @@ -2,9 +2,9 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; +using DotNut.ApiModels; -namespace DotNuts; +namespace DotNut.Api; public class CashuHttpClient { @@ -107,62 +107,4 @@ protected async Task HandleResponse(HttpResponseMessage response, Cancella return result!; } -} - -public class ProofSecretSet: Dictionary -{ - -} - -public class ProofSecret -{ - - [JsonPropertyName("nonce")] - public string Nonce { get; set; } - [JsonPropertyName("data")] - public string Data { get; set; } - [JsonPropertyName("tags")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string[][]? Tags { get; set; } -} - -public class PostRestoreRequest -{ -[JsonPropertyName("outputs")] - public BlindedMessage[] Outputs { get; set; } -}public class PostRestoreResponse -{ -[JsonPropertyName("outputs")] - public BlindedMessage[] Outputs { get; set; } -[JsonPropertyName("signatures")] - public BlindSignature[] Signatures { get; set; } -} - -public class PostCheckStateRequest -{ - [JsonPropertyName("Ys")] - public string[] Ys { get; set; } -} - -public class PostCheckStateResponse -{ - - [JsonPropertyName("states")] - public StateResponseItem[] States { get; set; } -} - -public class StateResponseItem -{ - - public string Y { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public TokenState State { get; set; } - public string? Witness { get; set; } - - public enum TokenState - { - UNSPENT, - PENDING, - SPENT - } } \ No newline at end of file diff --git a/DotNuts/CashuProtocolError.cs b/DotNut/Api/CashuProtocolError.cs similarity index 89% rename from DotNuts/CashuProtocolError.cs rename to DotNut/Api/CashuProtocolError.cs index 7862ab0..3e4f7aa 100644 --- a/DotNuts/CashuProtocolError.cs +++ b/DotNut/Api/CashuProtocolError.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +namespace DotNut.Api; + public class CashuProtocolError { [JsonPropertyName("detail")] public string Detail { get; set; } diff --git a/DotNuts/CashuProtocolException.cs b/DotNut/Api/CashuProtocolException.cs similarity index 69% rename from DotNuts/CashuProtocolException.cs rename to DotNut/Api/CashuProtocolException.cs index c86fdb2..86ad641 100644 --- a/DotNuts/CashuProtocolException.cs +++ b/DotNut/Api/CashuProtocolException.cs @@ -1,4 +1,6 @@ -public class CashuProtocolException : Exception +namespace DotNut.Api; + +public class CashuProtocolException : Exception { public CashuProtocolException(CashuProtocolError error) : base(error.Detail) { diff --git a/DotNut/ApiModels/GetInfoResponse.cs b/DotNut/ApiModels/GetInfoResponse.cs new file mode 100644 index 0000000..e2823cc --- /dev/null +++ b/DotNut/ApiModels/GetInfoResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNut.ApiModels; + +public class GetInfoResponse +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("pubkey")] public string Pubkey { get; set; } + [JsonPropertyName("version")] public string Version { get; set; } + [JsonPropertyName("description")] public string Description { get; set; } + [JsonPropertyName("description_long")] public string DescriptionLong { get; set; } + [JsonPropertyName("contact")] public List> Contact { get; set; } + [JsonPropertyName("motd")] public string Motd { get; set; } + [JsonPropertyName("nuts")] public Dictionary Nuts { get; set; } +} \ No newline at end of file diff --git a/DotNuts/GetKeysResponse.cs b/DotNut/ApiModels/GetKeysResponse.cs similarity index 93% rename from DotNuts/GetKeysResponse.cs rename to DotNut/ApiModels/GetKeysResponse.cs index 07ee30c..cedaed7 100644 --- a/DotNuts/GetKeysResponse.cs +++ b/DotNut/ApiModels/GetKeysResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DotNuts; +namespace DotNut.ApiModels; public class GetKeysResponse { diff --git a/DotNuts/GetKeysetsResponse.cs b/DotNut/ApiModels/GetKeysetsResponse.cs similarity index 93% rename from DotNuts/GetKeysetsResponse.cs rename to DotNut/ApiModels/GetKeysetsResponse.cs index c6311d9..7a72cbf 100644 --- a/DotNuts/GetKeysetsResponse.cs +++ b/DotNut/ApiModels/GetKeysetsResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DotNuts; +namespace DotNut.ApiModels; public class GetKeysetsResponse { diff --git a/DotNut/ApiModels/PostCheckStateRequest.cs b/DotNut/ApiModels/PostCheckStateRequest.cs new file mode 100644 index 0000000..3421fd2 --- /dev/null +++ b/DotNut/ApiModels/PostCheckStateRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace DotNut.ApiModels; + +public class PostCheckStateRequest +{ + [JsonPropertyName("Ys")] + public string[] Ys { get; set; } +} \ No newline at end of file diff --git a/DotNut/ApiModels/PostCheckStateResponse.cs b/DotNut/ApiModels/PostCheckStateResponse.cs new file mode 100644 index 0000000..475881b --- /dev/null +++ b/DotNut/ApiModels/PostCheckStateResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace DotNut.ApiModels; + +public class PostCheckStateResponse +{ + + [JsonPropertyName("states")] + public StateResponseItem[] States { get; set; } +} \ No newline at end of file diff --git a/DotNut/ApiModels/PostRestoreRequest.cs b/DotNut/ApiModels/PostRestoreRequest.cs new file mode 100644 index 0000000..e46e872 --- /dev/null +++ b/DotNut/ApiModels/PostRestoreRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace DotNut.ApiModels; + +public class PostRestoreRequest +{ + [JsonPropertyName("outputs")] + public BlindedMessage[] Outputs { get; set; } +} \ No newline at end of file diff --git a/DotNut/ApiModels/PostRestoreResponse.cs b/DotNut/ApiModels/PostRestoreResponse.cs new file mode 100644 index 0000000..7af6dfa --- /dev/null +++ b/DotNut/ApiModels/PostRestoreResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace DotNut.ApiModels; + +public class PostRestoreResponse +{ + [JsonPropertyName("outputs")] + public BlindedMessage[] Outputs { get; set; } + [JsonPropertyName("signatures")] + public BlindSignature[] Signatures { get; set; } +} \ No newline at end of file diff --git a/DotNuts/PostSwapRequest.cs b/DotNut/ApiModels/PostSwapRequest.cs similarity index 88% rename from DotNuts/PostSwapRequest.cs rename to DotNut/ApiModels/PostSwapRequest.cs index 4f59def..a018dce 100644 --- a/DotNuts/PostSwapRequest.cs +++ b/DotNut/ApiModels/PostSwapRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DotNuts; +namespace DotNut.ApiModels; public class PostSwapRequest { diff --git a/DotNuts/PostSwapResponse.cs b/DotNut/ApiModels/PostSwapResponse.cs similarity index 85% rename from DotNuts/PostSwapResponse.cs rename to DotNut/ApiModels/PostSwapResponse.cs index 7243f91..5bb3caf 100644 --- a/DotNuts/PostSwapResponse.cs +++ b/DotNut/ApiModels/PostSwapResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DotNuts; +namespace DotNut.ApiModels; public class PostSwapResponse { diff --git a/DotNut/ApiModels/StateResponseItem.cs b/DotNut/ApiModels/StateResponseItem.cs new file mode 100644 index 0000000..5d0a05c --- /dev/null +++ b/DotNut/ApiModels/StateResponseItem.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace DotNut.ApiModels; + +public class StateResponseItem +{ + + public string Y { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public TokenState State { get; set; } + public string? Witness { get; set; } + + public enum TokenState + { + UNSPENT, + PENDING, + SPENT + } +} \ No newline at end of file diff --git a/DotNuts/BlindSignature.cs b/DotNut/BlindSignature.cs similarity index 61% rename from DotNuts/BlindSignature.cs rename to DotNut/BlindSignature.cs index 5a04135..fa60826 100644 --- a/DotNuts/BlindSignature.cs +++ b/DotNut/BlindSignature.cs @@ -1,4 +1,7 @@ using System.Text.Json.Serialization; +using DotNut.JsonConverters; + +namespace DotNut; public class BlindSignature { @@ -9,4 +12,9 @@ public class BlindSignature public KeysetId Id { get; set; } [JsonPropertyName("C_")] public PubKey C_ { get; set; } + + + [JsonPropertyName("dleq")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DLEQProof? DLEQ { get; set; } } \ No newline at end of file diff --git a/DotNuts/BlindedMessage.cs b/DotNut/BlindedMessage.cs similarity index 93% rename from DotNuts/BlindedMessage.cs rename to DotNut/BlindedMessage.cs index d672476..5fc43bc 100644 --- a/DotNuts/BlindedMessage.cs +++ b/DotNut/BlindedMessage.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; -using NBitcoin.Secp256k1; + +namespace DotNut; public class BlindedMessage { diff --git a/DotNuts/Cashu.cs b/DotNut/Cashu.cs similarity index 65% rename from DotNuts/Cashu.cs rename to DotNut/Cashu.cs index 5011928..97d0fb3 100644 --- a/DotNuts/Cashu.cs +++ b/DotNut/Cashu.cs @@ -1,21 +1,18 @@ using System.Text; -using DotNuts; using NBitcoin.Secp256k1; using SHA256 = System.Security.Cryptography.SHA256; +namespace DotNut; + public static class Cashu { private static readonly byte[] DOMAIN_SEPARATOR = "Secp256k1_HashToCurve_Cashu_"u8.ToArray(); - - - - public static ECPubKey MessageToCurve(string message) { var hash = Encoding.UTF8.GetBytes(message); return HashToCurve(hash); - } + } public static ECPubKey HexToCurve(string hex) { @@ -24,12 +21,11 @@ public static ECPubKey HexToCurve(string hex) } public static ECPubKey HashToCurve(byte[] x) { - using SHA256 sha256 = SHA256.Create(); - var msg_hash = sha256.ComputeHash(Concat(DOMAIN_SEPARATOR, x)); + var msg_hash = SHA256.HashData(Concat(DOMAIN_SEPARATOR, x)); for (uint counter = 0;; counter++) { var counterBytes = BitConverter.GetBytes(counter); - var publicKeyBytes = Concat([0x02], sha256.ComputeHash(Concat(msg_hash, counterBytes))); + var publicKeyBytes = Concat([0x02], SHA256.HashData(Concat(msg_hash, counterBytes))); try { return ECPubKey.Create(publicKeyBytes); @@ -88,18 +84,32 @@ public static (ECPrivKey e, ECPrivKey s) ComputeProof(ECPubKey B_, ECPrivKey a, var C_ = ComputeC_(B_, a); var A = a.CreatePubKey(); - using SHA256 sha256 = SHA256.Create(); - var e = sha256.ComputeHash(Concat(r1.ToBytes(), r2.ToBytes(), A.ToBytes(), C_.ToBytes())); - var s = p.TweakAdd(a.TweakMul(e).ToBytes()); - return (new Scalar(e).ToPrivateKey(), s); + var e = ComputeE(r1, r2, A, C_); + var s = p.TweakAdd(a.TweakMul(e.ToBytes()).ToBytes()); + return (e.ToPrivateKey(), s); + } + + public static Scalar ComputeE(ECPubKey R1, ECPubKey R2, ECPubKey K, ECPubKey C_) + { + byte[] eBytes = Encoding.UTF8.GetBytes(string.Concat(new[] {R1, R2, K, C_}.Select(pk => pk.ToHex(false)))); + return new Scalar(SHA256.HashData(eBytes)); } + public static bool Verify(this Proof proof, ECPubKey A) + { + return VerifyProof(proof.Secret.ToCurve(),proof.DLEQ.R, proof.C, proof.DLEQ.E, proof.DLEQ.S, A); + } + public static bool Verify(this BlindSignature blindSig, ECPubKey A, ECPubKey B_) + { + return Cashu.VerifyProof(B_, blindSig.C_, blindSig.DLEQ.E, blindSig.DLEQ.S, A); + } + public static bool VerifyProof(ECPubKey B_, ECPubKey C_, ECPrivKey e, ECPrivKey s, ECPubKey A) { + var r1 = s.CreatePubKey().Q.ToGroupElementJacobian().Add((A.Q * e.sec.Negate()).ToGroupElement()).ToPubkey(); var r2 = (B_.Q * s.sec).Add((C_.Q * e.sec.Negate()).ToGroupElement()).ToPubkey(); - using SHA256 sha256 = SHA256.Create(); - var e_ = sha256.ComputeHash(Concat(r1.ToBytes(), r2.ToBytes(), A.ToBytes(), C_.ToBytes())); + var e_ = ComputeE(r1, r2, A, C_); return e.sec.Equals(e_); } @@ -112,7 +122,7 @@ public static bool VerifyProof(ECPubKey Y, ECPrivKey r, ECPubKey C, ECPrivKey e, public static ECPubKey ComputeC(ECPubKey C_, ECPrivKey r, ECPubKey A) { - return C_.Q.ToGroupElementJacobian().Add((A.Q * r.sec).ToGroupElement()).ToPubkey(); + return C_.Q.ToGroupElementJacobian().Add((A.Q * r.sec).ToGroupElement()).ToPubkey(); } private static byte[] Concat(params byte[][] arrays) @@ -122,9 +132,7 @@ private static byte[] Concat(params byte[][] arrays) public static string ToHex(this ECPrivKey key) { - Span output = stackalloc byte[32]; - key.WriteToSpan(output); - return Convert.ToHexString(output); + return Convert.ToHexString(key.ToBytes()).ToLower(); } public static byte[] ToBytes(this ECPrivKey key) @@ -133,4 +141,23 @@ public static byte[] ToBytes(this ECPrivKey key) key.WriteToSpan(output); return output.ToArray(); } + + public static byte[] ToUncompressedBytes(this ECPubKey key) + { + Span output = stackalloc byte[65]; + key.WriteToSpan(false, output, out _); + return output.ToArray(); + } + public static string ToHex(this ECPubKey key, bool compressed = true) + { + return compressed ? Convert.ToHexString(key.ToBytes(true)).ToLower() : Convert.ToHexString(key.ToUncompressedBytes()).ToLower(); + } + public static string ToHex(this Scalar scalar) + { + return Convert.ToHexString(scalar.ToBytes()).ToLower(); + } + public static string ToHex(this SecpSchnorrSignature sig) + { + return Convert.ToHexString(sig.ToBytes()).ToLower(); + } } \ No newline at end of file diff --git a/DotNuts/CashuToken.cs b/DotNut/CashuToken.cs similarity index 96% rename from DotNuts/CashuToken.cs rename to DotNut/CashuToken.cs index 0633832..4efb1cb 100644 --- a/DotNuts/CashuToken.cs +++ b/DotNut/CashuToken.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +namespace DotNut; + public class CashuToken { public class Token diff --git a/DotNuts/DLEQ.cs b/DotNut/DLEQ.cs similarity index 75% rename from DotNuts/DLEQ.cs rename to DotNut/DLEQ.cs index f80a401..9c7b973 100644 --- a/DotNuts/DLEQ.cs +++ b/DotNut/DLEQ.cs @@ -1,8 +1,9 @@ using System.Text.Json.Serialization; +namespace DotNut; + public class DLEQ { [JsonPropertyName("e")] public PrivKey E { get; set; } [JsonPropertyName("s")] public PrivKey S { get; set; } - [JsonPropertyName("r")] public PrivKey R { get; set; } } \ No newline at end of file diff --git a/DotNut/DLEQProof.cs b/DotNut/DLEQProof.cs new file mode 100644 index 0000000..a569e12 --- /dev/null +++ b/DotNut/DLEQProof.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace DotNut; + +public class DLEQProof: DLEQ +{ + [JsonPropertyName("r")] public PrivKey R { get; set; } +} \ No newline at end of file diff --git a/DotNut/DotNut.csproj b/DotNut/DotNut.csproj new file mode 100644 index 0000000..f202094 --- /dev/null +++ b/DotNut/DotNut.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + DotNut + Kukks + A full C# native implementation of the Cashu protocol + MIT + https://github.com/Kukks/DotNut + https://github.com/Kukks/DotNut/blob/master/LICENSE + https://github.com/Kukks/DotNut + git + bitcoin cashu ecash secp256k1 + + + + + + + diff --git a/DotNuts/Base64UrlSafe.cs b/DotNut/Encoding/Base64UrlSafe.cs similarity index 92% rename from DotNuts/Base64UrlSafe.cs rename to DotNut/Encoding/Base64UrlSafe.cs index 430ca18..512e6d8 100644 --- a/DotNuts/Base64UrlSafe.cs +++ b/DotNut/Encoding/Base64UrlSafe.cs @@ -1,4 +1,6 @@ -public static class Base64UrlSafe +namespace DotNut; + +public static class Base64UrlSafe { static readonly char[] padding = {'='}; diff --git a/DotNuts/CashuTokenHelper.cs b/DotNut/Encoding/CashuTokenHelper.cs similarity index 83% rename from DotNuts/CashuTokenHelper.cs rename to DotNut/Encoding/CashuTokenHelper.cs index ff9fd7b..f4ea54a 100644 --- a/DotNuts/CashuTokenHelper.cs +++ b/DotNut/Encoding/CashuTokenHelper.cs @@ -1,13 +1,14 @@ using System.Text; -using NBitcoin.Secp256k1; +using System.Text.Json; +namespace DotNut; public static class CashuTokenHelper { public const string CashuUriScheme = "cashu"; public const string CashuPrefix = "cashu"; - public static string Encode(CashuToken token, string version = "A", bool makeUri = true) + public static string Encode(this CashuToken token, string version = "A", bool makeUri = true) { var json = System.Text.Json.JsonSerializer.Serialize(token); var result = CashuPrefix + version + Base64UrlSafe.Encode(Encoding.UTF8.GetBytes(json)); @@ -37,6 +38,6 @@ public static CashuToken Decode(string token, out string? version) token = token.Substring(1); var json = Encoding.UTF8.GetString(Base64UrlSafe.Decode(token)); - return System.Text.Json.JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json)!; } } \ No newline at end of file diff --git a/DotNuts/ConvertUtils.cs b/DotNut/Encoding/ConvertUtils.cs similarity index 95% rename from DotNuts/ConvertUtils.cs rename to DotNut/Encoding/ConvertUtils.cs index aceb9e6..de38e7e 100644 --- a/DotNuts/ConvertUtils.cs +++ b/DotNut/Encoding/ConvertUtils.cs @@ -1,5 +1,6 @@ using NBitcoin.Secp256k1; +namespace DotNut; public static class ConvertUtils { diff --git a/DotNut/HTLCBuilder.cs b/DotNut/HTLCBuilder.cs new file mode 100644 index 0000000..51c2f08 --- /dev/null +++ b/DotNut/HTLCBuilder.cs @@ -0,0 +1,48 @@ +using NBitcoin.Secp256k1; + +namespace DotNut; + +public class HTLCBuilder : P2PkBuilder +{ + public ECPubKey HashLock { get; set; } + + public static HTLCBuilder Load(HTLCProofSecret proofSecret) + { + var hashLock = proofSecret.Data.ToPubKey(); + var innerbuilder = P2PkBuilder.Load(proofSecret); + innerbuilder.Pubkeys = innerbuilder.Pubkeys.Except(new[] {hashLock}).ToArray(); + return new HTLCBuilder() + { + HashLock = hashLock, + Lock = innerbuilder.Lock, + Pubkeys = innerbuilder.Pubkeys, + RefundPubkeys = innerbuilder.RefundPubkeys, + SignatureThreshold = innerbuilder.SignatureThreshold, + SigFlag = innerbuilder.SigFlag, + Nonce = innerbuilder.Nonce + }; + + } + + public new HTLCProofSecret Build() + { + var innerBuilder = new P2PkBuilder() + { + Lock = Lock, + Pubkeys = Pubkeys.ToArray(), + RefundPubkeys = RefundPubkeys, + SignatureThreshold = SignatureThreshold, + SigFlag = SigFlag, + Nonce = Nonce + }; + innerBuilder.Pubkeys = innerBuilder.Pubkeys.Prepend(HashLock).ToArray(); + + var p2pkProof = innerBuilder.Build(); + return new HTLCProofSecret() + { + Data = HashLock.ToHex(), + Nonce = p2pkProof.Nonce, + Tags = p2pkProof.Tags + }; + } +} \ No newline at end of file diff --git a/DotNut/HTLCProofSecret.cs b/DotNut/HTLCProofSecret.cs new file mode 100644 index 0000000..b34cacb --- /dev/null +++ b/DotNut/HTLCProofSecret.cs @@ -0,0 +1,123 @@ +using System.Text; +using System.Text.Json.Serialization; +using NBitcoin.Secp256k1; +using SHA256 = System.Security.Cryptography.SHA256; + +namespace DotNut; + +public class HTLCProofSecret : P2PKProofSecret +{ + public const string Key = "HTLC"; + + [JsonIgnore] public HTLCBuilder Builder => HTLCBuilder.Load(this); + + public override ECPubKey[] GetAllowedPubkeys(out int requiredSignatures) + { + var builder = Builder; + if (builder.Lock.HasValue && builder.Lock.Value.ToUnixTimeSeconds() < DateTimeOffset.Now.ToUnixTimeSeconds()) + { + requiredSignatures = Math.Min(builder.RefundPubkeys?.Length ?? 0, 1); + return builder.RefundPubkeys ?? Array.Empty(); + } + + requiredSignatures = builder.SignatureThreshold; + return builder.Pubkeys; + } + + public HTLCWitness GenerateWitness(Proof proof, ECPrivKey[] keys, string preimage) + { + return GenerateWitness(proof.Secret.GetBytes(), keys, Encoding.UTF8.GetBytes(preimage)); + } + + public HTLCWitness GenerateWitness(BlindedMessage blindedMessage, ECPrivKey[] keys, string preimage) + { + return GenerateWitness(blindedMessage.B_.Key.ToBytes(), keys, Encoding.UTF8.GetBytes(preimage)); + } + + public HTLCWitness GenerateWitness(byte[] msg, ECPrivKey[] keys, byte[] preimage) + { + var hash = SHA256.HashData(msg); + + return GenerateWitness(ECPrivKey.Create(hash), keys, preimage); + } + + public HTLCWitness GenerateWitness(ECPrivKey hash, ECPrivKey[] keys, byte[] preimage) + { + if (!VerifyPreimage(Encoding.UTF8.GetString(preimage))) + throw new InvalidOperationException("Invalid preimage"); + var p2pkhWitness = base.GenerateWitness(hash, keys); + return new HTLCWitness() + { + Signatures = p2pkhWitness.Signatures, + Preimage = Encoding.UTF8.GetString(preimage) + }; + } + + public bool VerifyPreimage(string preimage) + { + return Builder.HashLock.ToBytes().SequenceEqual(SHA256.HashData(Encoding.UTF8.GetBytes(preimage))); + } + + public bool VerifyWitness(string message, HTLCWitness witness) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(message)); + return VerifyWitnessHash(hash, witness); + } + + public bool VerifyWitness(ISecret secret, HTLCWitness witness) + { + if (secret is Nut10Secret {ProofSecret: HTLCProofSecret htlcProofSecret} && + !VerifyPreimage(htlcProofSecret.Builder.HashLock.ToHex())) + { + return false; + } + + return VerifyWitness(secret.GetBytes(), witness); + } + + [Obsolete("Use GenerateWitness(Proof proof, ECPrivKey[] keys, string preimage)")] + public override P2PKWitness GenerateWitness(Proof proof, ECPrivKey[] keys) + { + throw new InvalidOperationException("Use GenerateWitness(Proof proof, ECPrivKey[] keys, string preimage)"); + } + + [Obsolete("Use GenerateWitness(byte[] msg, ECPrivKey[] keys, byte[] preimage)")] + public override P2PKWitness GenerateWitness(BlindedMessage message, ECPrivKey[] keys) + { + throw new InvalidOperationException("Use GenerateWitness(BlindedMessage message, ECPrivKey[] keys, string preimage)"); + } + + [Obsolete("Use GenerateWitness(byte[] msg, ECPrivKey[] keys, byte[] preimage)")] + public override P2PKWitness GenerateWitness(byte[] msg, ECPrivKey[] keys) + { + throw new InvalidOperationException("Use GenerateWitness(byte[] msg, ECPrivKey[] keys, byte[] preimage)"); + } + + public override P2PKWitness GenerateWitness(ECPrivKey hash, ECPrivKey[] keys) + { + return base.GenerateWitness(hash, keys); + } + + public override bool VerifyWitness(string message, P2PKWitness witness) + { + return base.VerifyWitness(message, witness); + } + + public override bool VerifyWitness(ISecret secret, P2PKWitness witness) + { + return base.VerifyWitness(secret, witness); + } + + public override bool VerifyWitness(byte[] message, P2PKWitness witness) + { + return base.VerifyWitness(message, witness); + } + public override bool VerifyWitnessHash(byte[] hash, P2PKWitness witness) + { + if (witness is not HTLCWitness htlcWitness) + { + return false; + } + return base.VerifyWitnessHash(hash, witness); + } +} \ No newline at end of file diff --git a/DotNut/HTLCWitness.cs b/DotNut/HTLCWitness.cs new file mode 100644 index 0000000..6f37bcd --- /dev/null +++ b/DotNut/HTLCWitness.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace DotNut; + +public class HTLCWitness: P2PKWitness +{ + [JsonPropertyName("preimage")] public string Preimage { get; set; } +} \ No newline at end of file diff --git a/DotNut/ISecret.cs b/DotNut/ISecret.cs new file mode 100644 index 0000000..5e860f8 --- /dev/null +++ b/DotNut/ISecret.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using DotNut.JsonConverters; +using NBitcoin.Secp256k1; + +namespace DotNut; + +[JsonConverter(typeof(SecretJsonConverter))] +public interface ISecret +{ + byte[] GetBytes(); + ECPubKey ToCurve(); +} \ No newline at end of file diff --git a/DotNut/JsonConverters/KeysetIdJsonConverter.cs b/DotNut/JsonConverters/KeysetIdJsonConverter.cs new file mode 100644 index 0000000..465e377 --- /dev/null +++ b/DotNut/JsonConverters/KeysetIdJsonConverter.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNut.JsonConverters; + +public class KeysetIdJsonConverter : JsonConverter +{ + public override KeysetId? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.String || + reader.GetString() is not { } str || + string.IsNullOrEmpty(str)) + { + throw new JsonException("Expected string"); + } + + return new KeysetId(str); + } + + public override void Write(Utf8JsonWriter writer, KeysetId? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value.ToString()); + } +} \ No newline at end of file diff --git a/DotNuts/KeysetJsonConverter.cs b/DotNut/JsonConverters/KeysetJsonConverter.cs similarity index 76% rename from DotNuts/KeysetJsonConverter.cs rename to DotNut/JsonConverters/KeysetJsonConverter.cs index 02835e7..d15c997 100644 --- a/DotNuts/KeysetJsonConverter.cs +++ b/DotNut/JsonConverters/KeysetJsonConverter.cs @@ -1,10 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; -using NBitcoin.Secp256k1; + +namespace DotNut.JsonConverters; public class KeysetJsonConverter : JsonConverter { - public override Keyset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override Keyset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) { @@ -24,11 +25,6 @@ public override Keyset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS return keyset; } - if (reader.TokenType != JsonTokenType.PropertyName) - { - throw new JsonException("Expected property name"); - } - ulong amount; if (reader.TokenType == JsonTokenType.Number) { @@ -36,7 +32,10 @@ public override Keyset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS } else if (reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName) { - amount = ulong.Parse(reader.GetString()); + var str = reader.GetString(); + if (string.IsNullOrEmpty(str)) + throw new JsonException("Expected string"); + amount = ulong.Parse(str); } else { @@ -46,7 +45,7 @@ public override Keyset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS reader.Read(); var pubkey = JsonSerializer.Deserialize(ref reader, options); - if(pubkey.Key.ToBytes().Length != 33) + if(pubkey is null || pubkey.Key.ToBytes().Length != 33) throw new JsonException("Invalid public key (not compressed?)"); keyset.Add(amount, pubkey); } @@ -54,7 +53,7 @@ public override Keyset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS throw new JsonException("Missing end object"); } - public override void Write(Utf8JsonWriter writer, Keyset value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, Keyset? value, JsonSerializerOptions options) { if (value is null) { diff --git a/DotNut/JsonConverters/Nut10SecretJsonConverter.cs b/DotNut/JsonConverters/Nut10SecretJsonConverter.cs new file mode 100644 index 0000000..1f33bd1 --- /dev/null +++ b/DotNut/JsonConverters/Nut10SecretJsonConverter.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNut.JsonConverters; + +public class Nut10SecretJsonConverter : JsonConverter +{ + + + + public override Nut10Secret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if(reader.TokenType == JsonTokenType.Null) + return null; + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException("Expected array"); + } + reader.Read(); + if(reader.TokenType != JsonTokenType.String) + throw new JsonException("Expected string"); + var key = reader.GetString(); + reader.Read(); + + Nut10ProofSecret? proofSecret; + switch (key) + { + case P2PKProofSecret.Key: + proofSecret = JsonSerializer.Deserialize(ref reader, options); + break; + case HTLCProofSecret.Key: + proofSecret = JsonSerializer.Deserialize(ref reader, options); + + break; + default: + throw new JsonException("Unknown secret type"); + } + if(proofSecret is null) + throw new JsonException("Invalid proof secret"); + reader.Read(); + if (reader.TokenType != JsonTokenType.EndArray) + { + throw new JsonException("Expected end array"); + } + + return new Nut10Secret(key, proofSecret); + + + } + + public override void Write(Utf8JsonWriter writer, Nut10Secret? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartArray(); + JsonSerializer.Serialize(writer, value.Key, options); + JsonSerializer.Serialize(writer, value.ProofSecret, options); + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/DotNuts/KeysetIdJsonConverter.cs b/DotNut/JsonConverters/PrivKeyJsonConverter.cs similarity index 53% rename from DotNuts/KeysetIdJsonConverter.cs rename to DotNut/JsonConverters/PrivKeyJsonConverter.cs index a797de6..6e57bf1 100644 --- a/DotNuts/KeysetIdJsonConverter.cs +++ b/DotNut/JsonConverters/PrivKeyJsonConverter.cs @@ -1,24 +1,28 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; +namespace DotNut.JsonConverters; -public class KeysetIdJsonConverter : JsonConverter +public class PrivKeyJsonConverter : JsonConverter { - public override KeysetId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override PrivKey? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) { return null; } - else if (reader.TokenType != JsonTokenType.String) + + if (reader.TokenType != JsonTokenType.String || + reader.GetString() is not { } str || + string.IsNullOrEmpty(str)) { throw new JsonException("Expected string"); } - return new KeysetId(reader.GetString()); + return new PrivKey(str); } - public override void Write(Utf8JsonWriter writer, KeysetId value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, PrivKey? value, JsonSerializerOptions options) { if (value is null) { diff --git a/DotNuts/PrivKeyJsonConverter.cs b/DotNut/JsonConverters/PubKeyJsonConverter.cs similarity index 55% rename from DotNuts/PrivKeyJsonConverter.cs rename to DotNut/JsonConverters/PubKeyJsonConverter.cs index 71ec1f1..c3413ae 100644 --- a/DotNuts/PrivKeyJsonConverter.cs +++ b/DotNut/JsonConverters/PubKeyJsonConverter.cs @@ -1,24 +1,28 @@ using System.Text.Json; using System.Text.Json.Serialization; -public class PrivKeyJsonConverter : JsonConverter +namespace DotNut.JsonConverters; + +public class PubKeyJsonConverter : JsonConverter { - public override PrivKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override PubKey? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) { return null; } - if (reader.TokenType != JsonTokenType.String) + if (reader.TokenType != JsonTokenType.String || + reader.GetString() is not { } str || + string.IsNullOrEmpty(str)) { throw new JsonException("Expected string"); } - return new PrivKey(reader.GetString()); + return new PubKey(str, true); } - public override void Write(Utf8JsonWriter writer, PrivKey value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, PubKey? value, JsonSerializerOptions options) { if (value is null) { diff --git a/DotNut/JsonConverters/SecretJsonConverter.cs b/DotNut/JsonConverters/SecretJsonConverter.cs new file mode 100644 index 0000000..7576b38 --- /dev/null +++ b/DotNut/JsonConverters/SecretJsonConverter.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNut.JsonConverters; + +public class SecretJsonConverter : JsonConverter +{ + public override ISecret? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.StartArray && reader.CurrentDepth == 0) + { + //we are converting a nut10 secret directly + return JsonSerializer.Deserialize(ref reader, options); + } + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string"); + } + + var str = reader.GetString(); + if (string.IsNullOrEmpty(str)) + { + throw new JsonException("Secret was not nut10 or a (not empty) string"); + } + try + { + return JsonSerializer.Deserialize(str); + } + catch (Exception e) + { + + return new StringSecret(str); + } + } + + public override void Write(Utf8JsonWriter writer, ISecret? value, JsonSerializerOptions options) + { + switch (value) + { + case null: + writer.WriteNullValue(); + return; + case Nut10Secret nut10Secret: + writer.WriteStringValue(JsonSerializer.Serialize(nut10Secret)); + return; + case StringSecret stringSecret: + writer.WriteStringValue(stringSecret.Secret); + break; + } + } +} \ No newline at end of file diff --git a/DotNuts/Keyset.cs b/DotNut/Keyset.cs similarity index 95% rename from DotNuts/Keyset.cs rename to DotNut/Keyset.cs index ddb3f7e..b2c1ac2 100644 --- a/DotNuts/Keyset.cs +++ b/DotNut/Keyset.cs @@ -1,7 +1,9 @@ using System.Text.Json.Serialization; -using NBitcoin.Secp256k1; +using DotNut.JsonConverters; using SHA256 = System.Security.Cryptography.SHA256; +namespace DotNut; + [JsonConverter(typeof(KeysetJsonConverter))] public class Keyset : Dictionary { diff --git a/DotNuts/KeysetId.cs b/DotNut/KeysetId.cs similarity index 97% rename from DotNuts/KeysetId.cs rename to DotNut/KeysetId.cs index 5279bf1..0a875ee 100644 --- a/DotNuts/KeysetId.cs +++ b/DotNut/KeysetId.cs @@ -1,4 +1,7 @@ using System.Text.Json.Serialization; +using DotNut.JsonConverters; + +namespace DotNut; [JsonConverter(typeof(KeysetIdJsonConverter))] public class KeysetId : IEquatable,IEqualityComparer diff --git a/DotNut/MeltMethodSetting.cs b/DotNut/MeltMethodSetting.cs new file mode 100644 index 0000000..efe9fba --- /dev/null +++ b/DotNut/MeltMethodSetting.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace DotNut; + +public class MeltMethodSetting +{ + [JsonPropertyName("method")] public string Method { get; set; } + [JsonPropertyName("unit")] public List Unit { get; set; } + [JsonPropertyName("min_amount")] public int? Min { get; set; } + [JsonPropertyName("max_amount")] public int? Max { get; set; } +} \ No newline at end of file diff --git a/DotNuts/MintMethodSetting.cs b/DotNut/MintMethodSetting.cs similarity index 61% rename from DotNuts/MintMethodSetting.cs rename to DotNut/MintMethodSetting.cs index 96bd315..9c1b4f4 100644 --- a/DotNuts/MintMethodSetting.cs +++ b/DotNut/MintMethodSetting.cs @@ -1,15 +1,8 @@ -using System.Text.Json; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; -public class MintMethodSetting -{ - [JsonPropertyName("method")] public string Method { get; set; } - [JsonPropertyName("unit")] public List Unit { get; set; } - [JsonPropertyName("min_amount")] public int? Min { get; set; } - [JsonPropertyName("max_amount")] public int? Max { get; set; } -} +namespace DotNut; -public class MeltMethodSetting +public class MintMethodSetting { [JsonPropertyName("method")] public string Method { get; set; } [JsonPropertyName("unit")] public List Unit { get; set; } @@ -59,15 +52,4 @@ public class MeltMethodSetting // "10": {"supported": true}, // "12": {"supported": true} // } -// } -public class GetInfoResponse -{ - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("pubkey")] public string Pubkey { get; set; } - [JsonPropertyName("version")] public string Version { get; set; } - [JsonPropertyName("description")] public string Description { get; set; } - [JsonPropertyName("description_long")] public string DescriptionLong { get; set; } - [JsonPropertyName("contact")] public List> Contact { get; set; } - [JsonPropertyName("motd")] public string Motd { get; set; } - [JsonPropertyName("nuts")] public Dictionary Nuts { get; set; } -} \ No newline at end of file +// } \ No newline at end of file diff --git a/DotNut/MultipathPaymentSetting.cs b/DotNut/MultipathPaymentSetting.cs new file mode 100644 index 0000000..90134a3 --- /dev/null +++ b/DotNut/MultipathPaymentSetting.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace DotNut; + +public class MultipathPaymentSetting +{ + [JsonPropertyName("method")] public string Method { get; set; } + [JsonPropertyName("unit")] public List Unit { get; set; } + [JsonPropertyName("mpp")] public bool MultiPathPayments { get; set; } +} \ No newline at end of file diff --git a/DotNut/Nut10ProofSecret.cs b/DotNut/Nut10ProofSecret.cs new file mode 100644 index 0000000..b124be3 --- /dev/null +++ b/DotNut/Nut10ProofSecret.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace DotNut; + +public class Nut10ProofSecret +{ + + [JsonPropertyName("nonce")] + public string Nonce { get; set; } + [JsonPropertyName("data")] + public string Data { get; set; } + [JsonPropertyName("tags")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[][]? Tags { get; set; } +} \ No newline at end of file diff --git a/DotNut/Nut10Secret.cs b/DotNut/Nut10Secret.cs new file mode 100644 index 0000000..05538b6 --- /dev/null +++ b/DotNut/Nut10Secret.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DotNut.JsonConverters; +using NBitcoin.Secp256k1; + +namespace DotNut; + +[JsonConverter(typeof(Nut10SecretJsonConverter))] +public class Nut10Secret : ISecret +{ + private readonly string? _originalString; + + public Nut10Secret(string key, Nut10ProofSecret proofSecret) + { + Key = key; + ProofSecret = proofSecret; + } + + public Nut10Secret(string originalString) + { + _originalString = originalString; + } + + public string Key { get; set; } + public Nut10ProofSecret ProofSecret { get; set; } + + + public byte[] GetBytes() + { + return _originalString != null + ? System.Text.Encoding.UTF8.GetBytes(_originalString) + : JsonSerializer.SerializeToUtf8Bytes(this); + } + + public ECPubKey ToCurve() + { + return Cashu.HashToCurve(GetBytes()); + } +} \ No newline at end of file diff --git a/DotNut/P2PKProofSecret.cs b/DotNut/P2PKProofSecret.cs new file mode 100644 index 0000000..230f4f6 --- /dev/null +++ b/DotNut/P2PKProofSecret.cs @@ -0,0 +1,109 @@ +using System.Text; +using System.Text.Json.Serialization; +using NBitcoin.Secp256k1; +using SHA256 = System.Security.Cryptography.SHA256; + +namespace DotNut; + +public class P2PKProofSecret : Nut10ProofSecret +{ + public const string Key = "P2PK"; + + [JsonIgnore] P2PkBuilder Builder => P2PkBuilder.Load(this); + + public virtual ECPubKey[] GetAllowedPubkeys(out int requiredSignatures) + { + var builder = Builder; + if (builder.Lock.HasValue && builder.Lock.Value.ToUnixTimeSeconds() < DateTimeOffset.Now.ToUnixTimeSeconds()) + { + requiredSignatures = Math.Min(builder.RefundPubkeys?.Length ?? 0, 1); + return builder.RefundPubkeys ?? Array.Empty(); + } + + requiredSignatures = builder.SignatureThreshold; + return builder.Pubkeys; + } + + + public virtual P2PKWitness GenerateWitness(Proof proof, ECPrivKey[] keys) + { + return GenerateWitness(proof.Secret.GetBytes(), keys); + } + public virtual P2PKWitness GenerateWitness(BlindedMessage message, ECPrivKey[] keys) + { + return GenerateWitness(message.B_.Key.ToBytes(), keys); + } + + public virtual P2PKWitness GenerateWitness(byte[] msg, ECPrivKey[] keys) + { + var hash = SHA256.HashData(msg); + return GenerateWitness(ECPrivKey.Create(hash), keys); + } + + public virtual P2PKWitness GenerateWitness(ECPrivKey hash, ECPrivKey[] keys) + { + var msg = hash.ToBytes(); + //filter out keys that matter + var allowedKeys = GetAllowedPubkeys(out var requiredSignatures); + var keysRequiredLeft = requiredSignatures; + var availableKeysLeft = keys; + var result = new P2PKWitness(); + while (keysRequiredLeft > 0 && availableKeysLeft.Any()) + { + var key = availableKeysLeft.First(); + var pubkey = key.CreatePubKey(); + var isAllowed = allowedKeys.Any(p => p == pubkey); + if (isAllowed) + { + var sig = key.SignBIP340(msg); + + key.CreatePubKey().SigVerifySchnorr(sig, msg); + result.Signatures = result.Signatures.Append(sig.ToHex()).ToArray(); + } + + availableKeysLeft = availableKeysLeft.Except(new[] {key}).ToArray(); + keysRequiredLeft = requiredSignatures - result.Signatures.Length; + } + + if (keysRequiredLeft > 0) + throw new InvalidOperationException("Not enough valid keys to sign"); + + return result; + } + + public virtual bool VerifyWitness(string message, P2PKWitness witness) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(message)); + return VerifyWitnessHash(hash, witness); + } + + public virtual bool VerifyWitness(ISecret secret, P2PKWitness witness) + { + return VerifyWitness(secret.GetBytes(), witness); + } + + public virtual bool VerifyWitness(byte[] message, P2PKWitness witness) + { + var hash = SHA256.HashData(message); + return VerifyWitnessHash(hash, witness); + } + + public virtual bool VerifyWitnessHash(byte[] hash, P2PKWitness witness) + { + try + { + var allowedKeys = GetAllowedPubkeys(out var requiredSignatures); + if (witness.Signatures.Length < requiredSignatures) + return false; + var sigs = witness.Signatures + .Select(s => SecpSchnorrSignature.TryCreate(Convert.FromHexString(s), out var sig) ? sig : null) + .Where(signature => signature is not null).ToArray(); + return sigs.Count(s => allowedKeys.Any(p => p.ToXOnlyPubKey().SigVerifyBIP340(s, hash))) >= + requiredSignatures; + } + catch (Exception e) + { + return false; + } + } +} \ No newline at end of file diff --git a/DotNut/P2PKWitness.cs b/DotNut/P2PKWitness.cs new file mode 100644 index 0000000..49185a2 --- /dev/null +++ b/DotNut/P2PKWitness.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace DotNut; + +public class P2PKWitness +{ + [JsonPropertyName("signatures")] public string[] Signatures { get; set; } = Array.Empty(); +} \ No newline at end of file diff --git a/DotNut/P2PkBuilder.cs b/DotNut/P2PkBuilder.cs new file mode 100644 index 0000000..d5e98d2 --- /dev/null +++ b/DotNut/P2PkBuilder.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using NBitcoin.Secp256k1; + +namespace DotNut; + +public class P2PkBuilder +{ + public DateTimeOffset? Lock { get; set; } + public ECPubKey[]? RefundPubkeys { get; set; } + public int SignatureThreshold { get; set; } = 1; + public ECPubKey[] Pubkeys { get; set; } + //SIG_INPUTS, SIG_ALL + public string? SigFlag { get; set; } + + public string? Nonce { get; set; } + + public P2PKProofSecret Build() + { + var tags = new List(); + if(Pubkeys.Length > 1) + { + tags.Add(new[] {"pubkeys"}.Concat(Pubkeys.Skip(1).Select(p => p.ToHex())).ToArray()); + } + if (!string.IsNullOrEmpty(SigFlag)) + { + tags.Add(new[] {"sigflag", SigFlag}); + } + + if (Lock.HasValue) + { + tags.Add(new[] {"locktime", Lock.Value.ToUnixTimeSeconds().ToString()}); + if (RefundPubkeys?.Any() is true) + { + tags.Add(new[] {"refund"}.Concat(RefundPubkeys.Select(p => p.ToHex())) + .ToArray()); + } + } + + if (SignatureThreshold > 1 && Pubkeys.Length >= SignatureThreshold) + { + tags.Add(new[] {"n_sigs", SignatureThreshold.ToString()}); + } + + + return new P2PKProofSecret() + { + Data = Pubkeys.First().ToHex(), + Nonce = Nonce?? RandomNumberGenerator.GetHexString(32, true), + Tags = tags.ToArray() + }; + } + + public static P2PkBuilder Load(P2PKProofSecret proofSecret) + { + var builder = new P2PkBuilder(); + var primaryPubkey = proofSecret.Data.ToPubKey(); + var pubkeys = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "pubkeys"); + if (pubkeys is not null && pubkeys.Length > 1) + { + builder.Pubkeys = pubkeys.Skip(1).Select(s => s.ToPubKey()).Prepend(primaryPubkey).ToArray(); + } + else + { + builder.Pubkeys = [primaryPubkey]; + } + var rawUnixTs = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "locktime")?.Skip(1) + ?.FirstOrDefault(); + builder.Lock = rawUnixTs is not null && long.TryParse(rawUnixTs, out var unixTs) + ? DateTimeOffset.FromUnixTimeSeconds(unixTs) + : null; + + var refund = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "refund"); + if (refund is not null && refund.Length > 1) + { + builder.RefundPubkeys = refund.Skip(1).Select(s => s.ToPubKey()).ToArray(); + } + + var sigFlag = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "sigflag")?.Skip(1) + ?.FirstOrDefault(); + if (!string.IsNullOrEmpty(sigFlag)) + { + builder.SigFlag = sigFlag; + } + + var nSigs = proofSecret.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "n_sigs")?.Skip(1) + ?.FirstOrDefault(); + if (!string.IsNullOrEmpty(nSigs) && int.TryParse(nSigs, out var nSigsValue)) + { + builder.SignatureThreshold = nSigsValue; + } + builder.Nonce = proofSecret.Nonce; + + return builder; + } +} \ No newline at end of file diff --git a/DotNuts/PrivKey.cs b/DotNut/PrivKey.cs similarity index 93% rename from DotNuts/PrivKey.cs rename to DotNut/PrivKey.cs index 69afe3d..0b6861e 100644 --- a/DotNuts/PrivKey.cs +++ b/DotNut/PrivKey.cs @@ -1,6 +1,9 @@ using System.Text.Json.Serialization; +using DotNut.JsonConverters; using NBitcoin.Secp256k1; +namespace DotNut; + [JsonConverter(typeof(PrivKeyJsonConverter))] public class PrivKey { diff --git a/DotNut/Proof.cs b/DotNut/Proof.cs new file mode 100644 index 0000000..ac00d41 --- /dev/null +++ b/DotNut/Proof.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using DotNut.JsonConverters; + +// +// [JsonConverter(typeof(HexSecretJsonConverter))] +// public class HexSecret:IProofSecret +// { +// public byte[] Secret { get; set; } +// } +// + + +namespace DotNut; + +public class Proof +{ + [JsonPropertyName("amount")] public int Amount { get; set; } + + [JsonConverter(typeof(KeysetIdJsonConverter))] + [JsonPropertyName("id")] + public KeysetId Id { get; set; } + + [JsonPropertyName("secret")] public ISecret Secret { get; set; } + + [JsonPropertyName("C")] public PubKey C { get; set; } + + [JsonPropertyName("witness")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Witness { get; set; } + + + [JsonPropertyName("dleq")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DLEQProof? DLEQ { get; set; } + +} \ No newline at end of file diff --git a/DotNuts/PubKey.cs b/DotNut/PubKey.cs similarity index 94% rename from DotNuts/PubKey.cs rename to DotNut/PubKey.cs index 7be8c02..b17a81c 100644 --- a/DotNuts/PubKey.cs +++ b/DotNut/PubKey.cs @@ -1,6 +1,9 @@ using System.Text.Json.Serialization; +using DotNut.JsonConverters; using NBitcoin.Secp256k1; +namespace DotNut; + [JsonConverter(typeof(PubKeyJsonConverter))] public class PubKey { @@ -34,4 +37,5 @@ public static implicit operator ECPubKey(PubKey pubKey) { return pubKey.Key; } + } \ No newline at end of file diff --git a/DotNut/StringSecret.cs b/DotNut/StringSecret.cs new file mode 100644 index 0000000..5181db0 --- /dev/null +++ b/DotNut/StringSecret.cs @@ -0,0 +1,22 @@ +using NBitcoin.Secp256k1; + +namespace DotNut; + +public class StringSecret : ISecret +{ + public StringSecret(string secret) + { + Secret = secret; + } + + public string Secret { get; init; } + public byte[] GetBytes() + { + return System.Text.Encoding.UTF8.GetBytes(Secret); + } + + public ECPubKey ToCurve() + { + return Cashu.HashToCurve(GetBytes()); + } +} \ No newline at end of file diff --git a/DotNuts.sln.DotSettings.user b/DotNuts.sln.DotSettings.user index 31448c2..beea517 100644 --- a/DotNuts.sln.DotSettings.user +++ b/DotNuts.sln.DotSettings.user @@ -1,6 +1,12 @@  - <SessionState ContinuousTestingMode="0" IsActive="True" Name="Test1" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <SessionState ContinuousTestingMode="0" Name="Test1" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <TestAncestor> <TestId>xUnit::0AAAA85C-9FDC-4FD5-9CC2-ED8385B40106::net8.0::DotNuts.Tests.UnitTest1</TestId> </TestAncestor> +</SessionState> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="Nut11_Signatures" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <And> + <Namespace>DotNuts.Tests</Namespace> + <Project Location="C:\Git\DotNuts\DotNuts.Tests" Presentation="&lt;DotNuts.Tests&gt;" /> + </And> </SessionState> \ No newline at end of file diff --git a/DotNuts/DotNuts.csproj b/DotNuts/DotNuts.csproj deleted file mode 100644 index 68386a9..0000000 --- a/DotNuts/DotNuts.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - diff --git a/DotNuts/Proof.cs b/DotNuts/Proof.cs deleted file mode 100644 index 8c109ed..0000000 --- a/DotNuts/Proof.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using DotNuts; -using NBitcoin.Secp256k1; -using SHA256 = System.Security.Cryptography.SHA256; - -public class Proof -{ - [JsonPropertyName("amount")] public int Amount { get; set; } - - [JsonConverter(typeof(KeysetIdJsonConverter))] - [JsonPropertyName("id")] - public KeysetId Id { get; set; } - - [JsonPropertyName("secret")] public string Secret { get; set; } - - [JsonPropertyName("C")] public PubKey C { get; set; } - [JsonPropertyName("witness")][JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Witness { get; set; } -} - - -public static class P2PK -{ - - public class P2PkBuilder - { - public DateTimeOffset? Lock { get; set; } - public ECPubKey[]? RefundPubkeys { get; set; } - public int SignatureThreshold { get; set; } = 1; - public ECPubKey[] Pubkeys { get; set; } - public string? SigFlag { get; set; } - - public ProofSecret Build() - { - var tags = new List(); - if (!string.IsNullOrEmpty(SigFlag)) - { - tags.Add(new []{"sigflag",SigFlag}); - } - if(Lock.HasValue) - { - tags.Add(new []{"locktime",Lock.Value.ToUnixTimeSeconds().ToString()}); - if (RefundPubkeys?.Any() is true) - { - tags.Add(new []{"refund"}.Concat(RefundPubkeys.Select(p => Convert.ToHexString(p.ToBytes()).ToLower())).ToArray()); - } - } - if(SignatureThreshold > 1 && Pubkeys.Length >= SignatureThreshold ) - { - tags.Add(new []{"n_sigs",SignatureThreshold.ToString()}); - } - - return new ProofSecret() - { - Data = Convert.ToHexString(Pubkeys.First().ToBytes()).ToLower(), - Nonce = RandomNumberGenerator.GetHexString(32,true), - Tags = tags.ToArray() - }; - } - } - - - public static bool WitnessValid(this Proof proof) - { - //check if the secret is an array of json - try - { - var secretSet = JsonSerializer.Deserialize(proof.Secret); - if (secretSet is null) - { - return true; - } - - - if (!secretSet.TryGetValue("P2PK", out var proofSecret)) - { - return true; - } - - if (proofSecret.) - } - catch (Exception e) - { - return true; - } - } - - - public static ECPubKey[] ExtractValidPubkeys(this ProofSecret proof) - { - //check locktime - var locktime = proof.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "locktime")?.Skip(1) - ?.FirstOrDefault(); - if (!string.IsNullOrEmpty(locktime) && long.TryParse(locktime, out var locktimeValue) && - locktimeValue < DateTimeOffset.Now.ToUnixTimeSeconds()) - { - return ExtractRefundPubkeys(proof); - } - - var primary = proof.Data.ToPubKey(); - var extraPubKeys = proof.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "pubkeys"); - - if (extraPubKeys is not null && extraPubKeys.Length > 1) - { - return extraPubKeys.Skip(1).Select(s => s.ToPubKey()).Prepend(primary).ToArray(); - } - - return new[] {primary}; - } - - public static ECPubKey[] ExtractRefundPubkeys(this ProofSecret proof) - { - var extraPubKeys = proof.Tags?.FirstOrDefault(strings => strings.FirstOrDefault() == "refund"); - - if (extraPubKeys is not null && extraPubKeys.Length > 1) - { - return extraPubKeys.Skip(1).Select(s => s.ToPubKey()).ToArray(); - } - - return Array.Empty(); - } - - public static string GenerateWitness(this ProofSecret proof, ECPrivKey key) - { - var toSign = JsonSerializer.Serialize(proof); - using SHA256 sha256 = SHA256.Create(); - var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(toSign)); - if (proof.Tags?.Any() is true) - { - foreach (var tag in proof.Tags) - { - if (tag.Length < 1) - continue; - var cmd = tag.First(); - switch (cmd) - { - case "sigflag": - if (tag.Length < 2) - continue; - var sigHashFlag = tag[1]; - } - } - } - - var signature = key.SignBIP340(hash); - - return JsonSerializer.Serialize(new P2PKWitness {Signatures = [Convert.ToHexString(signature.ToBytes())]}); - } - - public class P2PKWitness - { - [JsonPropertyName("signatures")] public string[] Signatures { get; set; } - } -} \ No newline at end of file diff --git a/DotNuts/ProofCarol.cs b/DotNuts/ProofCarol.cs deleted file mode 100644 index 607bd8e..0000000 --- a/DotNuts/ProofCarol.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Text.Json.Serialization; - -public class ProofCarol:Proof -{ - [JsonPropertyName("dleq")] public DLEQ DLEQ { get; set; } -} \ No newline at end of file diff --git a/DotNuts/PubKeyJsonConverter.cs b/DotNuts/PubKeyJsonConverter.cs deleted file mode 100644 index c1bcf33..0000000 --- a/DotNuts/PubKeyJsonConverter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -public class PubKeyJsonConverter : JsonConverter -{ - public override PubKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - { - return null; - } - - if (reader.TokenType != JsonTokenType.String) - { - throw new JsonException("Expected string"); - } - - return new PubKey(reader.GetString(), true); - } - - public override void Write(Utf8JsonWriter writer, PubKey value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - return; - } - - writer.WriteStringValue(value.ToString()); - } -} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51f0e19 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Andrew Camilleri + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file