├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── LNURL ├── LNUrlException.cs ├── LNURLHostedChannelRequest.cs ├── LNUrlStatusResponse.cs ├── JsonConverters │ ├── NodeUriJsonConverter.cs │ ├── PubKeyJsonConverter.cs │ ├── SigJsonConverter.cs │ └── UriJsonConverter.cs ├── LNURL.csproj ├── Extensions.cs ├── LNURLChannelRequest.cs ├── LNURLWithdrawRequest.cs ├── LNAuthRequest.cs ├── LNURL.cs ├── Bech32.cs ├── BoltCardHelper.cs └── LNURLPayRequest.cs ├── LICENSE ├── LNURL.Tests ├── LNURL.Tests.csproj └── UnitTest1.cs └── LNURL.sln /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | /packages/ 4 | riderModule.iml 5 | /_ReSharper.Caches/ 6 | .idea 7 | LNURL.sln.DotSettings.user 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kukks] 4 | custom: ['https://donate.kukks.org'] 5 | -------------------------------------------------------------------------------- /LNURL/LNUrlException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace LNURL; 4 | 5 | public class LNUrlException : Exception 6 | { 7 | public LNUrlException(string message) : base(message) 8 | { 9 | } 10 | } -------------------------------------------------------------------------------- /LNURL/LNURLHostedChannelRequest.cs: -------------------------------------------------------------------------------- 1 | using BTCPayServer.Lightning; 2 | using LNURL.JsonConverters; 3 | using Newtonsoft.Json; 4 | 5 | namespace LNURL; 6 | 7 | /// 8 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/07.md 9 | /// 10 | public class LNURLHostedChannelRequest 11 | { 12 | [JsonProperty("uri")] 13 | [JsonConverter(typeof(NodeUriJsonConverter))] 14 | public NodeInfo Uri { get; set; } 15 | 16 | [JsonProperty("alias")] public string Alias { get; set; } 17 | 18 | [JsonProperty("k1")] public string K1 { get; set; } 19 | 20 | [JsonProperty("tag")] public string Tag { get; set; } 21 | } -------------------------------------------------------------------------------- /LNURL/LNUrlStatusResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Linq; 4 | 5 | namespace LNURL; 6 | 7 | public class LNUrlStatusResponse 8 | { 9 | [JsonProperty("status")] public string Status { get; set; } 10 | [JsonProperty("reason")] public string Reason { get; set; } 11 | 12 | public static bool IsErrorResponse(JObject response, out LNUrlStatusResponse status) 13 | { 14 | if (response.ContainsKey("status") && response["status"].Value() 15 | .Equals("Error", StringComparison.InvariantCultureIgnoreCase)) 16 | { 17 | status = response.ToObject(); 18 | return true; 19 | } 20 | 21 | status = null; 22 | return false; 23 | } 24 | } -------------------------------------------------------------------------------- /LNURL/JsonConverters/NodeUriJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using BTCPayServer.Lightning; 4 | using NBitcoin.JsonConverters; 5 | using Newtonsoft.Json; 6 | 7 | namespace LNURL.JsonConverters; 8 | 9 | public class NodeUriJsonConverter : JsonConverter 10 | { 11 | public override NodeInfo ReadJson(JsonReader reader, Type objectType, [AllowNull] NodeInfo existingValue, 12 | bool hasExistingValue, JsonSerializer serializer) 13 | { 14 | if (reader.TokenType != JsonToken.String) 15 | throw new JsonObjectException("Unexpected token type for NodeUri", reader.Path); 16 | if (NodeInfo.TryParse((string) reader.Value, out var info)) 17 | return info; 18 | throw new JsonObjectException("Invalid NodeUri", reader.Path); 19 | } 20 | 21 | public override void WriteJson(JsonWriter writer, [AllowNull] NodeInfo value, JsonSerializer serializer) 22 | { 23 | if (value is NodeInfo) 24 | writer.WriteValue(value.ToString()); 25 | } 26 | } -------------------------------------------------------------------------------- /LNURL/LNURL.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Andrew Camilleri (kukks) 6 | MIT 7 | true 8 | LNURL protocol implementation in .NET Core 9 | bitcoin lightning lnurl 10 | 0.0.36 11 | https://github.com/Kukks/LNURL.git 12 | git 13 | MIT 14 | 10 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LNURL/JsonConverters/PubKeyJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using NBitcoin; 4 | using NBitcoin.JsonConverters; 5 | using Newtonsoft.Json; 6 | 7 | namespace LNURL.JsonConverters; 8 | 9 | public class PubKeyJsonConverter : JsonConverter 10 | { 11 | public override PubKey ReadJson(JsonReader reader, Type objectType, [AllowNull] PubKey existingValue, 12 | bool hasExistingValue, JsonSerializer serializer) 13 | { 14 | if (reader.TokenType != JsonToken.String) 15 | throw new JsonObjectException("Unexpected token type for PubKey", reader.Path); 16 | try 17 | { 18 | return new PubKey((string) reader.Value); 19 | } 20 | catch (Exception e) 21 | { 22 | throw new JsonObjectException(e.Message, reader.Path); 23 | } 24 | } 25 | 26 | public override void WriteJson(JsonWriter writer, [AllowNull] PubKey value, JsonSerializer serializer) 27 | { 28 | if (value is { }) 29 | writer.WriteValue(value.ToString()); 30 | } 31 | } -------------------------------------------------------------------------------- /LNURL/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using NBitcoin; 4 | 5 | namespace LNURL; 6 | 7 | public static class Extensions 8 | { 9 | public static bool IsOnion(this Uri uri) 10 | { 11 | if (uri == null || !uri.IsAbsoluteUri) 12 | return false; 13 | return uri.DnsSafeHost.EndsWith(".onion", StringComparison.OrdinalIgnoreCase); 14 | } 15 | 16 | public static bool IsLocalNetwork(this Uri server) 17 | { 18 | if (server == null) 19 | throw new ArgumentNullException(nameof(server)); 20 | 21 | if (server.HostNameType == UriHostNameType.Dns) 22 | return server.Host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase) || 23 | server.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase) || 24 | server.Host.EndsWith(".lan", StringComparison.OrdinalIgnoreCase) || 25 | server.Host.IndexOf('.', StringComparison.OrdinalIgnoreCase) == -1; 26 | 27 | if (IPAddress.TryParse(server.Host, out var ip)) return ip.IsLocal() || ip.IsRFC1918(); 28 | 29 | return false; 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Andrew Camilleri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /LNURL/JsonConverters/SigJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using NBitcoin.Crypto; 4 | using NBitcoin.DataEncoders; 5 | using NBitcoin.JsonConverters; 6 | using Newtonsoft.Json; 7 | 8 | namespace LNURL.JsonConverters; 9 | 10 | public class SigJsonConverter : JsonConverter 11 | { 12 | public override ECDSASignature ReadJson(JsonReader reader, Type objectType, 13 | [AllowNull] ECDSASignature existingValue, bool hasExistingValue, JsonSerializer serializer) 14 | { 15 | if (reader.TokenType != JsonToken.String) 16 | throw new JsonObjectException("Unexpected token type for ECDSASignature", reader.Path); 17 | try 18 | { 19 | return ECDSASignature.FromDER(Encoders.Hex.DecodeData((string) reader.Value)); 20 | } 21 | catch (Exception e) 22 | { 23 | throw new JsonObjectException(e.Message, reader.Path); 24 | } 25 | } 26 | 27 | public override void WriteJson(JsonWriter writer, [AllowNull] ECDSASignature value, JsonSerializer serializer) 28 | { 29 | if (value is { }) 30 | writer.WriteValue(Encoders.Hex.EncodeData(value.ToDER())); 31 | } 32 | } -------------------------------------------------------------------------------- /LNURL.Tests/LNURL.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /LNURL.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LNURL", "LNURL\LNURL.csproj", "{8AE80152-6EF0-47BC-AE09-B9C72DBC6902}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LNURL.Tests", "LNURL.Tests\LNURL.Tests.csproj", "{0D55DB17-C3F5-4FB4-A456-58C3E8593AC2}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {8AE80152-6EF0-47BC-AE09-B9C72DBC6902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {8AE80152-6EF0-47BC-AE09-B9C72DBC6902}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {8AE80152-6EF0-47BC-AE09-B9C72DBC6902}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {8AE80152-6EF0-47BC-AE09-B9C72DBC6902}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {0D55DB17-C3F5-4FB4-A456-58C3E8593AC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {0D55DB17-C3F5-4FB4-A456-58C3E8593AC2}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {0D55DB17-C3F5-4FB4-A456-58C3E8593AC2}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {0D55DB17-C3F5-4FB4-A456-58C3E8593AC2}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish application' 2 | on: 3 | # Run the build for pushes and pull requests targeting master 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | # Checkout the code 16 | - uses: actions/checkout@v2 17 | 18 | # Install .NET Core SDK 19 | - name: Setup .NET Core 20 | uses: actions/setup-dotnet@v1 21 | with: 22 | dotnet-version: 8.0.x 23 | # Run tests 24 | - name: Test 25 | run: dotnet test 26 | - name: Publish NuGet 27 | if: ${{ github.ref == 'refs/heads/master' }} # Publish only when the push is on master 28 | uses: Rebel028/publish-nuget@v2.7.0 29 | with: 30 | PROJECT_FILE_PATH: LNURL/LNURL.csproj 31 | NUGET_KEY: ${{secrets.NUGET_KEY}} 32 | PACKAGE_NAME: LNURL 33 | INCLUDE_SYMBOLS: false 34 | VERSION_REGEX: ^\s*(.*)<\/PackageVersion>\s*$ 35 | TAG_COMMIT: true 36 | TAG_FORMAT: v* 37 | 38 | - name: Publish Github Package Registry 39 | if: ${{ github.ref == 'refs/heads/master' }} # Publish only when the push is on master 40 | uses: Rebel028/publish-nuget@v2.7.0 41 | with: 42 | PROJECT_FILE_PATH: LNURL/LNURL.csproj 43 | NUGET_SOURCE: "https://nuget.pkg.github.com/Kukks" 44 | NUGET_KEY: ${{secrets.GH_TOKEN}} 45 | PACKAGE_NAME: LNURL 46 | INCLUDE_SYMBOLS: false 47 | VERSION_REGEX: ^\s*(.*)<\/PackageVersion>\s*$ 48 | TAG_COMMIT: true 49 | TAG_FORMAT: v* 50 | -------------------------------------------------------------------------------- /LNURL/JsonConverters/UriJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using NBitcoin.JsonConverters; 4 | using Newtonsoft.Json; 5 | 6 | namespace LNURL.JsonConverters; 7 | 8 | public class UriJsonConverter : JsonConverter 9 | { 10 | public override bool CanConvert(Type objectType) 11 | { 12 | return typeof(Uri).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()) || objectType == typeof(string); 13 | } 14 | 15 | public override object ReadJson(JsonReader reader, Type objectType, object existingValue, 16 | JsonSerializer serializer) 17 | { 18 | try 19 | { 20 | var res = reader.TokenType == JsonToken.Null ? null : 21 | reader.TokenType == JsonToken.String && string.IsNullOrEmpty(reader.Value?.ToString()) ? null : 22 | Uri.TryCreate((string) reader.Value, UriKind.Absolute, out var result) ? result : 23 | throw new JsonObjectException("Invalid Uri value", reader); 24 | if (objectType == typeof(string)) 25 | { 26 | return res?.ToString(); 27 | } 28 | 29 | return res; 30 | } 31 | catch (InvalidCastException) 32 | { 33 | throw new JsonObjectException("Invalid Uri value", reader); 34 | } 35 | } 36 | 37 | public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) 38 | { 39 | switch (value) 40 | { 41 | case null: 42 | return; 43 | case string s: 44 | writer.WriteValue(s); 45 | break; 46 | case Uri uri: 47 | writer.WriteValue(uri.IsAbsoluteUri? uri.AbsoluteUri : uri.ToString()); 48 | break; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /LNURL/LNURLChannelRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using BTCPayServer.Lightning; 6 | using LNURL.JsonConverters; 7 | using NBitcoin; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Linq; 10 | 11 | namespace LNURL; 12 | 13 | /// 14 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/02.md 15 | /// 16 | public class LNURLChannelRequest 17 | { 18 | [JsonProperty("uri")] 19 | [JsonConverter(typeof(NodeUriJsonConverter))] 20 | public NodeInfo Uri { get; set; } 21 | 22 | [JsonProperty("callback")] 23 | [JsonConverter(typeof(UriJsonConverter))] 24 | public Uri Callback { get; set; } 25 | 26 | [JsonProperty("k1")] public string K1 { get; set; } 27 | 28 | [JsonProperty("tag")] public string Tag { get; set; } 29 | 30 | 31 | public async Task SendRequest(PubKey ourId, bool privateChannel, HttpClient httpClient, 32 | CancellationToken cancellationToken = default) 33 | { 34 | var url = Callback; 35 | var uriBuilder = new UriBuilder(url); 36 | LNURL.AppendPayloadToQuery(uriBuilder, "k1", K1); 37 | LNURL.AppendPayloadToQuery(uriBuilder, "remoteid", ourId.ToString()); 38 | LNURL.AppendPayloadToQuery(uriBuilder, "private",privateChannel? "1":"0"); 39 | 40 | url = new Uri(uriBuilder.ToString()); 41 | var response = await httpClient.GetAsync(url, cancellationToken); 42 | var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 43 | if (LNUrlStatusResponse.IsErrorResponse(json, out var error)) throw new LNUrlException(error.Reason); 44 | 45 | } 46 | 47 | public async Task CancelRequest(PubKey ourId, HttpClient httpClient, CancellationToken cancellationToken = default) 48 | { 49 | var url = Callback; 50 | var uriBuilder = new UriBuilder(url); 51 | LNURL.AppendPayloadToQuery(uriBuilder, "k1", K1); 52 | LNURL.AppendPayloadToQuery(uriBuilder, "remoteid", ourId.ToString()); 53 | LNURL.AppendPayloadToQuery(uriBuilder, "cancel", "1"); 54 | 55 | url = new Uri(uriBuilder.ToString()); 56 | var response = await httpClient.GetAsync(url, cancellationToken); 57 | var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 58 | if (LNUrlStatusResponse.IsErrorResponse(json, out var error)) throw new LNUrlException(error.Reason); 59 | } 60 | } -------------------------------------------------------------------------------- /LNURL/LNURLWithdrawRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using BTCPayServer.Lightning; 6 | using BTCPayServer.Lightning.JsonConverters; 7 | using LNURL.JsonConverters; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Linq; 10 | 11 | namespace LNURL; 12 | 13 | /// 14 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/02.md 15 | /// 16 | public class LNURLWithdrawRequest 17 | { 18 | [JsonProperty("callback")] 19 | [JsonConverter(typeof(UriJsonConverter))] 20 | public Uri Callback { get; set; } 21 | 22 | [JsonProperty("k1")] public string K1 { get; set; } 23 | 24 | [JsonProperty("tag")] public string Tag { get; set; } 25 | 26 | [JsonProperty("defaultDescription")] public string DefaultDescription { get; set; } 27 | 28 | [JsonProperty("minWithdrawable")] 29 | [JsonConverter(typeof(LightMoneyJsonConverter))] 30 | public LightMoney MinWithdrawable { get; set; } 31 | 32 | [JsonProperty("maxWithdrawable")] 33 | [JsonConverter(typeof(LightMoneyJsonConverter))] 34 | public LightMoney MaxWithdrawable { get; set; } 35 | 36 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/14.md 37 | [JsonProperty("currentBalance")] 38 | [JsonConverter(typeof(LightMoneyJsonConverter))] 39 | public LightMoney CurrentBalance { get; set; } 40 | 41 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/14.md 42 | [JsonProperty("balanceCheck")] 43 | [JsonConverter(typeof(UriJsonConverter))] 44 | public Uri BalanceCheck { get; set; } 45 | 46 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/19.md 47 | [JsonProperty("payLink", NullValueHandling = NullValueHandling.Ignore)] 48 | [JsonConverter(typeof(UriJsonConverter))] 49 | public Uri PayLink { get; set; } 50 | 51 | //https://github.com/bitcoin-ring/luds/blob/withdraw-pin/21.md 52 | [JsonProperty("pinLimit", NullValueHandling = NullValueHandling.Ignore)] 53 | [JsonConverter(typeof(LightMoneyJsonConverter))] 54 | public LightMoney PinLimit { get; set; } 55 | 56 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/15.md 57 | public Task SendRequest(string bolt11, HttpClient httpClient, 58 | Uri balanceNotify = null, CancellationToken cancellationToken = default) 59 | { 60 | return SendRequest(bolt11, httpClient, null, balanceNotify, cancellationToken); 61 | } 62 | public async Task SendRequest(string bolt11, HttpClient httpClient, string pin = null, 63 | Uri balanceNotify = null, CancellationToken cancellationToken = default) 64 | { 65 | var url = Callback; 66 | var uriBuilder = new UriBuilder(url); 67 | LNURL.AppendPayloadToQuery(uriBuilder, "pr", bolt11); 68 | LNURL.AppendPayloadToQuery(uriBuilder, "k1", K1); 69 | if (balanceNotify != null) LNURL.AppendPayloadToQuery(uriBuilder, "balanceNotify", balanceNotify.ToString()); 70 | if (pin != null) LNURL.AppendPayloadToQuery(uriBuilder, "pin", pin); 71 | 72 | url = new Uri(uriBuilder.ToString()); 73 | var response = await httpClient.GetAsync(url, cancellationToken); 74 | var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 75 | 76 | return json.ToObject(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /LNURL/LNAuthRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using NBitcoin; 6 | using NBitcoin.Crypto; 7 | using NBitcoin.DataEncoders; 8 | using Newtonsoft.Json; 9 | using Newtonsoft.Json.Converters; 10 | using Newtonsoft.Json.Linq; 11 | 12 | namespace LNURL; 13 | 14 | /// 15 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/04.md 16 | /// 17 | public class LNAuthRequest 18 | { 19 | public enum LNAuthRequestAction 20 | { 21 | Register, 22 | Login, 23 | Link, 24 | Auth 25 | } 26 | 27 | public Uri LNUrl { get; set; } 28 | 29 | 30 | [JsonProperty("tag")] public string Tag => "login"; 31 | [JsonProperty("k1")] public string K1 { get; set; } 32 | 33 | [JsonProperty("action")] 34 | [JsonConverter(typeof(StringEnumConverter))] 35 | public LNAuthRequestAction? Action { get; set; } 36 | 37 | public async Task SendChallenge(ECDSASignature sig, PubKey key, HttpClient httpClient, CancellationToken cancellationToken = default) 38 | { 39 | var url = LNUrl; 40 | var uriBuilder = new UriBuilder(url); 41 | LNURL.AppendPayloadToQuery(uriBuilder, "sig", Encoders.Hex.EncodeData(sig.ToDER())); 42 | LNURL.AppendPayloadToQuery(uriBuilder, "key", key.ToHex()); 43 | url = new Uri(uriBuilder.ToString()); 44 | var response = await httpClient.GetAsync(url, cancellationToken); 45 | var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 46 | 47 | return json.ToObject(); 48 | } 49 | 50 | public Task SendChallenge(Key key, HttpClient httpClient, CancellationToken cancellationToken = default) 51 | { 52 | var sig = SignChallenge(key); 53 | return SendChallenge(sig, key.PubKey, httpClient, cancellationToken); 54 | } 55 | 56 | public ECDSASignature SignChallenge(Key key) 57 | { 58 | return SignChallenge(key, K1); 59 | } 60 | 61 | public static ECDSASignature SignChallenge(Key key, string k1) 62 | { 63 | var messageBytes = Encoders.Hex.DecodeData(k1); 64 | var messageHash = new uint256(messageBytes); 65 | return key.Sign(messageHash); 66 | } 67 | 68 | public static void EnsureValidUrl(Uri serviceUrl) 69 | { 70 | var tag = serviceUrl.ParseQueryString().Get("tag"); 71 | if (tag != "login") 72 | throw new ArgumentException(nameof(serviceUrl), 73 | "LNURL-Auth(LUD04) requires tag to be provided straight away"); 74 | var k1 = serviceUrl.ParseQueryString().Get("k1"); 75 | if (k1 is null) throw new ArgumentException(nameof(serviceUrl), "LNURL-Auth(LUD04) requires k1 to be provided"); 76 | 77 | byte[] k1Bytes; 78 | try 79 | { 80 | k1Bytes = Encoders.Hex.DecodeData(k1); 81 | } 82 | catch (Exception) 83 | { 84 | throw new ArgumentException(nameof(serviceUrl), "LNURL-Auth(LUD04) requires k1 to be hex encoded"); 85 | } 86 | 87 | if (k1Bytes.Length != 32) 88 | throw new ArgumentException(nameof(serviceUrl), "LNURL-Auth(LUD04) requires k1 to be 32bytes"); 89 | 90 | var action = serviceUrl.ParseQueryString().Get("action"); 91 | if (action != null && !Enum.TryParse(typeof(LNAuthRequestAction), action, true, out _)) 92 | throw new ArgumentException(nameof(serviceUrl), "LNURL-Auth(LUD04) action value was invalid"); 93 | } 94 | 95 | public static bool VerifyChallenge(ECDSASignature sig, PubKey expectedPubKey, byte[] expectedMessage) 96 | { 97 | var messageHash = new uint256(expectedMessage); 98 | return expectedPubKey.Verify(messageHash, sig); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /LNURL/LNURL.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Text; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Newtonsoft.Json.Linq; 11 | 12 | namespace LNURL; 13 | 14 | public class LNURL 15 | { 16 | private static readonly Dictionary SchemeTagMapping = 17 | new(StringComparer.InvariantCultureIgnoreCase) 18 | { 19 | {"lnurlc", "channelRequest"}, 20 | {"lnurlw", "withdrawRequest"}, 21 | {"lnurlp", "payRequest"}, 22 | {"keyauth", "login"} 23 | }; 24 | 25 | private static readonly Dictionary SchemeTagMappingReversed = 26 | SchemeTagMapping.ToDictionary(pair => pair.Value, pair => pair.Key, 27 | StringComparer.InvariantCultureIgnoreCase); 28 | 29 | public static void AppendPayloadToQuery(UriBuilder uri, string key, string value) 30 | { 31 | if (uri.Query.Length > 1) 32 | uri.Query += "&"; 33 | 34 | uri.Query = uri.Query + WebUtility.UrlEncode(key) + "=" + 35 | WebUtility.UrlEncode(value); 36 | } 37 | 38 | public static Uri Parse(string lnurl, out string tag) 39 | { 40 | lnurl = lnurl.Replace("lightning:", "", StringComparison.InvariantCultureIgnoreCase); 41 | if (lnurl.StartsWith("lnurl1", StringComparison.InvariantCultureIgnoreCase)) 42 | { 43 | Bech32Engine.Decode(lnurl, out _, out var data); 44 | var result = new Uri(Encoding.UTF8.GetString(data)); 45 | 46 | if (!result.IsOnion() && !result.Scheme.Equals("https") && !result.IsLocalNetwork()) 47 | throw new FormatException("LNURL provided is not secure."); 48 | 49 | var query = result.ParseQueryString(); 50 | tag = query.Get("tag"); 51 | return result; 52 | } 53 | 54 | if (Uri.TryCreate(lnurl, UriKind.Absolute, out var lud17Uri) && 55 | SchemeTagMapping.TryGetValue(lud17Uri.Scheme.ToLowerInvariant(), out tag)) 56 | return new Uri(lud17Uri.ToString() 57 | .Replace(lud17Uri.Scheme + ":", lud17Uri.IsOnion() ? "http:" : "https:")); 58 | 59 | throw new FormatException("LNURL uses bech32 and 'lnurl' as the hrp (LUD1) or an lnurl LUD17 scheme. "); 60 | } 61 | 62 | public static string EncodeBech32(Uri serviceUrl) 63 | { 64 | if (serviceUrl.Scheme != "https" && !serviceUrl.IsOnion() && !serviceUrl.IsLocalNetwork()) 65 | throw new ArgumentException("serviceUrl must be an onion service OR https based OR on the local network", 66 | nameof(serviceUrl)); 67 | 68 | return Bech32Engine.Encode("lnurl", Encoding.UTF8.GetBytes(serviceUrl.ToString())); 69 | } 70 | 71 | public static Uri EncodeUri(Uri serviceUrl, string tag, bool bech32) 72 | { 73 | if (serviceUrl.Scheme != "https" && !serviceUrl.IsOnion() && !serviceUrl.IsLocalNetwork()) 74 | throw new ArgumentException("serviceUrl must be an onion service OR https based OR on the local network", 75 | nameof(serviceUrl)); 76 | if (string.IsNullOrEmpty(tag)) tag = serviceUrl.ParseQueryString().Get("tag"); 77 | if (tag == "login") LNAuthRequest.EnsureValidUrl(serviceUrl); 78 | if (bech32) return new Uri($"lightning:{EncodeBech32(serviceUrl)}"); 79 | 80 | if (string.IsNullOrEmpty(tag)) tag = serviceUrl.ParseQueryString().Get("tag"); 81 | 82 | if (string.IsNullOrEmpty(tag)) throw new ArgumentNullException("tag must be provided", nameof(tag)); 83 | 84 | if (!SchemeTagMappingReversed.TryGetValue(tag.ToLowerInvariant(), out var scheme)) 85 | throw new ArgumentOutOfRangeException( 86 | $"tag must be either {string.Join(',', SchemeTagMappingReversed.Select(pair => pair.Key))}", 87 | nameof(tag)); 88 | 89 | 90 | return new UriBuilder(serviceUrl) 91 | { 92 | Scheme = scheme 93 | }.Uri; 94 | } 95 | 96 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/16.md 97 | public static Task FetchPayRequestViaInternetIdentifier(string identifier, 98 | HttpClient httpClient) 99 | { 100 | return FetchPayRequestViaInternetIdentifier(identifier, httpClient, default); 101 | } 102 | public static async Task FetchPayRequestViaInternetIdentifier(string identifier, 103 | HttpClient httpClient, CancellationToken cancellationToken) 104 | { 105 | return (LNURLPayRequest) await FetchInformation(ExtractUriFromInternetIdentifier(identifier), "payRequest", 106 | httpClient, cancellationToken); 107 | } 108 | 109 | public static Uri ExtractUriFromInternetIdentifier(string identifier) 110 | { 111 | var s = identifier.Split("@"); 112 | var s2 = s[1].Split(":"); 113 | UriBuilder uriBuilder; 114 | if (s2.Length > 1) 115 | uriBuilder = new UriBuilder( 116 | s2[0].EndsWith(".onion", StringComparison.InvariantCultureIgnoreCase) ? "http" : "https", 117 | s2[0], int.Parse(s2[1])) 118 | { 119 | Path = $"/.well-known/lnurlp/{s[0]}" 120 | }; 121 | else 122 | uriBuilder = 123 | new UriBuilder(s[1].EndsWith(".onion", StringComparison.InvariantCultureIgnoreCase) ? "http" : "https", 124 | s2[0]) 125 | { 126 | Path = $"/.well-known/lnurlp/{s[0]}" 127 | }; 128 | 129 | return uriBuilder.Uri; 130 | } 131 | 132 | 133 | public static Task FetchInformation(Uri lnUrl, HttpClient httpClient) 134 | { 135 | return FetchInformation(lnUrl, httpClient, default); 136 | } 137 | public static async Task FetchInformation(Uri lnUrl, HttpClient httpClient, CancellationToken cancellationToken) 138 | { 139 | return await FetchInformation(lnUrl, null, httpClient, cancellationToken); 140 | } 141 | public static Task FetchInformation(Uri lnUrl, string tag, HttpClient httpClient) 142 | { 143 | return FetchInformation(lnUrl, tag, httpClient, default); 144 | } 145 | public static async Task FetchInformation(Uri lnUrl, string tag, HttpClient httpClient, CancellationToken cancellationToken) 146 | { 147 | try 148 | { 149 | lnUrl = Parse(lnUrl.ToString(), out tag); 150 | } 151 | catch (Exception) 152 | { 153 | // ignored 154 | } 155 | 156 | if (tag is null) tag = lnUrl.ParseQueryString().Get("tag"); 157 | JObject json; 158 | NameValueCollection queryString; 159 | HttpResponseMessage response; 160 | string k1; 161 | switch (tag) 162 | { 163 | case null: 164 | response = await httpClient.GetAsync(lnUrl, cancellationToken); 165 | json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 166 | 167 | if (json.TryGetValue("tag", out var tagToken)) 168 | { 169 | tag = tagToken.ToString(); 170 | return FetchInformation(json, tag); 171 | } 172 | 173 | throw new LNUrlException("A tag identifying the LNURL endpoint was not received."); 174 | case "withdrawRequest": 175 | //fast withdraw request supported: 176 | queryString = lnUrl.ParseQueryString(); 177 | k1 = queryString.Get("k1"); 178 | var minWithdrawable = queryString.Get("minWithdrawable"); 179 | var maxWithdrawable = queryString.Get("maxWithdrawable"); 180 | var defaultDescription = queryString.Get("defaultDescription"); 181 | var callback = queryString.Get("callback"); 182 | if (k1 is null || minWithdrawable is null || maxWithdrawable is null || callback is null) 183 | { 184 | response = await httpClient.GetAsync(lnUrl, cancellationToken); 185 | json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 186 | return FetchInformation(json, tag); 187 | } 188 | 189 | return new LNURLWithdrawRequest 190 | { 191 | Callback = new Uri(callback), 192 | K1 = k1, 193 | Tag = tag, 194 | DefaultDescription = defaultDescription, 195 | MaxWithdrawable = maxWithdrawable, 196 | MinWithdrawable = minWithdrawable 197 | }; 198 | case "login": 199 | 200 | queryString = lnUrl.ParseQueryString(); 201 | k1 = queryString.Get("k1"); 202 | var action = queryString.Get("action"); 203 | 204 | return new LNAuthRequest 205 | { 206 | K1 = k1, 207 | LNUrl = lnUrl, 208 | Action = string.IsNullOrEmpty(action) 209 | ? null 210 | : Enum.Parse(action, true) 211 | }; 212 | 213 | default: 214 | response = await httpClient.GetAsync(lnUrl, cancellationToken); 215 | json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 216 | return FetchInformation(json, tag); 217 | } 218 | } 219 | 220 | private static object FetchInformation(JObject response, string tag) 221 | { 222 | if (LNUrlStatusResponse.IsErrorResponse(response, out var errorResponse)) return errorResponse; 223 | 224 | return tag switch 225 | { 226 | "channelRequest" => response.ToObject(), 227 | "hostedChannelRequest" => response.ToObject(), 228 | "withdrawRequest" => response.ToObject(), 229 | "payRequest" => response.ToObject(), 230 | _ => response 231 | }; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /LNURL/Bech32.cs: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2017 Guillaume Bonnot and Palekhov Ilia 2 | * Based on the work of Pieter Wuille 3 | * Special Thanks to adiabat 4 | * 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | * THE SOFTWARE. 23 | */ 24 | 25 | using System; 26 | using System.Collections.Generic; 27 | using System.Diagnostics; 28 | using System.Linq; 29 | 30 | namespace LNURL; 31 | 32 | internal static class Bech32Engine 33 | { 34 | // charset is the sequence of ascii characters that make up the bech32 35 | // alphabet. Each character represents a 5-bit squashed byte. 36 | // q = 0b00000, p = 0b00001, z = 0b00010, and so on. 37 | 38 | private const string charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 39 | 40 | // used for polymod 41 | private static readonly uint[] generator = {0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}; 42 | 43 | // icharset is a mapping of 8-bit ascii characters to the charset 44 | // positions. Both uppercase and lowercase ascii are mapped to the 5-bit 45 | // position values. 46 | private static readonly short[] icharset = 47 | { 48 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 49 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 50 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 51 | 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, 52 | -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, 53 | 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, 54 | -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, 55 | 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 56 | }; 57 | 58 | // PolyMod takes a byte slice and returns the 32-bit BCH checksum. 59 | // Note that the input bytes to PolyMod need to be squashed to 5-bits tall 60 | // before being used in this function. And this function will not error, 61 | // but instead return an unsuable checksum, if you give it full-height bytes. 62 | public static uint PolyMod(byte[] values) 63 | { 64 | uint chk = 1; 65 | foreach (var value in values) 66 | { 67 | var top = chk >> 25; 68 | chk = ((chk & 0x1ffffff) << 5) ^ value; 69 | for (var i = 0; i < 5; ++i) 70 | if (((top >> i) & 1) == 1) 71 | chk ^= generator[i]; 72 | } 73 | 74 | return chk; 75 | } 76 | 77 | 78 | // on error, data == null 79 | public static void Decode(string encoded, out string hrp, out byte[] data) 80 | { 81 | byte[] squashed; 82 | DecodeSquashed(encoded, out hrp, out squashed); 83 | if (squashed == null) 84 | { 85 | data = null; 86 | return; 87 | } 88 | 89 | data = Bytes5to8(squashed); 90 | } 91 | 92 | // on error, data == null 93 | private static void DecodeSquashed(string adr, out string hrp, out byte[] data) 94 | { 95 | adr = CheckAndFormat(adr); 96 | if (adr == null) 97 | { 98 | data = null; 99 | hrp = null; 100 | return; 101 | } 102 | 103 | // find the last "1" and split there 104 | var splitLoc = adr.LastIndexOf("1"); 105 | if (splitLoc == -1) 106 | { 107 | Debug.WriteLine("1 separator not present in address"); 108 | data = null; 109 | hrp = null; 110 | return; 111 | } 112 | 113 | // hrp comes before the split 114 | hrp = adr.Substring(0, splitLoc); 115 | 116 | // get squashed data 117 | var squashed = StringToSquashedBytes(adr.Substring(splitLoc + 1)); 118 | if (squashed == null) 119 | { 120 | data = null; 121 | return; 122 | } 123 | 124 | // make sure checksum works 125 | if (!VerifyChecksum(hrp, squashed)) 126 | { 127 | Debug.WriteLine("Checksum invalid"); 128 | data = null; 129 | return; 130 | } 131 | 132 | // chop off checksum to return only payload 133 | var length = squashed.Length - 6; 134 | data = new byte[length]; 135 | Array.Copy(squashed, 0, data, 0, length); 136 | } 137 | 138 | // on error, return null 139 | private static string CheckAndFormat(string adr) 140 | { 141 | // make an all lowercase and all uppercase version of the input string 142 | var lowAdr = adr.ToLower(); 143 | var highAdr = adr.ToUpper(); 144 | 145 | // if there's mixed case, that's not OK 146 | if (adr != lowAdr && adr != highAdr) 147 | { 148 | Debug.WriteLine("mixed case address"); 149 | return null; 150 | } 151 | 152 | // default to lowercase 153 | return lowAdr; 154 | } 155 | 156 | private static bool VerifyChecksum(string hrp, byte[] data) 157 | { 158 | var values = HRPExpand(hrp).Concat(data).ToArray(); 159 | var checksum = PolyMod(values); 160 | // make sure it's 1 (from the LSB flip in CreateChecksum 161 | return checksum == 1; 162 | } 163 | 164 | // on error, return null 165 | private static byte[] StringToSquashedBytes(string input) 166 | { 167 | var squashed = new byte[input.Length]; 168 | 169 | for (var i = 0; i < input.Length; i++) 170 | { 171 | var c = input[i]; 172 | var buffer = icharset[c]; 173 | if (buffer == -1) 174 | { 175 | Debug.WriteLine("contains invalid character " + c); 176 | return null; 177 | } 178 | 179 | squashed[i] = (byte) buffer; 180 | } 181 | 182 | return squashed; 183 | } 184 | 185 | // we encode the data and the human readable prefix 186 | public static string Encode(string hrp, byte[] data) 187 | { 188 | var base5 = Bytes8to5(data); 189 | if (base5 == null) 190 | return string.Empty; 191 | return EncodeSquashed(hrp, base5); 192 | } 193 | 194 | // on error, return null 195 | private static string EncodeSquashed(string hrp, byte[] data) 196 | { 197 | var checksum = CreateChecksum(hrp, data); 198 | var combined = data.Concat(checksum).ToArray(); 199 | 200 | // Should be squashed, return empty string if it's not. 201 | var encoded = SquashedBytesToString(combined); 202 | if (encoded == null) 203 | return null; 204 | return hrp + "1" + encoded; 205 | } 206 | 207 | private static byte[] CreateChecksum(string hrp, byte[] data) 208 | { 209 | var values = HRPExpand(hrp).Concat(data).ToArray(); 210 | // put 6 zero bytes on at the end 211 | values = values.Concat(new byte[6]).ToArray(); 212 | //get checksum for whole slice 213 | 214 | // flip the LSB of the checksum data after creating it 215 | var checksum = PolyMod(values) ^ 1; 216 | 217 | var ret = new byte[6]; 218 | for (var i = 0; i < 6; i++) 219 | // note that this is NOT the same as converting 8 to 5 220 | // this is it's own expansion to 6 bytes from 4, chopping 221 | // off the MSBs. 222 | ret[i] = (byte) ((checksum >> (5 * (5 - i))) & 0x1f); 223 | 224 | return ret; 225 | } 226 | 227 | // HRPExpand turns the human redable part into 5bit-bytes for later processing 228 | private static byte[] HRPExpand(string input) 229 | { 230 | var output = new byte[input.Length * 2 + 1]; 231 | 232 | // first half is the input string shifted down 5 bits. 233 | // not much is going on there in terms of data / entropy 234 | for (var i = 0; i < input.Length; i++) 235 | { 236 | var c = input[i]; 237 | output[i] = (byte) (c >> 5); 238 | } 239 | 240 | // then there's a 0 byte separator 241 | // don't need to set 0 byte in the middle, as it starts out that way 242 | 243 | // second half is the input string, with the top 3 bits zeroed. 244 | // most of the data / entropy will live here. 245 | for (var i = 0; i < input.Length; i++) 246 | { 247 | var c = input[i]; 248 | output[i + input.Length + 1] = (byte) (c & 0x1f); 249 | } 250 | 251 | return output; 252 | } 253 | 254 | private static string SquashedBytesToString(byte[] input) 255 | { 256 | var s = string.Empty; 257 | for (var i = 0; i < input.Length; i++) 258 | { 259 | var c = input[i]; 260 | if ((c & 0xe0) != 0) 261 | { 262 | Debug.WriteLine("high bits set at position {0}: {1}", i, c); 263 | return null; 264 | } 265 | 266 | s += charset[c]; 267 | } 268 | 269 | return s; 270 | } 271 | 272 | private static byte[] Bytes8to5(byte[] data) 273 | { 274 | return ByteSquasher(data, 8, 5); 275 | } 276 | 277 | private static byte[] Bytes5to8(byte[] data) 278 | { 279 | return ByteSquasher(data, 5, 8); 280 | } 281 | 282 | // ByteSquasher squashes full-width (8-bit) bytes into "squashed" 5-bit bytes, 283 | // and vice versa. It can operate on other widths but in this package only 284 | // goes 5 to 8 and back again. It can return null if the squashed input 285 | // you give it isn't actually squashed, or if there is padding (trailing q characters) 286 | // when going from 5 to 8 287 | private static byte[] ByteSquasher(byte[] input, int inputWidth, int outputWidth) 288 | { 289 | var bitstash = 0; 290 | var accumulator = 0; 291 | var output = new List(); 292 | var maxOutputValue = (1 << outputWidth) - 1; 293 | 294 | for (var i = 0; i < input.Length; i++) 295 | { 296 | var c = input[i]; 297 | if (c >> inputWidth != 0) 298 | { 299 | Debug.WriteLine("byte {0} ({1}) high bits set", i, c); 300 | return null; 301 | } 302 | 303 | accumulator = (accumulator << inputWidth) | c; 304 | bitstash += inputWidth; 305 | while (bitstash >= outputWidth) 306 | { 307 | bitstash -= outputWidth; 308 | output.Add((byte) ((accumulator >> bitstash) & maxOutputValue)); 309 | } 310 | } 311 | 312 | // pad if going from 8 to 5 313 | if (inputWidth == 8 && outputWidth == 5) 314 | { 315 | if (bitstash != 0) output.Add((byte) ((accumulator << (outputWidth - bitstash)) & maxOutputValue)); 316 | } 317 | else if (bitstash >= inputWidth || ((accumulator << (outputWidth - bitstash)) & maxOutputValue) != 0) 318 | { 319 | // no pad from 5 to 8 allowed 320 | Debug.WriteLine("invalid padding from {0} to {1} bits", inputWidth, outputWidth); 321 | return null; 322 | } 323 | 324 | return output.ToArray(); 325 | } 326 | } -------------------------------------------------------------------------------- /LNURL/BoltCardHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Security.Cryptography; 6 | using NBitcoin.DataEncoders; 7 | 8 | namespace LNURL 9 | { 10 | public class BoltCardHelper 11 | { 12 | private const int AES_BLOCK_SIZE = 16; 13 | 14 | 15 | public static (string uid, uint counter, byte[] rawUid, byte[] rawCtr)? ExtractUidAndCounterFromP(string pHex, 16 | byte[] aesKey, out string? error) 17 | { 18 | if (!HexEncoder.IsWellFormed(pHex)) 19 | { 20 | error = "p parameter is not hex"; 21 | return null; 22 | } 23 | 24 | return ExtractUidAndCounterFromP(Convert.FromHexString(pHex), aesKey, out error); 25 | } 26 | 27 | public static (string uid, uint counter, byte[] rawUid, byte[] rawCtr)? ExtractUidAndCounterFromP(byte[] p, 28 | byte[] aesKey, out string? error) 29 | { 30 | if (p.Length != 16) 31 | { 32 | error = "p parameter length not valid"; 33 | return null; 34 | } 35 | 36 | using var aes = Aes.Create(); 37 | aes.Key = aesKey; 38 | aes.IV = new byte[16]; // assuming IV is zeros. Adjust if needed. 39 | aes.Mode = CipherMode.CBC; 40 | aes.Padding = PaddingMode.None; 41 | 42 | var decryptor = aes.CreateDecryptor(aes.Key, aes.IV); 43 | 44 | using var memoryStream = new System.IO.MemoryStream(p); 45 | using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read); 46 | using var reader = new System.IO.BinaryReader(cryptoStream); 47 | var decryptedPData = reader.ReadBytes(p.Length); 48 | if (decryptedPData[0] != 0xC7) 49 | { 50 | error = "decrypted data not starting with 0xC7"; 51 | return null; 52 | } 53 | 54 | var uid = decryptedPData[1..8]; 55 | var ctr = decryptedPData[8..11]; 56 | 57 | var c = (uint) (ctr[2] << 16 | ctr[1] << 8 | ctr[0]); 58 | var uidStr = BitConverter.ToString(uid).Replace("-", "").ToLower(); 59 | error = null; 60 | 61 | return (uidStr, c, uid, ctr); 62 | } 63 | 64 | /// 65 | /// Extracts BoltCard information from a given request URI. 66 | /// 67 | /// The URI containing BoltCard data. 68 | /// The AES key for decryption. 69 | /// Outputs an error string if extraction fails. 70 | /// A tuple containing the UID and counter if successful; null otherwise. 71 | public static (string uid, uint counter, byte[] rawUid, byte[] rawCtr, byte[] c)? ExtractBoltCardFromRequest( 72 | Uri requestUri, byte[] aesKey, 73 | out string error) 74 | { 75 | var query = requestUri.ParseQueryString(); 76 | 77 | var pParam = query.Get("p"); 78 | if (pParam is null) 79 | { 80 | error = "p parameter is missing"; 81 | return null; 82 | } 83 | 84 | var pResult = ExtractUidAndCounterFromP(pParam, aesKey, out error); 85 | if (error is not null || pResult is null) 86 | { 87 | return null; 88 | } 89 | 90 | var cParam = query.Get("c"); 91 | 92 | if (cParam is null) 93 | { 94 | error = "c parameter is missing"; 95 | return null; 96 | } 97 | 98 | 99 | if (!HexEncoder.IsWellFormed(cParam)) 100 | { 101 | error = "c parameter is not hex"; 102 | return null; 103 | } 104 | 105 | var cRaw = Convert.FromHexString(cParam); 106 | if (cRaw.Length != 8) 107 | { 108 | error = "c parameter length not valid"; 109 | return null; 110 | } 111 | 112 | 113 | return (pResult.Value.uid, pResult.Value.counter, pResult.Value.rawUid, pResult.Value.rawCtr, cRaw); 114 | } 115 | 116 | private static byte[] AesEncrypt(byte[] key, byte[] iv, byte[] data) 117 | { 118 | using MemoryStream ms = new MemoryStream(); 119 | using var aes = Aes.Create(); 120 | 121 | aes.Mode = CipherMode.CBC; 122 | aes.Padding = PaddingMode.None; 123 | 124 | using var cs = new CryptoStream(ms, aes.CreateEncryptor(key, iv), CryptoStreamMode.Write); 125 | cs.Write(data, 0, data.Length); 126 | cs.FlushFinalBlock(); 127 | 128 | return ms.ToArray(); 129 | } 130 | 131 | private static byte[] RotateLeft(byte[] b) 132 | { 133 | byte[] r = new byte[b.Length]; 134 | byte carry = 0; 135 | 136 | for (int i = b.Length - 1; i >= 0; i--) 137 | { 138 | ushort u = (ushort) (b[i] << 1); 139 | r[i] = (byte) ((u & 0xff) + carry); 140 | carry = (byte) ((u & 0xff00) >> 8); 141 | } 142 | 143 | return r; 144 | } 145 | 146 | private static byte[] AesCmac(byte[] key, byte[] data) 147 | { 148 | // SubKey generation 149 | // step 1, AES-128 with key K is applied to an all-zero input block. 150 | byte[] L = AesEncrypt(key, new byte[16], new byte[16]); 151 | 152 | // step 2, K1 is derived through the following operation: 153 | byte[] 154 | FirstSubkey = 155 | RotateLeft(L); //If the most significant bit of L is equal to 0, K1 is the left-shift of L by 1 bit. 156 | if ((L[0] & 0x80) == 0x80) 157 | FirstSubkey[15] ^= 158 | 0x87; // Otherwise, K1 is the exclusive-OR of const_Rb and the left-shift of L by 1 bit. 159 | 160 | // step 3, K2 is derived through the following operation: 161 | byte[] 162 | SecondSubkey = 163 | RotateLeft(FirstSubkey); // If the most significant bit of K1 is equal to 0, K2 is the left-shift of K1 by 1 bit. 164 | if ((FirstSubkey[0] & 0x80) == 0x80) 165 | SecondSubkey[15] ^= 166 | 0x87; // Otherwise, K2 is the exclusive-OR of const_Rb and the left-shift of K1 by 1 bit. 167 | 168 | // MAC computing 169 | if (((data.Length != 0) && (data.Length % 16 == 0)) == true) 170 | { 171 | // If the size of the input message block is equal to a positive multiple of the block size (namely, 128 bits), 172 | // the last block shall be exclusive-OR'ed with K1 before processing 173 | for (int j = 0; j < FirstSubkey.Length; j++) 174 | data[data.Length - 16 + j] ^= FirstSubkey[j]; 175 | } 176 | else 177 | { 178 | // Otherwise, the last block shall be padded with 10^i 179 | byte[] padding = new byte[16 - data.Length % 16]; 180 | padding[0] = 0x80; 181 | 182 | data = data.Concat(padding.AsEnumerable()).ToArray(); 183 | 184 | // and exclusive-OR'ed with K2 185 | for (int j = 0; j < SecondSubkey.Length; j++) 186 | data[data.Length - 16 + j] ^= SecondSubkey[j]; 187 | } 188 | 189 | // The result of the previous process will be the input of the last encryption. 190 | byte[] encResult = AesEncrypt(key, new byte[16], data); 191 | 192 | byte[] HashValue = new byte[16]; 193 | Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length); 194 | 195 | return HashValue; 196 | } 197 | 198 | private static byte[] GetSunMac(byte[] key, byte[] sv2) 199 | { 200 | var cmac1 = AesCmac(key, sv2); 201 | var cmac2 = AesCmac(cmac1, Array.Empty()); 202 | 203 | var halfMac = new byte[cmac2.Length / 2]; 204 | for (var i = 1; i < cmac2.Length; i += 2) 205 | { 206 | halfMac[i >> 1] = cmac2[i]; 207 | } 208 | 209 | return halfMac; 210 | } 211 | 212 | /// 213 | /// Verifies the CMAC for given UID, counter, key, and CMAC data. 214 | /// 215 | /// The user ID. 216 | /// The counter data. 217 | /// The CMAC key. 218 | /// The CMAC data to verify against. 219 | /// Outputs an error string if verification fails. 220 | /// True if CMAC verification is successful, otherwise false. 221 | public static bool CheckCmac(byte[] uid, byte[] ctr, byte[] k2CmacKey, byte[] cmac, out string error) 222 | { 223 | if (uid.Length != 7 || ctr.Length != 3 || k2CmacKey.Length != AES_BLOCK_SIZE) 224 | { 225 | error = "Invalid input lengths."; 226 | return false; 227 | } 228 | 229 | byte[] sv2 = new byte[] 230 | { 231 | 0x3c, 0xc3, 0x00, 0x01, 0x00, 0x80, 232 | uid[0], uid[1], uid[2], uid[3], uid[4], uid[5], uid[6], 233 | ctr[0], ctr[1], ctr[2] 234 | }; 235 | 236 | try 237 | { 238 | byte[] computedCmac = GetSunMac(k2CmacKey, sv2); 239 | 240 | if (computedCmac.Length != cmac.Length) 241 | { 242 | error = "Computed CMAC length mismatch."; 243 | return false; 244 | } 245 | 246 | if (!computedCmac.SequenceEqual(cmac)) 247 | { 248 | error = "CMAC verification failed."; 249 | return false; 250 | } 251 | 252 | error = null; 253 | return true; 254 | } 255 | catch (Exception ex) 256 | { 257 | error = ex.Message; 258 | return false; 259 | } 260 | } 261 | 262 | public static byte[] CreateCValue(string uid, uint counter, byte[] k2CmacKey) 263 | { 264 | var ctr = new byte[3]; 265 | ctr[2] = (byte) (counter >> 16); 266 | ctr[1] = (byte) (counter >> 8); 267 | ctr[0] = (byte) (counter); 268 | 269 | var uidBytes = Convert.FromHexString(uid); 270 | return CreateCValue(uidBytes, ctr, k2CmacKey); 271 | } 272 | 273 | public static byte[] CreateCValue(byte[] uid, byte[] counter, byte[] k2CmacKey) 274 | { 275 | if (uid.Length != 7 || counter.Length != 3 || k2CmacKey.Length != AES_BLOCK_SIZE) 276 | { 277 | throw new ArgumentException("Invalid input lengths."); 278 | } 279 | 280 | byte[] sv2 = 281 | { 282 | 0x3c, 0xc3, 0x00, 0x01, 0x00, 0x80, 283 | uid[0], uid[1], uid[2], uid[3], uid[4], uid[5], uid[6], 284 | counter[0], counter[1], counter[2] 285 | }; 286 | 287 | var computedCmac = GetSunMac(k2CmacKey, sv2); 288 | 289 | return computedCmac; 290 | } 291 | 292 | public static byte[] CreatePValue(byte[] aesKey, uint counter, string uid) 293 | { 294 | using var aes = Aes.Create(); 295 | aes.Key = aesKey; 296 | aes.IV = new byte[16]; // assuming IV is zeros. Adjust if needed. 297 | aes.Mode = CipherMode.CBC; 298 | aes.Padding = PaddingMode.None; 299 | 300 | // Constructing the 16-byte array to be encrypted 301 | byte[] toEncrypt = new byte[16]; 302 | toEncrypt[0] = 0xC7; // First byte is 0xC7 303 | 304 | var uidBytes = Convert.FromHexString(uid); 305 | Array.Copy(uidBytes, 0, toEncrypt, 1, uidBytes.Length); 306 | 307 | // Counter 308 | toEncrypt[8] = (byte) (counter & 0xFF); // least-significant byte 309 | toEncrypt[9] = (byte) ((counter >> 8) & 0xFF); 310 | toEncrypt[10] = (byte) ((counter >> 16) & 0xFF); 311 | 312 | // Encryption 313 | var encryptor = aes.CreateEncryptor(aes.Key, aes.IV); 314 | byte[] encryptedData; 315 | using var memoryStream = new System.IO.MemoryStream(); 316 | using (var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write)) 317 | { 318 | cryptoStream.Write(toEncrypt, 0, toEncrypt.Length); 319 | } 320 | 321 | encryptedData = memoryStream.ToArray(); 322 | 323 | var result = ExtractUidAndCounterFromP(encryptedData, aesKey, out var error); 324 | 325 | return encryptedData; 326 | } 327 | } 328 | } -------------------------------------------------------------------------------- /LNURL.Tests/UnitTest1.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using NBitcoin; 6 | using NBitcoin.Altcoins.Elements; 7 | using NBitcoin.Crypto; 8 | using NBitcoin.DataEncoders; 9 | using Newtonsoft.Json; 10 | using Xunit; 11 | 12 | namespace LNURL.Tests 13 | { 14 | public class UnitTest1 15 | { 16 | [Fact] 17 | public void CanHandlePayLinkEdgeCase() 18 | { 19 | // from https://github.com/btcpayserver/btcpayserver/issues/4393 20 | var json = 21 | "{" + 22 | " \"callback\": \"https://coincorner.io/lnurl/withdrawreq/auth/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?picc_data=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"," + 23 | " \"defaultDescription\": \"CoinCorner Withdrawal ⚡️\"," + 24 | " \"maxWithdrawable\": 363003000," + 25 | " \"minWithdrawable\": 1000," + 26 | " \"k1\": \"xxxxxxxxxx\"," + 27 | " \"tag\": \"withdrawRequest\"," + 28 | " \"payLink\": \"\"" + 29 | "}"; 30 | 31 | var req = JsonConvert.DeserializeObject(json); 32 | 33 | Assert.Null(req.PayLink); 34 | 35 | json = 36 | "{" + 37 | " \"callback\": \"https://coincorner.io/lnurl/withdrawreq/auth/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx?picc_data=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"," + 38 | " \"defaultDescription\": \"CoinCorner Withdrawal ⚡️\"," + 39 | " \"maxWithdrawable\": 363003000," + 40 | " \"minWithdrawable\": 1000," + 41 | " \"k1\": \"xxxxxxxxxx\"," + 42 | " \"tag\": \"withdrawRequest\"" + 43 | "}"; 44 | 45 | req = JsonConvert.DeserializeObject(json); 46 | 47 | Assert.Null(req.PayLink); 48 | } 49 | 50 | [Theory] 51 | [InlineData("kukks@btcpay.kukks.org", "https://btcpay.kukks.org/.well-known/lnurlp/kukks")] 52 | [InlineData("kukks@btcpay.kukks.org:4000", "https://btcpay.kukks.org:4000/.well-known/lnurlp/kukks")] 53 | [InlineData("kukks@tor.onion","http://tor.onion/.well-known/lnurlp/kukks")] 54 | [InlineData("kukks@tor.onion:4000","http://tor.onion:4000/.well-known/lnurlp/kukks")] 55 | public void CanParseLightningAddress(string lightningAddress, string expectedUrl) 56 | { 57 | Assert.Equal(expectedUrl, LNURL.ExtractUriFromInternetIdentifier(lightningAddress).ToString()); 58 | } 59 | 60 | [Fact] 61 | public void CanEncodeDecodeLNUrl() 62 | { 63 | var uri = LNURL.Parse( 64 | "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS", 65 | out var tag); 66 | Assert.Null(tag); 67 | Assert.NotNull(uri); 68 | Assert.Equal("https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df", 69 | uri.ToString()); 70 | Assert.Equal( 71 | "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS", 72 | LNURL.EncodeBech32(uri), StringComparer.InvariantCultureIgnoreCase); 73 | 74 | Assert.Equal( 75 | "lightning:LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS", 76 | LNURL.EncodeUri(uri, null, true).ToString(), StringComparer.InvariantCultureIgnoreCase); 77 | 78 | Assert.Throws(() => { LNURL.EncodeUri(uri, null, false); }); 79 | Assert.Throws(() => { LNURL.EncodeUri(uri, "swddwdd", false); }); 80 | var payRequestUri = LNURL.EncodeUri(uri, "payRequest", false); 81 | Assert.Equal("lnurlp", payRequestUri.Scheme); 82 | 83 | 84 | uri = LNURL.Parse( 85 | "lnurlp://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df", 86 | out tag); 87 | Assert.Equal("payRequest", tag); 88 | Assert.NotNull(uri); 89 | Assert.Equal("https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df", 90 | uri.ToString()); 91 | } 92 | 93 | [Fact] 94 | public async Task CanUseLNURLAUTH() 95 | { 96 | Assert.Throws(() => { LNURL.EncodeUri(new Uri("https://kukks.org"), "login", true); }); 97 | Assert.Throws(() => 98 | { 99 | LNURL.EncodeUri(new Uri("https://kukks.org?tag=login"), "login", true); 100 | }); 101 | Assert.Throws(() => 102 | { 103 | LNURL.EncodeUri(new Uri("https://kukks.org?tag=login&k1=123"), "login", true); 104 | }); 105 | Assert.Throws(() => 106 | { 107 | var k1 = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); 108 | LNURL.EncodeUri(new Uri($"https://kukks.org?tag=login&k1={k1}&action=xyz"), "login", true); 109 | }); 110 | 111 | var k1 = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); 112 | var lnurl = LNURL.EncodeUri(new Uri($"https://kukks.org?tag=login&k1={k1}"), "login", true); 113 | 114 | var request = Assert.IsType(await LNURL.FetchInformation(lnurl, null)); 115 | 116 | var linkingKey = new Key(); 117 | var sig = request.SignChallenge(linkingKey); 118 | Assert.True(LNAuthRequest.VerifyChallenge(sig, linkingKey.PubKey, Encoders.Hex.DecodeData(k1))); 119 | } 120 | 121 | [Fact] 122 | public async Task payerDataSerializerTest() 123 | { 124 | var req = 125 | new LNURLPayRequest() 126 | { 127 | PayerData = new LNURLPayRequest.LUD18PayerData() 128 | { 129 | Auth = new LNURLPayRequest.AuthPayerDataField() 130 | { 131 | K1 = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)), 132 | Mandatory = false, 133 | }, 134 | Pubkey = new LNURLPayRequest.PayerDataField() 135 | { 136 | Mandatory = true 137 | }, 138 | Name = new LNURLPayRequest.PayerDataField() 139 | { 140 | } 141 | } 142 | }; 143 | 144 | req = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(req)); 145 | Assert.NotNull(req.PayerData); 146 | Assert.True(req.PayerData.Pubkey.Mandatory); 147 | Assert.False(req.PayerData.Name.Mandatory); 148 | Assert.False(req.PayerData.Auth.Mandatory); 149 | Assert.Null(req.PayerData.Email); 150 | 151 | var k = new Key(); 152 | 153 | var resp = new LNURLPayRequest.LUD18PayerDataResponse() 154 | { 155 | Auth = new LNURLPayRequest.LUD18AuthPayerDataResponse() 156 | { 157 | K1 = req.PayerData.Auth.K1, 158 | Key = k.PubKey, 159 | Sig = k.Sign(new uint256(Encoders.Hex.DecodeData(req.PayerData.Auth.K1))) 160 | }, 161 | }; 162 | 163 | resp = JsonConvert.DeserializeObject( 164 | JsonConvert.SerializeObject(resp)); 165 | Assert.False(req.VerifyPayerData(resp)); 166 | resp.Pubkey = k.PubKey; 167 | resp = JsonConvert.DeserializeObject( 168 | JsonConvert.SerializeObject(resp)); 169 | Assert.True(req.VerifyPayerData(resp)); 170 | resp.Email = "test@test.com"; 171 | Assert.False(req.VerifyPayerData(resp)); 172 | 173 | resp.Email = null; 174 | resp.Name = "sdasds"; 175 | Assert.True(req.VerifyPayerData(resp)); 176 | } 177 | 178 | [Fact] 179 | public async Task CanUseBoltCardHelper() 180 | { 181 | var key = Convert.FromHexString("0c3b25d92b38ae443229dd59ad34b85d"); 182 | var cmacKey = Convert.FromHexString("b45775776cb224c75bcde7ca3704e933"); 183 | var result = BoltCardHelper.ExtractBoltCardFromRequest( 184 | new Uri("https://test.com?p=4E2E289D945A66BB13377A728884E867&c=E19CCB1FED8892CE"), 185 | key, out var error); 186 | 187 | Assert.Null(error); 188 | Assert.NotNull(result); 189 | Assert.Equal((uint) 3, result.Value.counter); 190 | Assert.Equal("04996c6a926980", result.Value.uid); 191 | Assert.True(BoltCardHelper.CheckCmac(result.Value.rawUid, result.Value.rawCtr, cmacKey, result.Value.c, 192 | out error)); 193 | 194 | var manualP = BoltCardHelper.CreatePValue(key, result.Value.counter, result.Value.uid); 195 | var manualPResult = BoltCardHelper.ExtractUidAndCounterFromP(manualP, key, out error); 196 | Assert.Null(error); 197 | Assert.NotNull(manualPResult); 198 | Assert.Equal((uint) 3, manualPResult.Value.counter); 199 | Assert.Equal("04996c6a926980", manualPResult.Value.uid); 200 | 201 | var manualC = BoltCardHelper.CreateCValue(result.Value.rawUid, result.Value.rawCtr, cmacKey); 202 | Assert.Equal(result.Value.c, manualC); 203 | } 204 | 205 | [Fact] 206 | public async Task DeterministicCards() 207 | { 208 | var masterSeed = RandomUtils.GetBytes(64); 209 | var masterSeedSlip21 = Slip21Node.FromSeed(masterSeed); 210 | 211 | var i = Random.Shared.Next(0, 10000); 212 | var k1 = masterSeedSlip21.DeriveChild(i + "k1").Key.ToBytes().Take(16).ToArray(); 213 | var k2 = masterSeedSlip21.DeriveChild(i + "k2").Key.ToBytes().Take(16).ToArray(); 214 | 215 | var counter = (uint) Random.Shared.Next(0, 1000); 216 | var uid = Convert.ToHexString(RandomUtils.GetBytes(7)); 217 | var pParam = Convert.ToHexString(BoltCardHelper.CreatePValue(k1, counter, uid)); 218 | var cParam = Convert.ToHexString(BoltCardHelper.CreateCValue(uid, counter, k2)); 219 | var lnurlw = $"https://test.com?p={pParam}&c={cParam}"; 220 | 221 | var result = BoltCardHelper.ExtractBoltCardFromRequest(new Uri(lnurlw), k1, out var error); 222 | Assert.Null(error); 223 | Assert.NotNull(result); 224 | Assert.Equal(uid.ToLowerInvariant(), result.Value.uid.ToLowerInvariant()); 225 | Assert.Equal(counter, result.Value.counter); 226 | Assert.True(BoltCardHelper.CheckCmac(result.Value.rawUid, result.Value.rawCtr, k2, result.Value.c, 227 | out error)); 228 | Assert.Null(error); 229 | 230 | 231 | for (int j = 0; j <= 10000; j++) 232 | { 233 | var brutek1 = masterSeedSlip21.DeriveChild(j + "k1").Key.ToBytes().Take(16).ToArray(); 234 | var brutek2 = masterSeedSlip21.DeriveChild(j + "k2").Key.ToBytes().Take(16).ToArray(); 235 | try 236 | { 237 | var bruteResult = BoltCardHelper.ExtractBoltCardFromRequest(new Uri(lnurlw), brutek1, out error); 238 | Assert.Null(error); 239 | Assert.NotNull(bruteResult); 240 | Assert.Equal(uid.ToLowerInvariant(), bruteResult.Value.uid.ToLowerInvariant()); 241 | Assert.Equal(counter, bruteResult.Value.counter); 242 | Assert.True(BoltCardHelper.CheckCmac(bruteResult.Value.rawUid, bruteResult.Value.rawCtr, brutek2, 243 | bruteResult.Value.c, out error)); 244 | Assert.Null(error); 245 | 246 | break; 247 | } 248 | catch (Exception e) 249 | { 250 | } 251 | } 252 | } 253 | //from https://github.com/boltcard/boltcard/blob/7745c9f20d5ad0129cb4b3fc534441038e79f5e6/docs/TEST_VECTORS.md 254 | [Theory] 255 | [InlineData("4E2E289D945A66BB13377A728884E867", "E19CCB1FED8892CE", "04996c6a926980", 3)] 256 | [InlineData("00F48C4F8E386DED06BCDC78FA92E2FE", "66B4826EA4C155B4", "04996c6a926980", 5)] 257 | [InlineData("0DBF3C59B59B0638D60B5842A997D4D1", "CC61660C020B4D96", "04996c6a926980", 7)] 258 | public void TestDecryptAndValidate(string pValueHex, string cValueHex, string expectedUidHex, uint expectedCtr) 259 | { 260 | 261 | var aesDecryptKey = Convert.FromHexString("0c3b25d92b38ae443229dd59ad34b85d"); 262 | var aesCmacKey = Convert.FromHexString("b45775776cb224c75bcde7ca3704e933"); 263 | byte[] pValue = Convert.FromHexString(pValueHex); 264 | byte[] cValue = Convert.FromHexString(cValueHex); 265 | 266 | // Decrypt p value 267 | var res = BoltCardHelper.ExtractUidAndCounterFromP(pValue, aesDecryptKey, out _); 268 | 269 | // Check UID and counter 270 | Assert.Equal(expectedUidHex, res.Value.uid); 271 | Assert.Equal(expectedCtr, res.Value.counter); 272 | 273 | // Validate CMAC 274 | var cmacIsValid = BoltCardHelper.CheckCmac(res.Value.rawUid, res.Value.rawCtr, aesCmacKey, cValue, out _); 275 | Assert.True(cmacIsValid, "CMAC validation failed"); 276 | } 277 | 278 | } 279 | } -------------------------------------------------------------------------------- /LNURL/LNURLPayRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using System.Web; 11 | using BTCPayServer.Lightning; 12 | using BTCPayServer.Lightning.JsonConverters; 13 | using LNURL.JsonConverters; 14 | using NBitcoin; 15 | using NBitcoin.Crypto; 16 | using NBitcoin.DataEncoders; 17 | using Newtonsoft.Json; 18 | using Newtonsoft.Json.Linq; 19 | 20 | namespace LNURL; 21 | 22 | /// 23 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/06.md 24 | /// 25 | public class LNURLPayRequest 26 | { 27 | [JsonProperty("callback")] 28 | [JsonConverter(typeof(UriJsonConverter))] 29 | public Uri Callback { get; set; } 30 | 31 | [JsonProperty("metadata")] public string Metadata { get; set; } 32 | 33 | [JsonIgnore] 34 | public List> ParsedMetadata => JsonConvert 35 | .DeserializeObject(Metadata ?? string.Empty) 36 | .Select(strings => new KeyValuePair(strings[0], strings[1])).ToList(); 37 | 38 | [JsonProperty("tag")] public string Tag { get; set; } 39 | 40 | [JsonProperty("minSendable")] 41 | [JsonConverter(typeof(LightMoneyJsonConverter))] 42 | public LightMoney MinSendable { get; set; } 43 | 44 | [JsonProperty("maxSendable")] 45 | [JsonConverter(typeof(LightMoneyJsonConverter))] 46 | public LightMoney MaxSendable { get; set; } 47 | 48 | /// 49 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/12.md 50 | /// 51 | [JsonProperty("commentAllowed", NullValueHandling = NullValueHandling.Ignore)] 52 | public int? CommentAllowed { get; set; } 53 | 54 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/19.md 55 | [JsonProperty("withdrawLink", NullValueHandling = NullValueHandling.Ignore)] 56 | [JsonConverter(typeof(UriJsonConverter))] 57 | public Uri WithdrawLink { get; set; } 58 | 59 | //https://github.com/fiatjaf/lnurl-rfc/blob/luds/18.md 60 | [JsonProperty("payerData", NullValueHandling = NullValueHandling.Ignore)] 61 | public LUD18PayerData PayerData { get; set; } 62 | 63 | [JsonExtensionData] public IDictionary AdditionalData { get; set; } 64 | //https://github.com/nostr-protocol/nips/blob/master/57.md 65 | [JsonProperty("nostrPubkey", NullValueHandling = NullValueHandling.Ignore)] 66 | public string? NostrPubkey { get; set; } 67 | //https://github.com/nostr-protocol/nips/blob/master/57.md 68 | [JsonProperty("allowsNostr", NullValueHandling = NullValueHandling.Ignore)] 69 | public bool? AllowsNostr { get; set; } 70 | 71 | public bool VerifyPayerData(LUD18PayerDataResponse response) 72 | { 73 | return VerifyPayerData(PayerData, response); 74 | } 75 | 76 | public static bool VerifyPayerData(LUD18PayerData payerFields, LUD18PayerDataResponse payerData) 77 | { 78 | if ((payerFields.Name is null && !string.IsNullOrEmpty(payerData.Name)) || 79 | (payerFields.Name?.Mandatory is true && string.IsNullOrEmpty(payerData.Name)) || 80 | (payerFields.Pubkey is null && payerData.Pubkey is not null) || 81 | (payerFields.Pubkey?.Mandatory is true && payerData.Pubkey is null) || 82 | (payerFields.Email is null && !string.IsNullOrEmpty(payerData.Email)) || 83 | (payerFields.Email?.Mandatory is true && string.IsNullOrEmpty(payerData.Email)) || 84 | (payerFields.Auth is null && payerData.Auth is not null) || 85 | (payerFields.Auth?.Mandatory is true && payerData.Auth is null) || 86 | payerFields.Auth?.K1 != payerData.Auth?.K1 || 87 | !LNAuthRequest.VerifyChallenge(payerData.Auth.Sig, payerData.Auth.Key, 88 | Encoders.Hex.DecodeData(payerData.Auth?.K1))) 89 | return false; 90 | 91 | return true; 92 | } 93 | 94 | public async Task SendRequest(LightMoney amount, Network network, 95 | HttpClient httpClient, string comment = null, LUD18PayerDataResponse payerData = null, CancellationToken cancellationToken = default) 96 | { 97 | var url = Callback; 98 | var uriBuilder = new UriBuilder(url); 99 | LNURL.AppendPayloadToQuery(uriBuilder, "amount", amount.MilliSatoshi.ToString()); 100 | if (!string.IsNullOrEmpty(comment)) LNURL.AppendPayloadToQuery(uriBuilder, "comment", comment); 101 | 102 | if (payerData is not null) 103 | LNURL.AppendPayloadToQuery(uriBuilder, "payerdata", 104 | HttpUtility.UrlEncode(JsonConvert.SerializeObject(payerData))); 105 | 106 | url = new Uri(uriBuilder.ToString()); 107 | var response = await httpClient.GetAsync(url, cancellationToken); 108 | var json = JObject.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); 109 | if (LNUrlStatusResponse.IsErrorResponse(json, out var error)) throw new LNUrlException(error.Reason); 110 | 111 | var result = json.ToObject(); 112 | if (result.Verify(this, amount, network, out var invoice)) return result; 113 | 114 | throw new LNUrlException( 115 | "LNURL payRequest returned an invoice but its amount or hash did not match the request"); 116 | } 117 | 118 | public class PayerDataField 119 | { 120 | [JsonProperty("mandatory", DefaultValueHandling = DefaultValueHandling.Populate)] 121 | public bool Mandatory { get; set; } 122 | } 123 | 124 | public class LUD18PayerData 125 | { 126 | [JsonProperty("name", DefaultValueHandling = DefaultValueHandling.Ignore)] 127 | public PayerDataField Name { get; set; } 128 | 129 | [JsonProperty("pubkey", DefaultValueHandling = DefaultValueHandling.Ignore)] 130 | public PayerDataField Pubkey { get; set; } 131 | 132 | [JsonProperty("email", DefaultValueHandling = DefaultValueHandling.Ignore)] 133 | public PayerDataField Email { get; set; } 134 | 135 | [JsonProperty("auth", DefaultValueHandling = DefaultValueHandling.Ignore)] 136 | public AuthPayerDataField Auth { get; set; } 137 | } 138 | 139 | public class LUD18PayerDataResponse 140 | { 141 | [JsonProperty("name", DefaultValueHandling = DefaultValueHandling.Ignore)] 142 | public string Name { get; set; } 143 | 144 | [JsonProperty("pubkey", DefaultValueHandling = DefaultValueHandling.Ignore)] 145 | [JsonConverter(typeof(PubKeyJsonConverter))] 146 | public PubKey Pubkey { get; set; } 147 | 148 | [JsonProperty("email", DefaultValueHandling = DefaultValueHandling.Ignore)] 149 | public string Email { get; set; } 150 | 151 | [JsonProperty("auth", DefaultValueHandling = DefaultValueHandling.Ignore)] 152 | public LUD18AuthPayerDataResponse Auth { get; set; } 153 | } 154 | 155 | public class LUD18AuthPayerDataResponse 156 | { 157 | [JsonProperty("key")] 158 | [JsonConverter(typeof(PubKeyJsonConverter))] 159 | public PubKey Key { get; set; } 160 | 161 | [JsonProperty("k1")] public string K1 { get; set; } 162 | 163 | [JsonProperty("sig")] 164 | [JsonConverter(typeof(SigJsonConverter))] 165 | public ECDSASignature Sig { get; set; } 166 | } 167 | 168 | public class AuthPayerDataField : PayerDataField 169 | { 170 | [JsonProperty("k1")] public string K1 { get; set; } 171 | } 172 | 173 | public class LNURLPayRequestCallbackResponse 174 | { 175 | [JsonIgnore] private BOLT11PaymentRequest _paymentRequest; 176 | 177 | [JsonProperty("pr")] public string Pr { get; set; } 178 | 179 | [JsonProperty("routes")] public string[] Routes { get; set; } = Array.Empty(); 180 | 181 | /// 182 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/11.md 183 | /// 184 | [JsonProperty("disposable")] 185 | public bool? Disposable { get; set; } 186 | 187 | /// 188 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/09.md 189 | /// 190 | [JsonProperty("successAction")] 191 | [JsonConverter(typeof(LNURLPayRequestSuccessActionJsonConverter))] 192 | public ILNURLPayRequestSuccessAction SuccessAction { get; set; } 193 | 194 | public bool Verify(LNURLPayRequest request, LightMoney expectedAmount, Network network, 195 | out BOLT11PaymentRequest bolt11PaymentRequest, bool verifyDescriptionHash = false) 196 | { 197 | if (string.IsNullOrEmpty(Pr)) 198 | { 199 | bolt11PaymentRequest = null; 200 | return false; 201 | } 202 | if (_paymentRequest != null) 203 | bolt11PaymentRequest = _paymentRequest; 204 | else if (!BOLT11PaymentRequest.TryParse(Pr, out bolt11PaymentRequest, network)) 205 | return false; 206 | else 207 | _paymentRequest = bolt11PaymentRequest; 208 | 209 | return _paymentRequest.MinimumAmount == expectedAmount && (!verifyDescriptionHash || 210 | _paymentRequest.VerifyDescriptionHash(request.Metadata)); 211 | } 212 | 213 | public BOLT11PaymentRequest GetPaymentRequest(Network network) 214 | { 215 | _paymentRequest ??= BOLT11PaymentRequest.Parse(Pr, network); 216 | return _paymentRequest; 217 | } 218 | 219 | public interface ILNURLPayRequestSuccessAction 220 | { 221 | public string Tag { get; set; } 222 | } 223 | 224 | /// 225 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/09.md 226 | /// 227 | public class LNURLPayRequestSuccessActionMessage : ILNURLPayRequestSuccessAction 228 | { 229 | [JsonProperty("message")] public string Message { get; set; } 230 | 231 | [JsonProperty("tag")] public string Tag { get; set; } 232 | } 233 | 234 | /// 235 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/09.md 236 | /// 237 | public class LNURLPayRequestSuccessActionUrl : ILNURLPayRequestSuccessAction 238 | { 239 | [JsonProperty("description")] public string Description { get; set; } 240 | 241 | [JsonProperty("url")] 242 | [JsonConverter(typeof(UriJsonConverter))] 243 | public string Url { get; set; } 244 | 245 | [JsonProperty("tag")] public string Tag { get; set; } 246 | } 247 | 248 | /// 249 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/09.md 250 | /// https://github.com/fiatjaf/lnurl-rfc/blob/luds/10.md 251 | /// 252 | public class LNURLPayRequestSuccessActionAES : ILNURLPayRequestSuccessAction 253 | { 254 | [JsonProperty("description")] public string Description { get; set; } 255 | [JsonProperty("ciphertext")] public string CipherText { get; set; } 256 | [JsonProperty("iv")] public string IV { get; set; } 257 | [JsonProperty("tag")] public string Tag { get; set; } 258 | 259 | public string Decrypt(string preimage) 260 | { 261 | var cipherText = Encoders.Base64.DecodeData(CipherText); 262 | // Declare the string used to hold 263 | // the decrypted text. 264 | string plaintext = null; 265 | 266 | // Create an Aes object 267 | // with the specified key and IV. 268 | using (var aesAlg = Aes.Create()) 269 | { 270 | aesAlg.Key = Encoding.UTF8.GetBytes(preimage); 271 | aesAlg.IV = Encoders.Base64.DecodeData(IV); 272 | 273 | // Create a decryptor to perform the stream transform. 274 | var decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV); 275 | 276 | // Create the streams used for decryption. 277 | using (var msDecrypt = new MemoryStream(cipherText)) 278 | { 279 | using (var csDecrypt = 280 | new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read)) 281 | { 282 | using (var srDecrypt = new StreamReader(csDecrypt)) 283 | { 284 | // Read the decrypted bytes from the decrypting stream 285 | // and place them in a string. 286 | plaintext = srDecrypt.ReadToEnd(); 287 | } 288 | } 289 | } 290 | } 291 | 292 | return plaintext; 293 | } 294 | } 295 | 296 | 297 | public class LNURLPayRequestSuccessActionJsonConverter : JsonConverter 298 | { 299 | public override void WriteJson(JsonWriter writer, ILNURLPayRequestSuccessAction value, 300 | JsonSerializer serializer) 301 | { 302 | if (value is null) 303 | { 304 | writer.WriteNull(); 305 | return; 306 | } 307 | 308 | JObject.FromObject(value).WriteTo(writer); 309 | } 310 | 311 | public override ILNURLPayRequestSuccessAction ReadJson(JsonReader reader, Type objectType, 312 | ILNURLPayRequestSuccessAction existingValue, 313 | bool hasExistingValue, JsonSerializer serializer) 314 | { 315 | if (reader.TokenType is JsonToken.Null) return null; 316 | var jobj = JObject.Load(reader); 317 | switch (jobj.GetValue("tag").Value()) 318 | { 319 | case "message": 320 | return jobj.ToObject(); 321 | case "url": 322 | return jobj.ToObject(); 323 | case "aes": 324 | return jobj.ToObject(); 325 | } 326 | 327 | throw new FormatException(); 328 | } 329 | } 330 | } 331 | } 332 | --------------------------------------------------------------------------------