Skip to content

Instantly share code, notes, and snippets.

@aalmada
Last active December 26, 2022 18:28
Show Gist options
  • Save aalmada/0ef235d12dab0d68f29c2b22b241e3c3 to your computer and use it in GitHub Desktop.
Save aalmada/0ef235d12dab0d68f29c2b22b241e3c3 to your computer and use it in GitHub Desktop.
EIP-4361: Sign-In with Ethereum (SIWE) message generation, parsing and verification in C#/.NET
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Nethereum.Signer;
using Nethereum.Util;
using NodaTime;
using NodaTime.Text;
namespace Farfetch.Web3;
public static class Siwe
{ public record Message(string Domain, string Address,
string? Statement, string Uri, string Version, int ChainId, string Nonce,
string IssuedAt, string? ExpirationTime = default, string? NotBefore = default,
string? RequestId = default, IReadOnlyCollection<string>? Resources = default)
{
public string Address {get;} =
Address.IsValidEthereumAddressHexFormat()
? AddressUtil.Current.ConvertToChecksumAddress(Address)
: throw new ArgumentException("Address not valid.", nameof(Address));
public string Uri {get;} =
System.Uri.IsWellFormedUriString(Uri, UriKind.Absolute)
? Uri
: throw new ArgumentException("Uri not valid.", nameof(Address));
public string Version {get;} =
Version == "1"
? Version
: throw new ArgumentException("Version not supported.", nameof(Version));
public string Nonce {get;} =
Nonce.Length >= 8
? Nonce
: throw new ArgumentException("Nonce requires at least 8 characters.", nameof(Nonce));
public string IssuedAt {get;} =
InstantPattern.ExtendedIso.Parse(IssuedAt).Success
? IssuedAt
: throw new ArgumentException("IssuedAt not valid", nameof(IssuedAt));
public string? ExpirationTime {get;} =
ExpirationTime is null || InstantPattern.ExtendedIso.Parse(ExpirationTime).Success
? ExpirationTime
: throw new ArgumentException("ExpirationTime not valid", nameof(ExpirationTime));
public string? NotBefore {get;} =
NotBefore is null || InstantPattern.ExtendedIso.Parse(NotBefore).Success
? NotBefore
: throw new ArgumentException("NotBefore not valid", nameof(NotBefore));
public string? RequestId {get;} =
RequestId is null || RequestId.Length != 0
? RequestId
: throw new ArgumentException("RequestId not valid.", nameof(Address));
public IReadOnlyCollection<string> Resources {get;} =
Resources is null
? Array.Empty<string>()
: Resources.All(resource => System.Uri.IsWellFormedUriString(resource, UriKind.Absolute))
? Resources
: throw new ArgumentException("Item in Resources is not valid.", nameof(Address));
public override string ToString()
{
var message = new StringBuilder();
message.Append(Domain).Append(" wants you to sign in with your Ethereum account:");
message.Append('\n').Append(Address);
message.Append('\n');
if (Statement is not null)
message.Append('\n').Append(Statement);
message.Append('\n');
message.Append('\n').Append("URI: ").Append(Uri);
message.Append('\n').Append("Version: ").Append(Version);
message.Append('\n').Append("Chain ID: ").Append(ChainId.ToString(CultureInfo.InvariantCulture));
message.Append('\n').Append("Nonce: ").Append(Nonce);
message.Append('\n').Append("Issued At: ").Append(IssuedAt);
if (ExpirationTime is not null)
message.Append('\n').Append("Expiration Time: ").Append(ExpirationTime);
if (NotBefore is not null)
message.Append('\n').Append("Not Before: ").Append(NotBefore);
if (RequestId is not null)
message.Append('\n').Append("Request ID: ").Append(RequestId);
if (Resources is not null && Resources.Count != 0)
{
message.Append('\n').Append("Resources:");
foreach(var resource in Resources)
message.Append('\n').Append("- ").Append(resource);
}
return message.ToString();
}
static readonly string messagePattern =
"(?<domain>(.*)) wants you to sign in with your Ethereum account:" +
"\\n(?<address>(.*))" +
"\\n" +
"(\\n(?<statement>(.*)))?" +
"\\n" +
"\\nURI: (?<uri>(.*))" +
"\\nVersion: (?<version>(.*))" +
"\\nChain ID: (?<chainId>(.*))" +
"\\nNonce: (?<nonce>(.*))" +
"\\nIssued At: (?<issuedAt>(.*))" +
"(\\nExpiration Time: (?<expirationTime>(.*)))?" +
"(\\nNot Before: (?<notBefore>(.*)))?" +
"(\\nRequest ID: (?<requestId>(.*)))?" +
"(\\nResources:(\\n- (?<resource>(.*)))+)?";
static readonly Regex regex = new (messagePattern);
public static Message Parse(string message)
{
var matches = regex.Matches(message);
if (matches.Count == 0)
throw new ArgumentException("Invalid message.", nameof(message));
var match = matches[0];
var domainGroup = match.Groups["domain"];
if (domainGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'domain' in message.", nameof(message));
var addressGroup = match.Groups["address"];
if (addressGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'address' in message.", nameof(message));
var statementGroup = match.Groups["statement"];
var statement = statementGroup.Captures.Count == 0
? default
: statementGroup.Value;
var uriGroup = match.Groups["uri"];
if (uriGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'uri' in message.", nameof(message));
var versionGroup = match.Groups["version"];
if (versionGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'version' in message.", nameof(message));
var chainIdGroup = match.Groups["chainId"];
if (chainIdGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'chainId' in message.", nameof(message));
if (!int.TryParse(chainIdGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var chainId))
throw new ArgumentException("Invalid 'chainId' in message.", nameof(message));
var nonceGroup = match.Groups["nonce"];
if (nonceGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'nonce' in message.", nameof(message));
var issuedAtGroup = match.Groups["issuedAt"];
if (issuedAtGroup.Captures.Count == 0)
throw new ArgumentException("Missing 'issuedAt' in message.", nameof(message));
var expirationTimeGroup = match.Groups["expirationTime"];
var expirationTime = expirationTimeGroup.Captures.Count == 0
? default
: expirationTimeGroup.Value;
var notBeforeGroup = match.Groups["notBefore"];
var notBefore = notBeforeGroup.Captures.Count == 0
? default
: notBeforeGroup.Value;
var requestIdGroup = match.Groups["requestId"];
var requestId = requestIdGroup.Captures.Count == 0
? default
: requestIdGroup.Value;
var resources = new List<string>();
foreach(var resource in (IEnumerable<Capture>)match.Groups["resource"].Captures)
resources.Add(resource.Value);
return new Message(
domainGroup.Value,
addressGroup.Value,
statement,
uriGroup.Value,
versionGroup.Value,
chainId,
nonceGroup.Value,
issuedAtGroup.Value,
expirationTime,
notBefore,
requestId,
resources);
}
public void Verify(string signature, string domain, string nonce, Instant time)
{
if(Domain != domain)
throw new Exception("Domain mismatch");
if(Nonce != nonce)
throw new Exception("Nonce mismatch");
if(ExpirationTime is not null)
{
var result = InstantPattern.ExtendedIso.Parse(ExpirationTime);
if (time >= result.Value)
throw new Exception("Message expired");
}
if(NotBefore is not null)
{
var result = InstantPattern.ExtendedIso.Parse(NotBefore);
if(time < result.Value)
throw new Exception("Message not activated yet");
}
var messageSigner = new EthereumMessageSigner();
var accountRecovered = messageSigner.EncodeUTF8AndEcRecover(ToString(), signature);
if (!accountRecovered.IsTheSameAddress(Address))
throw new Exception("Invalid signature");
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment