Last active
December 26, 2022 18:28
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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