diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c8ba7ca --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "DotNut.BLS12-381"] + path = DotNut.BLS12-381 + url = https://github.com/d4rp4t/DotNut.BLS12-381.git diff --git a/DotNut.BLS12-381 b/DotNut.BLS12-381 new file mode 160000 index 0000000..9eaeb67 --- /dev/null +++ b/DotNut.BLS12-381 @@ -0,0 +1 @@ +Subproject commit 9eaeb6730144dd2008663d859a0c008b9176432f diff --git a/DotNut.Tests/Unit/BlsTests.cs b/DotNut.Tests/Unit/BlsTests.cs new file mode 100644 index 0000000..977505b --- /dev/null +++ b/DotNut.Tests/Unit/BlsTests.cs @@ -0,0 +1,599 @@ +using System.Text; +using System.Text.Json; +using DotNut.Abstractions; +using DotNut.BLS12_381.Curve.G1; +using DotNut.BLS12_381.Curve.G2; +using DotNut.Crypto; +using DotNut.NUT13; +using DotNut.NBitcoin.BIP39; + +namespace DotNut.Tests.Unit; + +public class BlsTests +{ + // Deterministic 32-byte mint private key: 0x01020304...1f20 + private static readonly byte[] MintPrivKey = + Enumerable.Range(1, 32).Select(i => (byte)i).ToArray(); + + + [Fact] + public void HashToCurveG1_IsDeterministic() + { + var msg = "hello world"u8.ToArray(); + var p1 = BlsCashu.HashToCurveG1(msg); + var p2 = BlsCashu.HashToCurveG1(msg); + Assert.Equal(p1.ToCompressed(), p2.ToCompressed()); + } + + [Fact] + public void HashToCurveG1_DifferentMessages_DifferentPoints() + { + var p1 = BlsCashu.HashToCurveG1("hello"u8.ToArray()); + var p2 = BlsCashu.HashToCurveG1("world"u8.ToArray()); + Assert.NotEqual(p1.ToCompressed(), p2.ToCompressed()); + } + + [Fact] + public void HashToCurveG1_ResultIsNotAtInfinity() + { + var p = BlsCashu.HashToCurveG1("cashu bls test"u8.ToArray()); + Assert.False(p.IsInfinity); + Assert.Equal(48, p.ToCompressed().Length); + } + + [Fact] + public void BlindMessage_RandomR_DifferentEachTime() + { + var secret = "random blind"u8.ToArray(); + var r1 = BlsCashu.GenerateRandomScalar(); + var b1 = BlsCashu.BlindMessage(secret, r1); + var r2 = BlsCashu.GenerateRandomScalar(); + var b2 = BlsCashu.BlindMessage(secret, r2); + // Collisions statistically impossible with 255-bit scalars + Assert.NotEqual(r1, r2); + Assert.NotEqual(b1.ToCompressed(), b2.ToCompressed()); + } + + [Fact] + public void BlindThenUnblind_RecoverskOriginalPoint() + { + var secretBytes = "unblind round-trip"u8.ToArray(); + var Y = BlsCashu.HashToCurveG1(secretBytes); + var r = BlsCashu.GenerateRandomScalar(); + // B_ = Y * r, then B_ * r^-1 must equal Y + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var recovered = BlsCashu.UnblindSignature(B_, r); + Assert.Equal(Y.ToCompressed(), recovered.ToCompressed()); + } + + [Fact] + public void FullBdhke_RoundTrip_VerifiesCorrectly() + { + var secretBytes = "the quick brown fox jumps over the lazy dog"u8.ToArray(); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var C = BlsCashu.UnblindSignature(C_, r); + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + + Assert.True(BlsCashu.VerifySignature(K2, C, secretBytes)); + } + + [Fact] + public void VerifySignature_WrongMintKey_ReturnsFalse() + { + var secretBytes = "test secret"u8.ToArray(); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var C = BlsCashu.UnblindSignature(C_, r); + + var wrongKey = new byte[32]; + wrongKey[31] = 99; + var wrongK2 = BlsCashu.GetG2PubKeyFromPrivKey(wrongKey); + + Assert.False(BlsCashu.VerifySignature(wrongK2, C, secretBytes)); + } + + [Fact] + public void VerifySignature_WrongSecret_ReturnsFalse() + { + var secretBytes = "correct secret"u8.ToArray(); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var C = BlsCashu.UnblindSignature(C_, r); + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + + Assert.False(BlsCashu.VerifySignature(K2, C, "wrong secret"u8.ToArray())); + } + + [Fact] + public void BatchVerifySignatures_EmptyList_ReturnsTrue() + { + Assert.True(BlsCashu.BatchVerifySignatures([])); + } + + [Fact] + public void BatchVerifySignatures_SingleValidItem_ReturnsTrue() + { + var secretBytes = "batch single"u8.ToArray(); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var C = BlsCashu.UnblindSignature(C_, r); + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + + Assert.True(BlsCashu.BatchVerifySignatures([(K2, C, secretBytes)])); + } + + [Fact] + public void BatchVerifySignatures_MultipleValidItems_ReturnsTrue() + { + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var items = new List<(G2Affine, G1Affine, byte[])>(); + for (int i = 0; i < 5; i++) + { + var secretBytes = Encoding.UTF8.GetBytes($"batch secret {i}"); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var C = BlsCashu.UnblindSignature(C_, r); + items.Add((K2, C, secretBytes)); + } + + Assert.True(BlsCashu.BatchVerifySignatures(items)); + } + + [Fact] + public void BatchVerifySignatures_OneForgedSignature_ReturnsFalse() + { + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var items = new List<(G2Affine, G1Affine, byte[])>(); + + // Two valid proofs + for (int i = 0; i < 2; i++) + { + var secretBytes = Encoding.UTF8.GetBytes($"valid {i}"); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var C = BlsCashu.UnblindSignature(C_, r); + items.Add((K2, C, secretBytes)); + } + + // One proof with mismatched secret (C was signed for different secret) + var realSecret = "real"u8.ToArray(); + var fakeR = BlsCashu.GenerateRandomScalar(); + var fakeB_ = BlsCashu.BlindMessage(realSecret, fakeR); + var fakeC_ = BlsCashu.CreateBlindSignature(fakeB_, MintPrivKey); + var fakeC = BlsCashu.UnblindSignature(fakeC_, fakeR); + items.Add((K2, fakeC, "different"u8.ToArray())); + + Assert.False(BlsCashu.BatchVerifySignatures(items)); + } + + [Theory] + [InlineData(10)] + public void GenerateRandomScalar_IsInFrRange(int iterations) + { + for (int i = 0; i < iterations; i++) + { + var r = BlsCashu.GenerateRandomScalar(); + Assert.Equal(32, r.Length); + Assert.NotEqual(new byte[32], r); + Assert.True(DotNut.BLS12_381.Scalar.TryFromBytesBigEndian(r, out _), + "GenerateRandomScalar must return a canonical Fr scalar"); + } + } + + [Fact] + public void GetG2PubKeyFromPrivKey_IsDeterministic() + { + var k1 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var k2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + Assert.Equal(k1.ToCompressed(), k2.ToCompressed()); + Assert.Equal(96, k1.ToCompressed().Length); // 96 bytes = 192 hex chars + } + + [Fact] + public void PubKey_G1_FromHex_SetsIsBlsG1() + { + var Y = BlsCashu.HashToCurveG1("g1 test"u8.ToArray()); + var hex = Convert.ToHexString(Y.ToCompressed()).ToLower(); + Assert.Equal(96, hex.Length); + + var pubKey = new PubKey(hex); + Assert.True(pubKey.IsBlsG1); + Assert.False(pubKey.IsBlsG2); + Assert.Null(pubKey.Key); + Assert.Equal(hex, pubKey.ToString()); + } + + [Fact] + public void PubKey_G2_FromHex_SetsIsBlsG2() + { + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var hex = Convert.ToHexString(K2.ToCompressed()).ToLower(); + Assert.Equal(192, hex.Length); + + var pubKey = new PubKey(hex); + Assert.True(pubKey.IsBlsG2); + Assert.False(pubKey.IsBlsG1); + Assert.Null(pubKey.Key); + Assert.Equal(hex, pubKey.ToString()); + } + + [Fact] + public void PubKey_G1_GetBlsG1Point_RoundTrip() + { + var Y = BlsCashu.HashToCurveG1("g1 round-trip"u8.ToArray()); + var pubKey = new PubKey(Convert.ToHexString(Y.ToCompressed()).ToLower()); + var recovered = pubKey.GetBlsG1Point(); + Assert.Equal(Y.ToCompressed(), recovered.ToCompressed()); + } + + [Fact] + public void PubKey_G2_GetBlsG2Point_RoundTrip() + { + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var pubKey = new PubKey(Convert.ToHexString(K2.ToCompressed()).ToLower()); + var recovered = pubKey.GetBlsG2Point(); + Assert.Equal(K2.ToCompressed(), recovered.ToCompressed()); + } + + [Fact] + public void PubKey_Secp_GetBlsG1Point_Throws() + { + var secpKey = (PubKey)"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".ToPubKey(); + Assert.Throws(() => secpKey.GetBlsG1Point()); + } + + [Fact] + public void PubKey_G1_GetBlsG2Point_Throws() + { + var g1Key = new PubKey( + Convert.ToHexString(BlsCashu.HashToCurveG1("x"u8.ToArray()).ToCompressed()).ToLower()); + Assert.Throws(() => g1Key.GetBlsG2Point()); + } + + [Fact] + public void PubKey_G1_EqualityAndHashCode() + { + var hex = Convert.ToHexString(BlsCashu.HashToCurveG1("eq"u8.ToArray()).ToCompressed()).ToLower(); + var p1 = new PubKey(hex); + var p2 = new PubKey(hex); + Assert.Equal(p1, p2); + Assert.Equal(p1.GetHashCode(), p2.GetHashCode()); + } + + [Fact] + public void PubKey_G2_NotEqualToG1_SameBytes() + { + // G1 (48 bytes) and G2 (96 bytes) have different hex lengths so they are different types + var g1Key = new PubKey(Convert.ToHexString(BlsCashu.HashToCurveG1("x"u8.ToArray()).ToCompressed()).ToLower()); + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var g2Key = new PubKey(Convert.ToHexString(K2.ToCompressed()).ToLower()); + Assert.NotEqual(g1Key, g2Key); + } + + // ─────────────────────────── KeysetId ─────────────────────────── + + [Theory] + [InlineData("009a1f293253e41e")] + [InlineData("015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a")] + public void KeysetId_IsBlsKeyset_FalseForNonBls(string id) + { + Assert.False(new KeysetId(id).IsBlsKeyset()); + } + + [Fact] + public void KeysetId_IsBlsKeyset_TrueFor02Prefix() + { + var id = new KeysetId("02" + new string('a', 64)); + Assert.True(id.IsBlsKeyset()); + Assert.Equal(0x02, id.GetVersion()); + } + + [Fact] + public void BlsKeyset_InheritsFromKeyset() + { + Assert.IsAssignableFrom(new BlsKeyset()); + } + + [Fact] + public void BlsKeyset_Amounts_AccessibleViaBaseKeyset() + { + var keyset = MakeBlsKeyset(); + // SplitToProofsAmounts uses base Keyset.Keys + var amounts = Utils.SplitToProofsAmounts(7, keyset); + Assert.Equal(new List { 4, 2, 1 }, amounts); + } + + [Fact] + public void BlsKeyset_GetKeysetId_IsDeterministic() + { + var keyset = MakeBlsKeyset(); + var id1 = keyset.GetKeysetId("sat"); + var id2 = keyset.GetKeysetId("sat"); + Assert.Equal(id1.ToString(), id2.ToString()); + } + + [Fact] + public void BlsKeyset_GetKeysetId_StartsWithV3Prefix() + { + var keyset = MakeBlsKeyset(); + var id = keyset.GetKeysetId("sat"); + Assert.StartsWith("02", id.ToString()); + Assert.True(id.IsBlsKeyset()); + } + + [Fact] + public void BlsKeyset_GetKeysetId_DifferentUnits_DifferentIds() + { + var keyset = MakeBlsKeyset(); + var sat = keyset.GetKeysetId("sat"); + var msat = keyset.GetKeysetId("msat"); + Assert.NotEqual(sat.ToString(), msat.ToString()); + } + + [Fact] + public void BlsKeyset_GetKeysetId_WithFee_DifferentFromWithout() + { + var keyset = MakeBlsKeyset(); + var noFee = keyset.GetKeysetId("sat"); + var withFee = keyset.GetKeysetId("sat", inputFeePpk: 100); + Assert.NotEqual(noFee.ToString(), withFee.ToString()); + } + + [Fact] + public void BlsKeyset_VerifyKeysetId_AcceptsCorrectId() + { + var keyset = MakeBlsKeyset(); + var id = keyset.GetKeysetId("sat"); + Assert.True(keyset.VerifyKeysetId(id, "sat")); + } + + [Fact] + public void BlsKeyset_VerifyKeysetId_RejectsWrongUnit() + { + var keyset = MakeBlsKeyset(); + var id = keyset.GetKeysetId("sat"); + Assert.False(keyset.VerifyKeysetId(id, "msat")); + } + + [Fact] + public void BlsKeyset_EmptyKeyset_GetKeysetId_Throws() + { + var keyset = new BlsKeyset(); + Assert.Throws(() => keyset.GetKeysetId("sat")); + } + + [Fact] + public void BlsKeyset_NullUnit_GetKeysetId_Throws() + { + var keyset = MakeBlsKeyset(); + Assert.Throws(() => keyset.GetKeysetId(null)); + } + + [Fact] + public void BlsKeyset_JsonRoundTrip() + { + var keyset = MakeBlsKeyset(); + var json = JsonSerializer.Serialize(keyset); + var parsed = JsonSerializer.Deserialize(json)!; + + Assert.Equal(keyset.Count, parsed.Count); + foreach (var (amount, key) in keyset) + { + Assert.True(parsed.ContainsKey(amount)); + Assert.Equal(key.ToString(), parsed[amount].ToString()); + Assert.True(parsed[amount].IsBlsG2); + } + } + + [Fact] + public void AnyKeysetJsonConverter_SecpKeyset_DeserializesAsKeyset() + { + // Test via GetKeysResponse, which is where AnyKeysetJsonConverter is used as a property converter + const string keysetJson = "{\"1\":\"03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc\"}"; + var responseJson = $$"""{"keysets":[{"id":"009a1f293253e41e","unit":"sat","active":true,"keys":{{keysetJson}}}]}"""; + var response = JsonSerializer.Deserialize(responseJson)!; + + Assert.IsNotType(response.Keysets[0].Keys); + Assert.NotNull(response.Keysets[0].Keys[1].Key); + } + + [Fact] + public void AnyKeysetJsonConverter_BlsKeyset_DeserializesAsBlsKeyset() + { + var keyset = MakeBlsKeyset(); + var keysetJson = JsonSerializer.Serialize(keyset); + var keysetId = keyset.GetKeysetId("sat"); + var responseJson = $$"""{"keysets":[{"id":"{{keysetId}}","unit":"sat","active":true,"keys":{{keysetJson}}}]}"""; + var response = JsonSerializer.Deserialize(responseJson)!; + + Assert.IsType(response.Keysets[0].Keys); + Assert.Equal(keyset.Count, response.Keysets[0].Keys.Count); + foreach (var (amount, _) in keyset) + Assert.True(response.Keysets[0].Keys[amount].IsBlsG2); + } + + [Fact] + public void BlsKeyset_InvalidHexLength_ThrowsOnDeserialize() + { + // 66-char hex (secp) is invalid for a BLS keyset + var badJson = "{\"1\":\"03a40f20667ed53513075dc51e715ff2046cad64eb68960632269ba7f0210e38bc\"}"; + Assert.ThrowsAny(() => JsonSerializer.Deserialize(badJson)); + } + + private static readonly Mnemonic TestMnemonic = new( + "half depart obvious quality work element tank gorilla view sugar picture humble"); + private static readonly KeysetId BlsKeysetId = new("02" + new string('b', 64)); + + [Fact] + public void Nut13_BLS_BlindingFactor_IsInFrRange() + { + for (uint i = 0; i < 5; i++) + { + var rBytes = TestMnemonic.DeriveBlindingFactor(BlsKeysetId, i); + Assert.Equal(32, rBytes.Length); + Assert.NotEqual(new byte[32], rBytes); + Assert.True(DotNut.BLS12_381.Scalar.TryFromBytesBigEndian(rBytes, out _), + $"counter {i}: blinding factor must be a canonical Fr scalar"); + } + } + + [Fact] + public void Nut13_BLS_BlindingFactor_IsDeterministic() + { + var r0a = TestMnemonic.DeriveBlindingFactor(BlsKeysetId, 0); + var r0b = TestMnemonic.DeriveBlindingFactor(BlsKeysetId, 0); + Assert.Equal(r0a, r0b); + } + + [Fact] + public void Nut13_BLS_BlindingFactor_DifferentCounters_DifferentResults() + { + var r0 = TestMnemonic.DeriveBlindingFactor(BlsKeysetId, 0); + var r1 = TestMnemonic.DeriveBlindingFactor(BlsKeysetId, 1); + Assert.NotEqual(r0, r1); + } + + [Fact] + public void Nut13_BLS_Secret_IsDeterministic() + { + var s0a = TestMnemonic.DeriveSecret(BlsKeysetId, 0).Secret; + var s0b = TestMnemonic.DeriveSecret(BlsKeysetId, 0).Secret; + Assert.Equal(s0a, s0b); + } + + [Fact] + public void Nut13_BLS_Secret_DifferentCounters_DifferentResults() + { + var s0 = TestMnemonic.DeriveSecret(BlsKeysetId, 0).Secret; + var s1 = TestMnemonic.DeriveSecret(BlsKeysetId, 1).Secret; + Assert.NotEqual(s0, s1); + } + + [Fact] + public void Nut13_BLS_DeriveOutputs_BlindedMessagesAreG1Points() + { + var outputs = TestMnemonic.DeriveOutputs(new ulong[] { 1, 2, 4 }, BlsKeysetId, 0); + Assert.Equal(3, outputs.Count); + foreach (var o in outputs) + { + Assert.True(o.BlindedMessage.B_.IsBlsG1, + "BLS output's B_ must be a G1 point"); + } + } + + [Fact] + public void ConstructBlsProofFromPromise_ProducesVerifiableProof() + { + var secretStr = "cashu bls proof secret"; + var secretBytes = Encoding.UTF8.GetBytes(secretStr); + + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + var C_ = BlsCashu.CreateBlindSignature(B_, MintPrivKey); + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); + var amountKey = new BlsG2PubKey(K2); + + var rPriv = new PrivKey(r); + var blindSig = new BlindSignature + { + Amount = 1, + Id = new KeysetId("02" + new string('0', 64)), + C_ = new PubKey(Convert.ToHexString(C_.ToCompressed()).ToLower()), + }; + + var proof = Utils.ConstructBlsProofFromPromise(blindSig, rPriv, new StringSecret(secretStr), amountKey); + + Assert.Equal(1UL, proof.Amount); + Assert.True(proof.C.IsBlsG1); + Assert.True(BlsCashu.VerifySignature(K2, proof.C.GetBlsG1Point(), secretBytes)); + } + + [Fact] + public void ConstructBlsProofFromPromise_WrongKey_Throws() + { + var secretBytes = "real secret"u8.ToArray(); + var r = BlsCashu.GenerateRandomScalar(); + var B_ = BlsCashu.BlindMessage(secretBytes, r); + + var wrongKey = new byte[32]; + wrongKey[31] = 99; + var C_ = BlsCashu.CreateBlindSignature(B_, wrongKey); // signed by wrongKey + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(MintPrivKey); // verified against MintPrivKey + + var blindSig = new BlindSignature + { + Amount = 1, + Id = new KeysetId("02" + new string('0', 64)), + C_ = new PubKey(Convert.ToHexString(C_.ToCompressed()).ToLower()), + }; + + Assert.Throws(() => + Utils.ConstructBlsProofFromPromise( + blindSig, + new PrivKey(r), + new StringSecret("real secret"), + new BlsG2PubKey(K2))); + } + + [Fact] + public void ConstructProofsFromPromises_BlsKeyset_FullRoundTrip() + { + var blsKeyset = MakeBlsKeyset(); + var keysetId = blsKeyset.GetKeysetId("sat"); + + var outputs = Utils.CreateOutputs(new ulong[] { 1, 2 }, keysetId, blsKeyset); + Assert.Equal(2, outputs.Count); + + // Simulate mint: sign each blinded message with the per-amount key + var blindSigs = outputs.Select(o => + { + var B_ = o.BlindedMessage.B_.GetBlsG1Point(); + var mintKey = GetMintKeyForAmount(o.BlindedMessage.Amount); + var C_ = BlsCashu.CreateBlindSignature(B_, mintKey); + return new BlindSignature + { + Amount = o.BlindedMessage.Amount, + Id = keysetId, + C_ = new PubKey(Convert.ToHexString(C_.ToCompressed()).ToLower()), + }; + }).ToList(); + + var proofs = Utils.ConstructProofsFromPromises(blindSigs, outputs, blsKeyset); + + Assert.Equal(2, proofs.Count); + foreach (var proof in proofs) + { + Assert.True(proof.C.IsBlsG1); + var K2 = blsKeyset[proof.Amount].GetBlsG2Point(); + Assert.True(BlsCashu.VerifySignature(K2, proof.C.GetBlsG1Point(), proof.Secret.GetBytes())); + } + } + + private static BlsKeyset MakeBlsKeyset() + { + var keyset = new BlsKeyset(); + foreach (var amount in new ulong[] { 1, 2, 4, 8 }) + { + var K2 = BlsCashu.GetG2PubKeyFromPrivKey(GetMintKeyForAmount(amount)); + keyset[amount] = new PubKey(Convert.ToHexString(K2.ToCompressed()).ToLower()); + } + return keyset; + } + + private static byte[] GetMintKeyForAmount(ulong amount) + { + var key = new byte[32]; + key[0] = 1; // ensure non-zero + key[30] = (byte)((amount >> 8) & 0xFF); + key[31] = (byte)(amount & 0xFF); + return key; + } + +} diff --git a/DotNut.sln b/DotNut.sln index c8a95fd..477aa37 100644 --- a/DotNut.sln +++ b/DotNut.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNut.Nostr", "DotNut.Nost EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNut.Demo", "DotNut.Demo\DotNut.Demo.csproj", "{305097F3-A4E5-4511-8E4E-0C4C12A953C6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNut.BLS12-381", "DotNut.BLS12-381\DotNut.BLS12-381\DotNut.BLS12-381.csproj", "{858306C5-EAE6-4CD0-B120-C40040BCCFFD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -66,6 +68,18 @@ Global {305097F3-A4E5-4511-8E4E-0C4C12A953C6}.Release|x64.Build.0 = Release|Any CPU {305097F3-A4E5-4511-8E4E-0C4C12A953C6}.Release|x86.ActiveCfg = Release|Any CPU {305097F3-A4E5-4511-8E4E-0C4C12A953C6}.Release|x86.Build.0 = Release|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Debug|x64.ActiveCfg = Debug|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Debug|x64.Build.0 = Debug|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Debug|x86.ActiveCfg = Debug|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Debug|x86.Build.0 = Debug|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Release|Any CPU.Build.0 = Release|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Release|x64.ActiveCfg = Release|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Release|x64.Build.0 = Release|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Release|x86.ActiveCfg = Release|Any CPU + {858306C5-EAE6-4CD0-B120-C40040BCCFFD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DotNut/Abstractions/SwapBuilder.cs b/DotNut/Abstractions/SwapBuilder.cs index d202cfc..0ac126f 100644 --- a/DotNut/Abstractions/SwapBuilder.cs +++ b/DotNut/Abstractions/SwapBuilder.cs @@ -151,6 +151,8 @@ await _wallet.GetActiveKeysetId(this._unit, ct) { if (proof.DLEQ == null) { + // BLS v3 proofs don't use DLEQ — skip client-side verification. + if (proof.Id.IsBlsKeyset()) continue; throw new ArgumentNullException( nameof(proof.DLEQ), "Can't verify non-existent DLEQ proof!" diff --git a/DotNut/Abstractions/Utils.cs b/DotNut/Abstractions/Utils.cs index 9349cbb..8bbc257 100644 --- a/DotNut/Abstractions/Utils.cs +++ b/DotNut/Abstractions/Utils.cs @@ -1,5 +1,7 @@ using System.Security.Cryptography; +using System.Text; using System.Text.Json; +using DotNut.Crypto; using DotNut.NUT13; namespace DotNut.Abstractions; @@ -100,25 +102,31 @@ public static List CreateOutputs( var outputs = new List(amountsList.Count); + var isBls = keysetId.IsBlsKeyset(); + if (mnemonic is not null && counter is { } c) { for (uint i = 0; i < amountsList.Count; i++) { var secret = mnemonic.DeriveSecret(keysetId, c + i); - var r = new PrivKey(mnemonic.DeriveBlindingFactor(keysetId, c + i)); - var B_ = Cashu.ComputeB_(secret.ToCurve(), r); - var output = new OutputData + var rBytes = mnemonic.DeriveBlindingFactor(keysetId, c + i); + var r = new PrivKey(rBytes); + PubKey B_; + if (isBls) + { + var b_ = BlsCashu.BlindMessage(secret.GetBytes(), rBytes); + B_ = new PubKey(b_); + } + else { - BlindedMessage = new BlindedMessage - { - Amount = amountsList[(int)i], - B_ = B_, - Id = keysetId, - }, + B_ = Cashu.ComputeB_(secret.ToCurve(), r); + } + outputs.Add(new OutputData + { + BlindedMessage = new BlindedMessage { Amount = amountsList[(int)i], B_ = B_, Id = keysetId }, BlindingFactor = r, Secret = secret, - }; - outputs.Add(output); + }); } return outputs; } @@ -126,20 +134,26 @@ public static List CreateOutputs( foreach (var amount in amountsList) { var secret = RandomSecret(); - var r = RandomPrivkey(); - var B_ = Cashu.ComputeB_(secret.ToCurve(), r); - var output = new OutputData + PubKey B_; + PrivKey r; + if (isBls) { - BlindedMessage = new BlindedMessage - { - Amount = amount, - B_ = B_, - Id = keysetId, - }, + var rBytes = BlsCashu.GenerateRandomScalar(); + r = new PrivKey(rBytes); + var b_ = BlsCashu.BlindMessage(secret.GetBytes(), rBytes); + B_ = new PubKey(b_); + } + else + { + r = RandomPrivkey(); + B_ = Cashu.ComputeB_(secret.ToCurve(), r); + } + outputs.Add(new OutputData + { + BlindedMessage = new BlindedMessage { Amount = amount, B_ = B_, Id = keysetId }, BlindingFactor = r, Secret = secret, - }; - outputs.Add(output); + }); } return outputs; } @@ -277,11 +291,7 @@ public static Proof ConstructProofFromPromise( PubKey? P2PkE = null ) { - //unblind signature var C = Cashu.ComputeC(promise.C_, r, amountPubkey); - - DLEQProof? dleq = null; - var proof = new Proof { Id = promise.Id, @@ -290,23 +300,40 @@ public static Proof ConstructProofFromPromise( C = C, P2PkE = P2PkE, }; + if (promise.DLEQ is null) return proof; + proof.DLEQ = new DLEQProof { E = promise.DLEQ.E, S = promise.DLEQ.S, R = r.Key.Clone() }; + if (!proof.Verify(amountPubkey)) + throw new InvalidOperationException("Could not verify mint signature on proof"); + return proof; + } - if (promise.DLEQ is null) - { - return proof; - } + /// + /// Constructs a v3 (BLS12-381) proof from a blind signature. + /// Performs multiplicative unblinding and pairing verification. + /// + public static Proof ConstructBlsProofFromPromise( + BlindSignature promise, + PrivKey r, + ISecret secret, + BlsG2PubKey amountKey + ) + { + if (!promise.C_.IsBlsG1) + throw new InvalidOperationException("Expected BLS G1 blind signature C_"); - proof.DLEQ = new DLEQProof + var C = BlsCashu.UnblindSignature(promise.C_.GetBlsG1Point(), r.Key.ToBytes()); + + var secretBytes = secret.GetBytes(); + if (!BlsCashu.VerifySignature(amountKey.Point, C, secretBytes)) + throw new InvalidOperationException("Could not verify BLS mint signature on proof"); + + return new Proof { - E = promise.DLEQ.E, - S = promise.DLEQ.S, - R = r.Key.Clone(), + Id = promise.Id, + Amount = promise.Amount, + Secret = secret, + C = new PubKey(C), }; - if (!proof.Verify(amountPubkey)) - { - throw new InvalidOperationException($"Could not verify mint signature on proof"); - } - return proof; } public static List ConstructProofsFromPromises( @@ -315,31 +342,47 @@ public static List ConstructProofsFromPromises( Keyset keys ) { + if (keys is BlsKeyset blsKeys) + return ConstructProofsFromPromises(promises, outputs, blsKeys); + var bs = promises as IReadOnlyList ?? promises.ToList(); var os = outputs as IReadOnlyList ?? outputs.ToList(); if (os.Count < bs.Count) + throw new ArgumentException("Outputs must at least equal amount of elements!"); + + var proofs = new List(bs.Count); + for (int i = 0; i < bs.Count; i++) { - throw new ArgumentException("Outputs must as least equal amount of elements!"); + if (!keys.TryGetValue(bs[i].Amount, out var key)) + throw new ArgumentException( + $"Provided keyset doesn't contain PubKey for amount {bs[i].Amount}"); + proofs.Add(ConstructProofFromPromise(bs[i], os[i].BlindingFactor, os[i].Secret, key, os[i].P2BkE)); } + return proofs; + } - List proofs = new List(bs.Count); + /// Constructs v3 (BLS12-381) proofs from blind signatures using the mint's G2 keys. + public static List ConstructProofsFromPromises( + IEnumerable promises, + IEnumerable outputs, + BlsKeyset keys + ) + { + var bs = promises as IReadOnlyList ?? promises.ToList(); + var os = outputs as IReadOnlyList ?? outputs.ToList(); + if (os.Count < bs.Count) + throw new ArgumentException("Outputs must at least equal amount of elements!"); + + var proofs = new List(bs.Count); for (int i = 0; i < bs.Count; i++) { if (!keys.TryGetValue(bs[i].Amount, out var key)) - { throw new ArgumentException( - $"Provided keyset doesn't contain PubKey for amount {bs[i].Amount}" - ); - } - - var proof = ConstructProofFromPromise( - bs[i], - os[i].BlindingFactor, - os[i].Secret, - key, - os[i].P2BkE - ); - proofs.Add(proof); + $"Provided BLS keyset doesn't contain key for amount {bs[i].Amount}"); + if (!key.IsBlsG2) + throw new InvalidOperationException($"Keyset entry for amount {bs[i].Amount} is not a BLS G2 key"); + proofs.Add(ConstructBlsProofFromPromise(bs[i], os[i].BlindingFactor, os[i].Secret, + new BlsG2PubKey(key.GetBlsG2Point()))); } return proofs; } diff --git a/DotNut/ApiModels/GetKeysResponse.cs b/DotNut/ApiModels/GetKeysResponse.cs index 242a799..b843654 100644 --- a/DotNut/ApiModels/GetKeysResponse.cs +++ b/DotNut/ApiModels/GetKeysResponse.cs @@ -26,7 +26,12 @@ public class KeysetItemResponse [JsonPropertyName("final_expiry")] public ulong? FinalExpiry { get; set; } + /// + /// The keyset public keys. For secp256k1 (v0/v1/v2) keysets this is a ; + /// for BLS12-381 v3 keysets this is a . Use is BlsKeyset to distinguish. + /// [JsonPropertyName("keys")] + [JsonConverter(typeof(JsonConverters.AnyKeysetJsonConverter))] public Keyset Keys { get; set; } } } diff --git a/DotNut/BlsG1PubKey.cs b/DotNut/BlsG1PubKey.cs new file mode 100644 index 0000000..351b41d --- /dev/null +++ b/DotNut/BlsG1PubKey.cs @@ -0,0 +1,66 @@ +using DotNut.BLS12_381.Curve.G1; + +namespace DotNut; + +/// +/// A BLS12-381 G1 compressed point (48 bytes / 96 hex chars). +/// Used for proof commitments (C) and blinded messages (B_) in v3 keysets. +/// +public class BlsG1PubKey +{ + public readonly G1Affine Point; + + public BlsG1PubKey(G1Affine point) + { + if (point.IsInfinity) + throw new ArgumentException("G1 point at infinity is not valid"); + Point = point; + } + + public BlsG1PubKey(string hex, bool compressed = true) + { + var bytes = Convert.FromHexString(hex); + if (compressed) + { + if (!G1Affine.TryFromCompressed(bytes, out var pt)) + throw new ArgumentException($"Invalid G1 compressed point: {hex}"); + if (pt.IsInfinity) + throw new ArgumentException("G1 point at infinity is not valid"); + Point = pt; + } + else + { + if (!G1Affine.TryFromUncompressed(bytes, out var pt)) + throw new ArgumentException($"Invalid G1 uncompressed point: {hex}"); + if (pt.IsInfinity) + throw new ArgumentException("G1 point at infinity is not valid"); + Point = pt; + } + } + + public BlsG1PubKey(byte[] compressed) + { + if (!G1Affine.TryFromCompressed(compressed, out var point)) + throw new ArgumentException("Invalid G1 compressed bytes"); + if (point.IsInfinity) + throw new ArgumentException("G1 point at infinity is not valid"); + Point = point; + } + + public byte[] ToCompressedBytes() => Point.ToCompressed(); + + // 96 hex chars (48 compressed bytes) + public override string ToString() => Convert.ToHexString(ToCompressedBytes()).ToLower(); + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) return true; + return obj is BlsG1PubKey other && Point == other.Point; + } + + public override int GetHashCode() => Point.GetHashCode(); + + public static implicit operator G1Affine(BlsG1PubKey a) => a.Point; + public static implicit operator G1Projective(BlsG1PubKey a) => a.Point.ToProjective(); + public static implicit operator BlsG1PubKey(G1Affine a) => new(a); +} diff --git a/DotNut/BlsG2PubKey.cs b/DotNut/BlsG2PubKey.cs new file mode 100644 index 0000000..e3cd497 --- /dev/null +++ b/DotNut/BlsG2PubKey.cs @@ -0,0 +1,51 @@ +using DotNut.BLS12_381.Curve.G2; + +namespace DotNut; + +/// +/// A BLS12-381 G2 compressed point (96 bytes / 192 hex chars). +/// Used for mint public keys in v3 keysets. +/// +public class BlsG2PubKey +{ + public readonly G2Affine Point; + + public BlsG2PubKey(G2Affine point) + { + if (point.IsInfinity) + throw new ArgumentException("G2 point at infinity is not valid"); + Point = point; + } + + public BlsG2PubKey(string hex) + { + var bytes = Convert.FromHexString(hex); + if (!G2Affine.TryFromCompressed(bytes, out var point)) + throw new ArgumentException($"Invalid G2 compressed point: {hex}"); + if (point.IsInfinity) + throw new ArgumentException("G2 point at infinity is not valid"); + Point = point; + } + + public BlsG2PubKey(byte[] compressed) + { + if (!G2Affine.TryFromCompressed(compressed, out var point)) + throw new ArgumentException("Invalid G2 compressed bytes"); + if (point.IsInfinity) + throw new ArgumentException("G2 point at infinity is not valid"); + Point = point; + } + + public byte[] ToCompressedBytes() => Point.ToCompressed(); + + // 192 hex chars (96 compressed bytes) + public override string ToString() => Convert.ToHexString(ToCompressedBytes()).ToLower(); + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) return true; + return obj is BlsG2PubKey other && Point == other.Point; + } + + public override int GetHashCode() => Point.GetHashCode(); +} diff --git a/DotNut/Crypto/BlsCashu.cs b/DotNut/Crypto/BlsCashu.cs new file mode 100644 index 0000000..1fd992c --- /dev/null +++ b/DotNut/Crypto/BlsCashu.cs @@ -0,0 +1,236 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using DotNut.BLS12_381; +using DotNut.BLS12_381.Curve.G1; +using DotNut.BLS12_381.Curve.G2; +using DotNut.BLS12_381.HashToCurve; +using DotNut.BLS12_381.Pairing; + +namespace DotNut.Crypto; + +public static class BlsCashu +{ + public static readonly byte[] HashToCurveDst = + "CASHU_BLS12_381_G1_XMD:SHA-256_SSWU_RO_"u8.ToArray(); + + public static readonly G2Affine G2Generator = G2Affine.Generator; + + private static readonly byte[] BatchDst = "Cashu_BLS_Batch_v1"u8.ToArray(); + + public static G1Affine HashToCurveG1(byte[] message) => + HashToCurve.HashToG1(message, HashToCurveDst); + + /// + /// Multiplicative blinding for v3 keysets: B_ = Y · r. + /// + public static G1Affine BlindMessage(byte[] secret, byte[] r) + { + var Y = HashToCurveG1(secret); + return (Y.ToProjective() * Scalar.FromBytesBigEndian(r)).ToAffine(); + } + + /// + /// Wallet-side unblinding: C = C_ · r⁻¹. + /// + public static G1Affine UnblindSignature(G1Affine C_, byte[] r) + { + var rScalar = Scalar.FromBytesBigEndian(r); + if (Scalar.IsZero(rScalar)) + throw new ArgumentException("Blinding factor reduces to zero mod Fr"); + var rInv = Scalar.Invert(rScalar); + return (C_.ToProjective() * rInv).ToAffine(); + } + + /// + /// Mint-side blind signing: C_ = B_ · a. + /// + public static G1Affine CreateBlindSignature(G1Affine B_, byte[] privateKey) + { + var a = ScalarFromKeyBytes(privateKey); + if (Scalar.IsZero(a)) + throw new ArgumentException("Mint scalar must be non-zero"); + return (B_.ToProjective() * a).ToAffine(); + } + + /// + /// V3 mint public key: K2 = a · G2_gen (compressed 96 bytes). + /// + public static G2Affine GetG2PubKeyFromPrivKey(byte[] privateKey) + { + var a = ScalarFromKeyBytes(privateKey); + if (Scalar.IsZero(a)) + throw new ArgumentException("Mint scalar must be non-zero"); + return (G2Projective.Generator * a).ToAffine(); + } + + /// + /// Wallet-side verification: e(C, G2_gen) == e(Y, K2). + /// Implemented as e(-C, G2_gen) · e(Y, K2) == 1 via a single multi-pairing. + /// + public static bool VerifySignature(G2Affine K2, G1Affine C, byte[] secret) + { + if (C.IsInfinity || K2.IsInfinity) return false; + var Y = HashToCurveG1(secret); + var result = Bls12Pairing.MultiMillerLoop([ + (-C, G2Prepared.From(G2Generator)), + (Y, G2Prepared.From(K2)), + ]).FinalExponentiation(); + return Gt.Equal(result, Gt.Identity); + } + + /// + /// Batch verify many v3 proofs via a single multi-pairing with Fiat-Shamir weights. + /// Safe against aggregation attacks: each proof gets an independent random weight. + /// + public static bool BatchVerifySignatures( + IReadOnlyList<(G2Affine K2, G1Affine C, byte[] Secret)> items) + { + if (items.Count == 0) + { + return true; + } + foreach (var it in items) + { + if (it.C.IsInfinity || it.K2.IsInfinity) + { + return false; + } + } + + var rs = DeriveBatchWeights(items); + + // Left: Σ rᵢ·Cᵢ, then pair against G2 + var sumC = items[0].C.ToProjective() * rs[0]; + for (int i = 1; i < items.Count; i++) + { + sumC = sumC + (items[i].C.ToProjective() * rs[i]); + } + + // Right: group rᵢ·Yᵢ by K2 + var grouped = new Dictionary(); + for (int i = 0; i < items.Count; i++) + { + var Y = HashToCurveG1(items[i].Secret); + var term = Y.ToProjective() * rs[i]; + var key = Convert.ToHexString(items[i].K2.ToCompressed()).ToLower(); + if (grouped.TryGetValue(key, out var existing)) + { + grouped[key] = (existing.K2, existing.SumY + term); + } + else + { + grouped[key] = (items[i].K2, term); + } + } + + var pairs = new List<(G1Affine, G2Prepared)> + { + (-sumC.ToAffine(), G2Prepared.From(G2Generator)) + }; + foreach (var (_, (k2, sumY)) in grouped) + { + pairs.Add((sumY.ToAffine(), G2Prepared.From(k2))); + } + + var result = Bls12Pairing.MultiMillerLoop(pairs).FinalExponentiation(); + return Gt.Equal(result, Gt.Identity); + } + + /// + /// Derive deterministic batch weights via Fiat-Shamir over the full batch transcript. + /// + internal static Scalar[] DeriveBatchWeights( + IReadOnlyList<(G2Affine K2, G1Affine C, byte[] Secret)> items) + { + // Transcript: BatchDst || (C48 || K296 || len32(secret) || secret) per item + using var ms = new MemoryStream(); + ms.Write(BatchDst); + Span lenBuf = stackalloc byte[4]; + foreach (var it in items) + { + ms.Write(it.C.ToCompressed()); + ms.Write(it.K2.ToCompressed()); + BinaryPrimitives.WriteInt32BigEndian(lenBuf, it.Secret.Length); + ms.Write(lenBuf); + ms.Write(it.Secret); + } + var challenge = SHA256.HashData(ms.ToArray()); + + var rs = new Scalar[items.Count]; + Span iBuf = stackalloc byte[4]; + Span wide = stackalloc byte[64]; + for (int i = 0; i < items.Count; i++) + { + BinaryPrimitives.WriteInt32BigEndian(iBuf, i); + bool found = false; + for (int ctr = 0; ctr < 256; ctr++) + { + var h = SHA256.HashData([..challenge, ..iBuf, (byte)ctr]); + // Place the 32-byte hash in the lo word of a 64-byte LE buffer for wide reduction. + wide.Clear(); + for (int b = 0; b < 32; b++) + { + wide[b] = h[31 - b]; + } // big-endian → LE + var s = Scalar.FromBytesWide(wide); + if (!Scalar.IsZero(s)) { rs[i] = s; found = true; break; } + } + if (!found) + { + throw new InvalidOperationException("BLS batch weight derivation failed"); + } + } + return rs; + } + + /// + /// Generates a random non-zero BLS12-381 Fr scalar, returned as 32 big-endian bytes. + /// + public static byte[] GenerateRandomScalar() + { + Span buf = stackalloc byte[32]; + while (true) + { + RandomNumberGenerator.Fill(buf); + if (Scalar.TryFromBytesBigEndian(buf, out var s) && !Scalar.IsZero(s)) + { + var result = new byte[32]; + Scalar.ToBytesBigEndian(s, result); + return result; + } + } + } + + /// + /// Reduces a 32-byte big-endian value mod Fr using wide reduction (safe for HMAC output). + /// Returns 32 big-endian bytes of the result. Throws if result is zero. + /// + internal static byte[] ReduceHmacToScalarBytes(ReadOnlySpan hmac32) + { + if (hmac32.Length != 32) + { + throw new ArgumentException("Expected 32 bytes", nameof(hmac32)); + } + // FromBytesWide takes 64 LE bytes: lo=bytes[0..32], hi=bytes[32..64]. + // To place hmac in the lo word: reverse to LE then pad hi with zeros. + Span wide = stackalloc byte[64]; + for (int i = 0; i < 32; i++) + { + wide[i] = hmac32[31 - i]; // big-endian → little-endian in lo word + } + + var s = Scalar.FromBytesWide(wide); + if (Scalar.IsZero(s)) + { + throw new InvalidOperationException("HMAC-derived BLS scalar is zero"); + } + var result = new byte[32]; + Scalar.ToBytesBigEndian(s, result); + return result; + } + + // Converts a big-endian 32-byte private key to a Scalar. + // Private keys are canonical (< r) by construction, so FromBytesBigEndian is safe. + private static Scalar ScalarFromKeyBytes(byte[] keyBytes) => + Scalar.FromBytesBigEndian(keyBytes); +} diff --git a/DotNut/DotNut.csproj b/DotNut/DotNut.csproj index 25bc9c0..a977885 100644 --- a/DotNut/DotNut.csproj +++ b/DotNut/DotNut.csproj @@ -23,4 +23,10 @@ + + + + + + diff --git a/DotNut/Encoding/CashuTokenV4Encoder.cs b/DotNut/Encoding/CashuTokenV4Encoder.cs index 47b7b85..ec07e8b 100644 --- a/DotNut/Encoding/CashuTokenV4Encoder.cs +++ b/DotNut/Encoding/CashuTokenV4Encoder.cs @@ -41,7 +41,7 @@ var proofSet in token .NewOrderedMap() .Add("a", proof.Amount) .Add("s", Encoding.UTF8.GetString(proof.Secret.GetBytes())) - .Add("c", proof.C.Key.ToBytes()); + .Add("c", proof.C.IsBlsG1 ? proof.C.GetBlsG1Point().ToCompressed() : proof.C.Key!.ToBytes()); if (proof.DLEQ is not null) { proofItem.Add( diff --git a/DotNut/JsonConverters/AnyKeysetJsonConverter.cs b/DotNut/JsonConverters/AnyKeysetJsonConverter.cs new file mode 100644 index 0000000..055fa65 --- /dev/null +++ b/DotNut/JsonConverters/AnyKeysetJsonConverter.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNut.JsonConverters; + +/// +/// Reads a keyset JSON object as either (secp256k1, 66-char hex values) +/// or (BLS12-381 G2, 192-char hex values). +/// Registered as the [JsonConverter] on so any field typed as +/// gets polymorphic dispatch automatically. +/// +public class AnyKeysetJsonConverter : JsonConverter +{ + public override Keyset? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Expected object for keyset"); + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + bool isBls = false; + foreach (var prop in root.EnumerateObject()) + { + var val = prop.Value.GetString(); + if (val is null) continue; + if (val.Length == 192) { isBls = true; break; } + if (val.Length == 66 || val.Length == 96) break; + } + + var jsonBytes = System.Text.Encoding.UTF8.GetBytes(root.GetRawText()); + + if (isBls) + { + // BlsKeyset has [JsonConverter(typeof(BlsKeysetJsonConverter))] — no recursion. + return JsonSerializer.Deserialize(jsonBytes, options); + } + + // Call KeysetJsonConverter directly to avoid re-entering this converter + // (Keyset itself is now annotated with AnyKeysetJsonConverter). + var innerReader = new Utf8JsonReader(jsonBytes); + innerReader.Read(); + return new KeysetJsonConverter().Read(ref innerReader, typeof(Keyset), options); + } + + public override void Write(Utf8JsonWriter writer, Keyset? value, JsonSerializerOptions options) + { + if (value is BlsKeyset blsKeyset) + JsonSerializer.Serialize(writer, blsKeyset, options); + else if (value is not null) + new KeysetJsonConverter().Write(writer, value, options); + else + writer.WriteNullValue(); + } +} diff --git a/DotNut/JsonConverters/BlsKeysetJsonConverter.cs b/DotNut/JsonConverters/BlsKeysetJsonConverter.cs new file mode 100644 index 0000000..d85d9e8 --- /dev/null +++ b/DotNut/JsonConverters/BlsKeysetJsonConverter.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DotNut.JsonConverters; + +public class BlsKeysetJsonConverter : JsonConverter +{ + public override BlsKeyset? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected object"); + } + + var keyset = new BlsKeyset(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return keyset; + } + + ulong amount; + if (reader.TokenType == JsonTokenType.Number) + { + amount = reader.GetUInt64(); + } + else if (reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName) + { + var s = reader.GetString(); + if (string.IsNullOrEmpty(s)) + { + throw new JsonException("Expected string key"); + } + amount = ulong.Parse(s); + } + else + { + throw new JsonException("Expected number or string key"); + } + + reader.Read(); + var hex = reader.GetString(); + if (string.IsNullOrEmpty(hex) || hex.Length != 192) + { + throw new JsonException($"Expected 192-char G2 compressed hex, got length {hex?.Length}"); + } + + var g2Key = new BlsG2PubKey(hex); + keyset[amount] = new PubKey(g2Key.Point); + } + throw new JsonException("Missing end object"); + } + + public override void Write(Utf8JsonWriter writer, BlsKeyset? value, JsonSerializerOptions options) + { + if (value is null) { writer.WriteNullValue(); return; } + writer.WriteStartObject(); + foreach (var (amount, key) in value) + { + writer.WritePropertyName(amount.ToString()); + writer.WriteStringValue(key.ToString()); // 192-hex for G2 PubKey + } + writer.WriteEndObject(); + } +} diff --git a/DotNut/JsonConverters/KeysetJsonConverter.cs b/DotNut/JsonConverters/KeysetJsonConverter.cs index 5b7b4eb..5ba41de 100644 --- a/DotNut/JsonConverters/KeysetJsonConverter.cs +++ b/DotNut/JsonConverters/KeysetJsonConverter.cs @@ -47,8 +47,19 @@ JsonSerializerOptions options reader.Read(); var pubkey = JsonSerializer.Deserialize(ref reader, options); - if (pubkey is null || pubkey.Key.ToBytes().Length != 33) - throw new JsonException("Invalid public key (not compressed?)"); + if (pubkey is null) + { + throw new JsonException("Invalid public key"); + } + if (pubkey.IsBlsG1) + { + throw new JsonException( + "Key is a BLS12-381 G1 point — this is a BLS v3 keyset. Deserialize as BlsKeyset."); + } + if (pubkey.Key is null || pubkey.Key.ToBytes().Length != 33) + { + throw new JsonException("Invalid secp256k1 public key (not compressed?)"); + } keyset.Add(amount, pubkey); } diff --git a/DotNut/JsonConverters/Nut10SecretJsonConverter.cs b/DotNut/JsonConverters/Nut10SecretJsonConverter.cs index 7dbc211..58cdd6e 100644 --- a/DotNut/JsonConverters/Nut10SecretJsonConverter.cs +++ b/DotNut/JsonConverters/Nut10SecretJsonConverter.cs @@ -12,14 +12,18 @@ 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(); @@ -37,7 +41,9 @@ JsonSerializerOptions options throw new JsonException("Unknown secret type"); } if (proofSecret is null) + { throw new JsonException("Invalid proof secret"); + } reader.Read(); if (reader.TokenType != JsonTokenType.EndArray) { diff --git a/DotNut/JsonConverters/PubKeyJsonConverter.cs b/DotNut/JsonConverters/PubKeyJsonConverter.cs index 30e270d..6950cfe 100644 --- a/DotNut/JsonConverters/PubKeyJsonConverter.cs +++ b/DotNut/JsonConverters/PubKeyJsonConverter.cs @@ -25,7 +25,10 @@ JsonSerializerOptions options throw new JsonException("Expected string"); } - return new PubKey(str, true); + // Accept both secp256k1 compressed (66 chars) and BLS G1 compressed (96 chars) + if (str.Length != 66 && str.Length != 96) + throw new JsonException($"Expected 66-char secp or 96-char BLS G1 hex, got length {str.Length}"); + return new PubKey(str); } public override void Write(Utf8JsonWriter writer, PubKey? value, JsonSerializerOptions options) diff --git a/DotNut/NUT01/BlsKeyset.cs b/DotNut/NUT01/BlsKeyset.cs new file mode 100644 index 0000000..3abbf93 --- /dev/null +++ b/DotNut/NUT01/BlsKeyset.cs @@ -0,0 +1,70 @@ +using System.Text; +using System.Text.Json.Serialization; +using DotNut.JsonConverters; +using SHA256 = System.Security.Cryptography.SHA256; + +namespace DotNut; + +/// +/// A v3 (BLS12-381) keyset: maps amounts to G2 public keys (96 bytes / 192 hex chars each). +/// Entries in the base dictionary are instances +/// with == true. +/// +[JsonConverter(typeof(BlsKeysetJsonConverter))] +public class BlsKeyset : Keyset +{ + public KeysetId GetKeysetId( + string? unit = null, + ulong? inputFeePpk = null, + ulong? finalExpiration = null + ) + { + if (Count == 0) + { + throw new InvalidOperationException("Keyset cannot be empty."); + } + if (string.IsNullOrWhiteSpace(unit)) + { + throw new ArgumentNullException(nameof(unit), "Unit is required for v3 keyset ID."); + } + + var sortedKeys = this.OrderBy(x => x.Key); + + using var sha256 = SHA256.Create(); + using var stream = new MemoryStream(); + + var preimage = string.Join(",", + sortedKeys.Select(p => $"{p.Key}:{p.Value.ToString().ToLowerInvariant()}")); + stream.Write(Encoding.UTF8.GetBytes(preimage)); + stream.Write(Encoding.UTF8.GetBytes($"|unit:{unit.Trim().ToLowerInvariant()}")); + + if (inputFeePpk.HasValue && inputFeePpk.Value != 0) + { + stream.Write(Encoding.UTF8.GetBytes($"|input_fee_ppk:{inputFeePpk.Value}")); + } + + if (finalExpiration is not null) + { + stream.Write(Encoding.UTF8.GetBytes($"|final_expiry:{finalExpiration}")); + } + var hash = sha256.ComputeHash(stream.ToArray()); + return new KeysetId("02" + Convert.ToHexString(hash).ToLower()); + } + + public override bool VerifyKeysetId( + KeysetId keysetId, + string? unit = null, + ulong? inputFeePpk = null, + ulong? finalExpiration = null + ) + { + var derived = GetKeysetId(unit, inputFeePpk, finalExpiration).ToString(); + var presented = keysetId.ToString(); + if (presented.Length > derived.Length) + { + return false; + } + return string.Equals(derived, presented, StringComparison.InvariantCultureIgnoreCase) + || derived.StartsWith(presented, StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/DotNut/NUT01/Keyset.cs b/DotNut/NUT01/Keyset.cs index e2c1ec2..0ade70d 100644 --- a/DotNut/NUT01/Keyset.cs +++ b/DotNut/NUT01/Keyset.cs @@ -7,7 +7,7 @@ namespace DotNut; -[JsonConverter(typeof(KeysetJsonConverter))] +[JsonConverter(typeof(AnyKeysetJsonConverter))] public class Keyset : Dictionary { public KeysetId GetKeysetId( @@ -98,12 +98,16 @@ public KeysetId GetKeysetId( Convert.ToHexString(new[] { version }) + Convert.ToHexString(hash).ToLower() ); } + case 0x02: + throw new ArgumentException( + "Version 0x02 is a BLS12-381 keyset. Use BlsKeyset.GetKeysetId() instead."); + default: throw new ArgumentException($"Unsupported keyset version: {version}"); } } - public bool VerifyKeysetId( + public virtual bool VerifyKeysetId( KeysetId keysetId, string? unit = null, ulong? inputFeePpk = null, @@ -114,7 +118,9 @@ public bool VerifyKeysetId( var derived = GetKeysetId(version, unit, inputFeePpk, finalExpiration).ToString(); var presented = keysetId.ToString(); if (presented.Length > derived.Length) + { return false; + } return string.Equals(derived, presented, StringComparison.InvariantCultureIgnoreCase) || derived.StartsWith(presented, StringComparison.InvariantCultureIgnoreCase); } diff --git a/DotNut/NUT02/KeysetId.cs b/DotNut/NUT02/KeysetId.cs index 1c3ba7a..5871a38 100644 --- a/DotNut/NUT02/KeysetId.cs +++ b/DotNut/NUT02/KeysetId.cs @@ -83,6 +83,17 @@ public byte GetVersion() return Convert.ToByte(versionStr, 16); } + /// + /// Returns true for v3 BLS12-381 keyset IDs (modern hex, version byte 0x02). + /// Strict gate: does not assume future versions are BLS. + /// + public bool IsBlsKeyset() + { + if (_id.Length != 16 && _id.Length != 66) return false; + if (!System.Text.RegularExpressions.Regex.IsMatch(_id, "^[0-9a-fA-F]+$")) return false; + return _id.StartsWith("02", StringComparison.OrdinalIgnoreCase); + } + public byte[] GetBytes() { return Convert.FromHexString(_id); diff --git a/DotNut/NUT13/Nut13.cs b/DotNut/NUT13/Nut13.cs index 4dd7b6f..8d3e0bf 100644 --- a/DotNut/NUT13/Nut13.cs +++ b/DotNut/NUT13/Nut13.cs @@ -1,5 +1,7 @@ using System.Security.Cryptography; +using System.Text; using DotNut.Abstractions; +using DotNut.Crypto; using DotNut.NBitcoin.BIP39; using NBip32Fast; @@ -27,30 +29,37 @@ uint counter ) { var outputs = new List(); - var amountList = amounts.ToList(); + var isBls = keysetId.IsBlsKeyset(); for (uint i = 0; i < amountList.Count; i++) { var secret = DeriveSecret(mnemonic, keysetId, counter + i); - var r = new PrivKey(DeriveBlindingFactor(mnemonic, keysetId, counter + i)); + var rBytes = DeriveBlindingFactor(mnemonic, keysetId, counter + i); + var r = new PrivKey(rBytes); - var Y = secret.ToCurve(); - var B_ = Cashu.ComputeB_(Y, r); + PubKey B_; + if (isBls) + { + var b_ = BlsCashu.BlindMessage(secret.GetBytes(), rBytes); + B_ = new PubKey(b_); + } + else + { + B_ = Cashu.ComputeB_(secret.ToCurve(), r); + } - outputs.Add( - new OutputData() + outputs.Add(new OutputData + { + BlindedMessage = new BlindedMessage { - BlindedMessage = new BlindedMessage() - { - Amount = amountList[(int)i], - Id = keysetId, - B_ = B_, - }, - Secret = secret, - BlindingFactor = r, - } - ); + Amount = amountList[(int)i], + Id = keysetId, + B_ = B_, + }, + Secret = secret, + BlindingFactor = r, + }); } return outputs; @@ -65,8 +74,13 @@ public static byte[] DeriveBlindingFactor(this byte[] seed, KeysetId keysetId, u .Instance.DerivePath(GetNut13DerivationPath(keysetId, counter, false), seed) .PrivateKey.ToArray(); case 0x01: - { return DeriveHmac(seed, keysetId, counter, false); + case 0x02: + { + // Same HMAC as v1, but reduce mod BLS Fr order via wide reduction. + // The 256-bit HMAC output may be >= r, so we cannot use FromBytesBigEndian directly. + var hmacBytes = DeriveHmac(seed, keysetId, counter, false); + return BlsCashu.ReduceHmacToScalarBytes(hmacBytes); } default: throw new ArgumentException("Invalid keyset id prefix"); @@ -83,6 +97,7 @@ public static StringSecret DeriveSecret(this byte[] seed, KeysetId keysetId, uin .PrivateKey; return new StringSecret(Convert.ToHexString(key).ToLower()); case 0x01: + case 0x02: // BLS v3 uses same HMAC path for the secret { var secretBytes = DeriveHmac(seed, keysetId, counter, true); return new StringSecret(Convert.ToHexString(secretBytes).ToLower()); diff --git a/DotNut/PubKey.cs b/DotNut/PubKey.cs index 4c1ca6c..10ebb63 100644 --- a/DotNut/PubKey.cs +++ b/DotNut/PubKey.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; +using DotNut.BLS12_381.Curve.G1; +using DotNut.BLS12_381.Curve.G2; using DotNut.JsonConverters; using NBitcoin.Secp256k1; @@ -8,14 +10,31 @@ namespace DotNut; public class PubKey { [JsonIgnore(Condition = JsonIgnoreCondition.Always)] - public readonly ECPubKey Key; + public readonly ECPubKey? Key; + + private readonly byte[]? _blsG1Bytes; // 48 bytes, BLS12-381 G1 + private readonly byte[]? _blsG2Bytes; // 96 bytes, BLS12-381 G2 + + /// True when this holds a BLS12-381 G1 point (v3 proof C / blinded message B_). + public bool IsBlsG1 => _blsG1Bytes != null; + + /// True when this holds a BLS12-381 G2 point (v3 mint key). + public bool IsBlsG2 => _blsG2Bytes != null; public PubKey(string hex, bool onlyAllowCompressed = false) { - if (onlyAllowCompressed && hex.Length != 66) + if (hex.Length == 96) // BLS G1 compressed: 48 bytes { - throw new ArgumentException("Only compressed public keys are allowed"); + _blsG1Bytes = Convert.FromHexString(hex); + return; + } + if (hex.Length == 192) // BLS G2 compressed: 96 bytes + { + _blsG2Bytes = Convert.FromHexString(hex); + return; } + if (onlyAllowCompressed && hex.Length != 66) + throw new ArgumentException("Only compressed public keys are allowed"); Key = hex.ToPubKey(); } @@ -24,32 +43,75 @@ private PubKey(ECPubKey ecPubKey) Key = ecPubKey; } - public override string ToString() + internal PubKey(G1Affine g1Point) { - return Convert.ToHexString(Key.ToBytes()).ToLower(); + if (g1Point.IsInfinity) + throw new ArgumentException("G1 point at infinity is not valid"); + _blsG1Bytes = g1Point.ToCompressed(); } - public static implicit operator PubKey(ECPubKey ecPubKey) + internal PubKey(G2Affine g2Point) { - return new PubKey(ecPubKey); + if (g2Point.IsInfinity) + throw new ArgumentException("G2 point at infinity is not valid"); + _blsG2Bytes = g2Point.ToCompressed(); } - public static implicit operator ECPubKey(PubKey pubKey) + public G1Affine GetBlsG1Point() { - return pubKey.Key; + if (_blsG1Bytes == null) + throw new InvalidOperationException("Not a BLS G1 point. Check IsBlsG1 first."); + if (!G1Affine.TryFromCompressed(_blsG1Bytes, out var p)) + throw new InvalidOperationException("Stored BLS G1 bytes are invalid"); + return p; } + public G2Affine GetBlsG2Point() + { + if (_blsG2Bytes == null) + throw new InvalidOperationException("Not a BLS G2 point. Check IsBlsG2 first."); + if (!G2Affine.TryFromCompressed(_blsG2Bytes, out var p)) + throw new InvalidOperationException("Stored BLS G2 bytes are invalid"); + return p; + } + + public override string ToString() + { + if (_blsG1Bytes != null) return Convert.ToHexString(_blsG1Bytes).ToLower(); + if (_blsG2Bytes != null) return Convert.ToHexString(_blsG2Bytes).ToLower(); + return Convert.ToHexString(Key!.ToBytes()).ToLower(); + } + + public static implicit operator PubKey(ECPubKey ecPubKey) => new(ecPubKey); + + public static implicit operator ECPubKey(PubKey pubKey) => + pubKey.Key ?? throw new InvalidOperationException( + "This PubKey holds a BLS point. Use GetBlsG1Point() or GetBlsG2Point()."); + public override bool Equals(object? obj) { - if (ReferenceEquals(this, obj)) - return true; - if (obj is not PubKey other) - return false; - return this.Key == other.Key; + if (ReferenceEquals(this, obj)) return true; + if (obj is not PubKey other) return false; + if (IsBlsG1 != other.IsBlsG1 || IsBlsG2 != other.IsBlsG2) return false; + if (IsBlsG1) return _blsG1Bytes!.SequenceEqual(other._blsG1Bytes!); + if (IsBlsG2) return _blsG2Bytes!.SequenceEqual(other._blsG2Bytes!); + return Key == other.Key; } public override int GetHashCode() { - return Key.GetHashCode(); + if (_blsG1Bytes != null) + { + var h = new HashCode(); + foreach (var b in _blsG1Bytes) h.Add(b); + return h.ToHashCode(); + } + if (_blsG2Bytes != null) + { + var h = new HashCode(); + foreach (var b in _blsG2Bytes) h.Add(b); + return h.ToHashCode(); + } + return Key!.GetHashCode(); } }