From a3db676892f04afb5b61ae8abf26c00cef167cfd Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 23 Aug 2023 12:58:35 -0300 Subject: [PATCH] In addition to the hashing, which might still be concerning since knowing your sponsors' emails and sponsored accounts might still allow reconstructing the hash, adding a random, per-install GUID completely removes this possibility. The new Session handles these environment variables so we don't even incur any I/O down the road from the analyzer: * SPONSORLINK_INSTALLATION: a GUID created if not already present (can be cleared by the user at any time to completely change all future hashes as needed), used for salting all hashes. * SPONSORLINK_TOKEN: an access token used to invoke the SponsorLink API to sign the manifest hashes. This is done only to allow integrity verification at analyzer/check time. * SPONSORLINK_MANIFEST: last sync'ed manifest JWT to check for sponsorships. Since the hashes are now effectively opaque by the server, all the server would do is sign the JWT received in the `/sign` endpoint with the corresponding private key, but otherwise, the JWT remains intact (only the expiration date is set from the server-side too when signing). Related to https://github.com/devlooped/SponsorLink/issues/31 --- src/Commands/AccountSettings.cs | 30 ----- src/Commands/Commands.csproj | 7 +- src/Commands/GitHub.cs | 28 ++++- src/Commands/GitHubCommand.cs | 50 -------- src/Commands/LinkCommand.cs | 97 ---------------- src/Commands/ListCommand.cs | 162 +++++++++++++++++++++----- src/Commands/Manifest.cs | 200 +++++++++++++++++++++++--------- src/Commands/Session.cs | 167 ++++++++++++++++++++++++++ src/Commands/ShowCommand.cs | 4 +- src/Commands/SyncCommand.cs | 189 ++++++++++++++++++++++++++++++ src/Commands/ValidateCommand.cs | 20 ++-- src/Extension/Extension.csproj | 2 + src/Extension/Program.cs | 34 +++++- src/Extension/TypeRegistrar.cs | 34 ++++++ src/Tests/ManifestTests.cs | 29 ++--- src/Tests/Signing.cs | 10 +- 16 files changed, 755 insertions(+), 308 deletions(-) delete mode 100644 src/Commands/AccountSettings.cs delete mode 100644 src/Commands/GitHubCommand.cs delete mode 100644 src/Commands/LinkCommand.cs create mode 100644 src/Commands/Session.cs create mode 100644 src/Commands/SyncCommand.cs create mode 100644 src/Extension/TypeRegistrar.cs diff --git a/src/Commands/AccountSettings.cs b/src/Commands/AccountSettings.cs deleted file mode 100644 index 84501c2..0000000 --- a/src/Commands/AccountSettings.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace Devlooped.SponsorLink; - -public partial class LinkCommand -{ - public class AccountSettings(string account) : CommandSettings - { - [Description("The account you are sponsoring.")] - [CommandArgument(0, "")] - public string Account { get; init; } = account; - - public override ValidationResult Validate() - { - if (string.IsNullOrWhiteSpace(Account)) - return ValidationResult.Error("Account is required."); - - if (GitHub.IsInstalled && - GitHub.Authenticate() is { } && - // If we are authenticated but can't get the acccount login from either org nor users - !GitHub.TryApi($"orgs/{Account}", ".login", out _) && - !GitHub.TryApi($"users/{Account}", ".login", out _)) - return ValidationResult.Error($"Specified account '{Account}' does not exist on GitHub. See https://github.com/{Account}."); - - return ValidationResult.Success(); - } - } -} diff --git a/src/Commands/Commands.csproj b/src/Commands/Commands.csproj index 61d5b5c..9b96f35 100644 --- a/src/Commands/Commands.csproj +++ b/src/Commands/Commands.csproj @@ -11,10 +11,15 @@ - + + + + + + diff --git a/src/Commands/GitHub.cs b/src/Commands/GitHub.cs index 4455c47..0d715cc 100644 --- a/src/Commands/GitHub.cs +++ b/src/Commands/GitHub.cs @@ -1,9 +1,12 @@ -using System.Text.Json; -using System.Text.Json.Serialization; + +using System.Text.Json; namespace Devlooped.SponsorLink; -public record Account([property: JsonPropertyName("node_id")] string Id, string Login); +public record Account(int Id, string Login) +{ + public string[] Emails { get; init; } = Array.Empty(); +} public static class GitHub { @@ -21,12 +24,17 @@ public static bool TryApi(string endpoint, string jq, out string? json) return Process.TryExecute("gh", args, out json); } - public static bool TryQuery(string query, string jq, out string? json) + public static bool TryQuery(string query, string jq, out string? json, params (string name, string value)[] fields) { var args = $"api graphql -f query=\"{query}\""; if (!string.IsNullOrEmpty(jq)) args += $" --jq \"{jq}\""; + foreach (var field in fields) + { + args += $" -f {field.name}={field.value}"; + } + return Process.TryExecute("gh", args, out json); } @@ -41,6 +49,16 @@ public static bool TryQuery(string query, string jq, out string? json) if (!Process.TryExecute("gh", "api user", out output)) return default; - return JsonSerializer.Deserialize(output, JsonOptions.Default); + if (JsonSerializer.Deserialize(output, JsonOptions.Default) is not { } account) + return default; + + if (!TryApi("user/emails", "[.[] | select(.verified == true) | .email]", out output) || + string.IsNullOrEmpty(output)) + return account; + + return account with + { + Emails = JsonSerializer.Deserialize(output, JsonOptions.Default) ?? Array.Empty() + }; } } diff --git a/src/Commands/GitHubCommand.cs b/src/Commands/GitHubCommand.cs deleted file mode 100644 index 9891124..0000000 --- a/src/Commands/GitHubCommand.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace Devlooped.SponsorLink; - -public abstract class GitHubCommand : Command - where TSettings : CommandSettings -{ - public sealed override int Execute([NotNull] CommandContext context, [NotNull] TSettings settings) - { - if (!GitHub.IsInstalled) - { - AnsiConsole.MarkupLine("[yellow]Please install GitHub CLI from [/][link]https://cli.github.com/[/]"); - return -1; - } - - if (GitHub.Authenticate() is not { } account) - { - AnsiConsole.MarkupLine("Please run [yellow]gh auth login[/] to authenticate, [yellow]gh auth status -h github.com[/] to verify your status."); - return -1; - } - - return OnExecute(account, settings, context); - } - - abstract protected int OnExecute(Account account, TSettings settings, CommandContext context); -} - -public abstract class GitHubCommand : Command -{ - public sealed override int Execute(CommandContext context) - { - if (!GitHub.IsInstalled) - { - AnsiConsole.MarkupLine("[yellow]Please install GitHub CLI from [/][link]https://cli.github.com/[/]"); - return -1; - } - - if (GitHub.Authenticate() is not { } account) - { - AnsiConsole.MarkupLine("Please run [yellow]gh auth login[/] to authenticate, [yellow]gh auth status -h github.com[/] to verify your status."); - return -1; - } - - return OnExecute(account, context); - } - - abstract protected int OnExecute(Account account, CommandContext context); -} diff --git a/src/Commands/LinkCommand.cs b/src/Commands/LinkCommand.cs deleted file mode 100644 index d407b49..0000000 --- a/src/Commands/LinkCommand.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Text.Json; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace Devlooped.SponsorLink; - -public partial class LinkCommand : GitHubCommand -{ - record Organization(string Email, string WebsiteUrl); - - protected override int OnExecute(Account user, CommandContext context) - { - if (!GitHub.TryQuery( - """ - query { - viewer { - sponsorshipsAsSponsor(activeOnly: true, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { - nodes { - sponsorable { - ... on Organization { - login - } - ... on User { - login - } - } - } - } - } - } - """, - """ - [.data.viewer.sponsorshipsAsSponsor.nodes.[].sponsorable.login] - """, out var json)) - return -1; - - if (string.IsNullOrEmpty(json)) - return 0; - - var sponsoring = JsonSerializer.Deserialize(json, JsonOptions.Default) ?? Array.Empty(); - if (sponsoring.Length == 0) - { - AnsiConsole.WriteLine($"[yellow]User {user.Login} is not currently sponsoring any accounts."); - return 0; - } - - if (!GitHub.TryQuery( - """ - query { - viewer { - organizations(first: 100) { - nodes { - isVerified - email - websiteUrl - } - } - } - } - """, - """ - [.data.viewer.organizations.nodes.[] | select(.isVerified == true)] - """, out json) || json is null) - return -1; - - var orgs = JsonSerializer.Deserialize(json, JsonOptions.Default) ?? Array.Empty(); - var domains = new HashSet(); - // Collect unique domains from verified org website and email - foreach (var org in orgs) - { - // NOTE: should we automatically also collect subdomains? - if (Uri.TryCreate(org.WebsiteUrl, UriKind.Absolute, out var uri)) - domains.Add(uri.Host); - - if (string.IsNullOrEmpty(org.Email)) - continue; - - var domain = org.Email.Split('@')[1]; - if (string.IsNullOrEmpty(domain)) - continue; - - domains.Add(domain); - } - - if (!GitHub.TryApi("user/emails", "[.[] | select(.verified == true) | .email]", out json) || json is null) - return -1; - - var emails = JsonSerializer.Deserialize(json, JsonOptions.Default) ?? Array.Empty(); - // Create unsigned manifest locally, for back-end validation - var manifest = Manifest.Create(emails, domains.ToArray(), sponsoring); - - // Send token to API to get signed manifest and persist it locally - File.WriteAllText(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".sponsorlink"), manifest.Token); - - return 0; - } -} diff --git a/src/Commands/ListCommand.cs b/src/Commands/ListCommand.cs index e1ea30b..68a7dce 100644 --- a/src/Commands/ListCommand.cs +++ b/src/Commands/ListCommand.cs @@ -5,48 +5,148 @@ namespace Devlooped.SponsorLink; -public class ListCommand : GitHubCommand +public class ListCommand(Account account) : Command { record Sponsorship(string Sponsorable, [property: DisplayName("Tier (USD)")] int Dollars, DateOnly CreatedAt, [property: DisplayName("One-time")] bool OneTime); + record Organization(string Login, string[] Sponsorables); - protected override int OnExecute(Account account, CommandContext context) + public override int Execute(CommandContext context) { - if (!GitHub.TryQuery( - """ - query { - viewer { - sponsorshipsAsSponsor(activeOnly: true, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { - nodes { - createdAt - isOneTimePayment - sponsorable { - ... on Organization { - login - } - ... on User { - login - } - } - tier { - monthlyPriceInDollars - } + var status = AnsiConsole.Status(); + string? json = default; + if (status.Start("Querying user sponsorships", _ => + { + if (!GitHub.TryQuery( + """ + query { + viewer { + sponsorshipsAsSponsor(activeOnly: true, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + createdAt + isOneTimePayment + sponsorable { + ... on Organization { + login + } + ... on User { + login + } + } + tier { + monthlyPriceInDollars + } + } + } } } - } + """, + """ + [.data.viewer.sponsorshipsAsSponsor.nodes.[] | { sponsorable: .sponsorable.login, dollars: .tier.monthlyPriceInDollars, oneTime: .isOneTimePayment, createdAt } ] + """, out json) || string.IsNullOrEmpty(json)) + { + AnsiConsole.MarkupLine("[red]Could not query GitHub for user sponsorships.[/]"); + return -1; } - """, - """ - [.data.viewer.sponsorshipsAsSponsor.nodes.[] | { sponsorable: .sponsorable.login, dollars: .tier.monthlyPriceInDollars, oneTime: .isOneTimePayment, createdAt } ] - """, out var json)) + + return 0; + }) == -1) + { + return -1; + } + + var usersponsored = JsonSerializer.Deserialize(json!, JsonOptions.Default); + + if (status.Start("Querying user organizations", _ => + { + // It's unlikely that any account would belong to more than 100 orgs. + if (!GitHub.TryQuery( + """ + query { + viewer { + organizations(first: 100) { + nodes { + login + } + } + } + } + """, + """ + [.data.viewer.organizations.nodes.[].login] + """, out json) || string.IsNullOrEmpty(json)) + { + AnsiConsole.MarkupLine("[red]Could not query GitHub for user organizations.[/]"); + return -1; + } + + return 0; + }) == -1) + { return -1; + } + + var orgs = JsonSerializer.Deserialize(json!, JsonOptions.Default) ?? Array.Empty(); + var orgsponsored = new List(); + + status.Start("Querying organization sponsorships", ctx => + { + // Collect org-sponsored accounts. NOTE: these must be public sponsorships + // since the current user would typically NOT be an admin of these orgs. + foreach (var org in orgs) + { + ctx.Status($"Querying {org} sponsorships"); + // TODO: we'll need to account for pagination after 100 sponsorships is commonplace :) + if (GitHub.TryQuery( + $$""" + query($login: String!) { + organization(login: $login) { + sponsorshipsAsSponsor(activeOnly: true, first: 100) { + nodes { + sponsorable { + ... on Organization { + login + } + ... on User { + login + } + } + } + } + } + } + """, + """ + [.data.organization.sponsorshipsAsSponsor.nodes.[].sponsorable.login] + """, out json, ("login", org)) && + !string.IsNullOrEmpty(json) && + JsonSerializer.Deserialize(json, JsonOptions.Default) is { } sponsored && + sponsored.Length > 0) + { + orgsponsored.Add(new Organization(org, sponsored)); + } + } + }); + + if (usersponsored != null) + { + AnsiConsole.Write(new Paragraph($"Sponsored by {account.Login}", new Style(Color.Green))); + AnsiConsole.WriteLine(); + AnsiConsole.Write(usersponsored.AsTable()); + } - if (string.IsNullOrEmpty(json)) - return 0; + if (orgsponsored.Count > 0) + { + var tree = new Tree(new Text("Sponsored by Organizations", new Style(Color.Yellow))); - var table = JsonSerializer.Deserialize(json, JsonOptions.Default)! - .AsTable(); + foreach (var org in orgsponsored) + { + var node = new TreeNode(new Text(org.Login, new Style(Color.Green))); + node.AddNodes(org.Sponsorables); + tree.AddNode(node); + } - AnsiConsole.Write(table); + AnsiConsole.Write(tree); + } return 0; } diff --git a/src/Commands/Manifest.cs b/src/Commands/Manifest.cs index 9d72d39..74604be 100644 --- a/src/Commands/Manifest.cs +++ b/src/Commands/Manifest.cs @@ -1,4 +1,5 @@ -using System.IdentityModel.Tokens.Jwt; +using System.Diagnostics.CodeAnalysis; +using System.IdentityModel.Tokens.Jwt; using System.Numerics; using System.Security.Claims; using System.Security.Cryptography; @@ -8,12 +9,36 @@ namespace Devlooped.SponsorLink; /// -/// Represents a manifest of sponsorship links. +/// Represents a manifest of sponsorship claims. /// public class Manifest { + readonly string salt; readonly HashSet linked; + /// + /// Status of a manifest. + /// + public enum Status + { + /// + /// The manifest is expired and needs re-issuing via gh sponsors sync. + /// + Expired, + /// + /// The manifest is invalid and needs re-issuing via gh sponsors sync. + /// + Invalid, + /// + /// The manifest was not found at all. Requires running gh sponsors sync. + /// + NotFound, + /// + /// The manifest was found and is valid. + /// + Verified, + } + static Manifest() { PublicKey = RSA.Create(); @@ -26,11 +51,16 @@ static Manifest() PublicKey.ImportRSAPublicKey(mem.ToArray(), out _); } - Manifest(string jwt, ClaimsPrincipal principal) - : this(jwt, new HashSet(principal.FindAll("sl").Select(x => x.Value))) { } + Manifest(string jwt, string salt, ClaimsPrincipal principal) + : this(jwt, salt, new HashSet(principal.FindAll("hash").Select(x => x.Value))) { } - Manifest(string jwt, HashSet linked) - => (Token, this.linked) = (jwt, linked); + Manifest(string jwt, string salt, HashSet linked) + => (Token, this.salt, this.linked) = (jwt, salt, linked); + + /// + /// The public key used to validate manifests signed with the default private key. + /// + public static RSA PublicKey { get; } /// /// Checks whether the given email is sponsoring the given sponsorable account. @@ -38,10 +68,16 @@ static Manifest() public bool IsSponsoring(string email, string sponsorable) => linked.Contains( Base62.Encode(BigInteger.Abs(new BigInteger( - SHA256.HashData(Encoding.UTF8.GetBytes(email + sponsorable)))))) || - linked.Contains( + SHA256.HashData(Encoding.UTF8.GetBytes(salt + email + sponsorable)))))) || + (email.IndexOf('@') is int index && index > 0 && + linked.Contains( Base62.Encode(BigInteger.Abs(new BigInteger( - SHA256.HashData(Encoding.UTF8.GetBytes(email[(email.IndexOf('@') + 1)..] + sponsorable)))))); + SHA256.HashData(Encoding.UTF8.GetBytes(salt + email[(index + 1)..] + sponsorable))))))); + + /// + /// Hashes contained in the manifest. + /// + public IEnumerable Hashes => linked; /// /// The JWT token representing the manifest. @@ -49,19 +85,50 @@ public bool IsSponsoring(string email, string sponsorable) public string Token { get; } /// - /// The public key used to validate manifests signed with the default private key. + /// Tries to read the default manifest. /// - public static RSA PublicKey { get; } + /// The read manifest if present and valid. + /// The manifest status. + public static Status TryRead([NotNullWhen(true)] out Manifest? manifest) + { + manifest = default; + + var jwt = Environment.GetEnvironmentVariable("SPONSORLINK_MANIFEST", EnvironmentVariableTarget.User); + var salt = Environment.GetEnvironmentVariable("SPONSORLINK_INSTALLATION", EnvironmentVariableTarget.User); + // We need both values in order to use the manifest at all. + // These are generated by the gh sponsors sync command. + if (string.IsNullOrEmpty(jwt) || string.IsNullOrEmpty(salt)) + return Status.NotFound; + + try + { + manifest = Read(jwt, salt); + return Status.Verified; + } + catch (SecurityTokenExpiredException) + { + return Status.Expired; + } + catch (SecurityTokenException) + { + return Status.Invalid; + } + } /// /// Reads a manifest and validates it using the embedded public key. /// - public static Manifest Read(string token) => Read(token, PublicKey); + public static Manifest Read(string token) => Read(token, Session.InstallationId, PublicKey); + + /// + /// Reads a manifest and validates it using the embedded public key. + /// + public static Manifest Read(string token, string salt) => Read(token, salt, PublicKey); /// /// Reads a manifest and validates it using the given public key. /// - public static Manifest Read(string token, RSA rsa) + internal static Manifest Read(string token, string salt, RSA rsa) { var validation = new TokenValidationParameters { @@ -73,21 +140,18 @@ public static Manifest Read(string token, RSA rsa) var principal = new JwtSecurityTokenHandler().ValidateToken(token, validation, out var _); - // For now, it's a single entry, with "is active sponsor" claim only. sl = sponsor-linked - return new Manifest(token, principal); - - //try - //{ - //} - //catch (Exception ex) when (ex is SecurityTokenExpiredException || ex is SecurityTokenInvalidSignatureException) - //{ - //} + return new Manifest(token, salt, principal); } /// /// Creates an unsigned manifest, to be used to request a signed one. /// - public static Manifest Create(string[] emails, string[] domains, string[] sponsoring) + /// A random string used to salt the values to be hashed. + /// The identifier of the manifest owner. + /// Email(s) of the manifest owner. + /// Verified organization domains the user belongs to. + /// The accounts the manifest owner is sponsoring. + public static Manifest Create(string salt, string user, string[] emails, string[] domains, string[] sponsoring) { var linked = new HashSet(); @@ -95,7 +159,7 @@ public static Manifest Create(string[] emails, string[] domains, string[] sponso { foreach (var email in emails) { - var data = SHA256.HashData(Encoding.UTF8.GetBytes(email + sponsorable)); + var data = SHA256.HashData(Encoding.UTF8.GetBytes(salt + email + sponsorable)); var hash = Base62.Encode(BigInteger.Abs(new BigInteger(data))); linked.Add(hash); @@ -103,7 +167,7 @@ public static Manifest Create(string[] emails, string[] domains, string[] sponso foreach (var domain in domains) { - var data = SHA256.HashData(Encoding.UTF8.GetBytes(domain + sponsorable)); + var data = SHA256.HashData(Encoding.UTF8.GetBytes(salt + domain + sponsorable)); var hash = Base62.Encode(BigInteger.Abs(new BigInteger(data))); linked.Add(hash); @@ -113,55 +177,81 @@ public static Manifest Create(string[] emails, string[] domains, string[] sponso var token = new JwtSecurityToken( issuer: "Devlooped", audience: "SponsorLink", - claims: linked.Select(x => new Claim("sl", x)), + claims: new[] { new Claim("sub", user) }.Concat(linked.Select(x => new Claim("hash", x))), // Expire at the end of the month expires: new DateTime(DateTime.Today.Year, DateTime.Today.Month + 1, 1, 0, 0, 0, DateTimeKind.Utc)); // Serialize the token and return as a string var jwt = new JwtSecurityTokenHandler().WriteToken(token); - return new Manifest(jwt, linked); + return new Manifest(jwt, salt, linked); } /// - /// Creates a signed manifest, to be used to verify sponsorships. + /// Signs the manifes with the given key and returns the new JWT. /// - public static Manifest Create(string[] emails, string[] domains, string[] sponsoring, RSA rsa) + /// The RSA key to use for signing. + public string Sign(RSA key) { - var key = new RsaSecurityKey(rsa.ExportParameters(true)); - var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); - var linked = new HashSet(); + var token = new JwtSecurityTokenHandler().ReadJwtToken(Token); + var signing = new SigningCredentials(new RsaSecurityKey(key), SecurityAlgorithms.RsaSha256); - foreach (var sponsorable in sponsoring) - { - foreach (var email in emails) - { - var data = SHA256.HashData(Encoding.UTF8.GetBytes(email + sponsorable)); - var hash = Base62.Encode(BigInteger.Abs(new BigInteger(data))); + var jwt = new JwtSecurityToken( + issuer: token.Issuer, + audience: token.Audiences.First(), + claims: token.Claims, + expires: token.ValidTo, + signingCredentials: signing); - linked.Add(hash); - } + return new JwtSecurityTokenHandler().WriteToken(jwt); + } - foreach (var domain in domains) - { - var data = SHA256.HashData(Encoding.UTF8.GetBytes(domain + sponsorable)); - var hash = Base62.Encode(BigInteger.Abs(new BigInteger(data))); + /// + /// Inspired in a bunch of searches, samples and snippets on various languages + /// and blogs and what-not on doing URL shortering :), heavily tweaked to make + /// it fully idiomatic in C#. + /// + static partial class Base62 + { + /// + /// Encodes a numeric value into a base62 string. + /// + public static string Encode(BigInteger value) + { + // TODO: I'm almost sure there's a more succint way + // of doing this with LINQ and Aggregate, but just + // can't figure it out... + var sb = new StringBuilder(); - linked.Add(hash); + while (value != 0) + { + sb = sb.Append(ToBase62(value % 62)); + value /= 62; } + + return new string(sb.ToString().Reverse().ToArray()); } - var token = new JwtSecurityToken( - issuer: "Devlooped", - audience: "SponsorLink", - claims: linked.Select(x => new Claim("sl", x)), - // Expire at the end of the month - expires: new DateTime(DateTime.Today.Year, DateTime.Today.Month + 1, 1, 0, 0, 0, DateTimeKind.Utc), - signingCredentials: credentials); + /// + /// Decodes a base62 string into its original numeric value. + /// + public static BigInteger Decode(string value) + => value.Aggregate(new BigInteger(0), (current, c) => current * 62 + FromBase62(c)); - // Serialize the token and return as a string - var jwt = new JwtSecurityTokenHandler().WriteToken(token); + static char ToBase62(BigInteger d) => d switch + { + BigInteger v when v < 10 => (char)('0' + d), + BigInteger v when v < 36 => (char)('A' + d - 10), + BigInteger v when v < 62 => (char)('a' + d - 36), + _ => throw new ArgumentException($"Cannot encode digit {d} to base 62.", nameof(d)), + }; - return new Manifest(jwt, linked); + static BigInteger FromBase62(char c) => c switch + { + char v when c >= 'a' && v <= 'z' => 36 + c - 'a', + char v when c >= 'A' && v <= 'Z' => 10 + c - 'A', + char v when c >= '0' && v <= '9' => c - '0', + _ => throw new ArgumentException($"Cannot decode char '{c}' from base 62.", nameof(c)), + }; } } diff --git a/src/Commands/Session.cs b/src/Commands/Session.cs new file mode 100644 index 0000000..809b6f2 --- /dev/null +++ b/src/Commands/Session.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Auth0.AuthenticationApi; +using Auth0.AuthenticationApi.Models; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace Devlooped.SponsorLink; + +/// +/// Manages session token, authentication and installation ininitialization. +/// +public static class Session +{ + const string InstallationVariable = "SPONSORLINK_INSTALLATION"; + const string AccessTokenVariable = "SPONSORLINK_TOKEN"; + const string Issuer = "https://sponsorlink.us.auth0.com/"; + const string Audience = "https://sponsorlink.devlooped.com"; + + //static readonly string AccessTokenFile = Path.Combine( + // Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + // ".sponsorlink.jwt"); + + static readonly IConfigurationManager configuration; + + static Session() + { + configuration = new ConfigurationManager( + $"{Issuer}.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = true }); + + if (Environment.GetEnvironmentVariable(InstallationVariable, EnvironmentVariableTarget.User) is not string installation) + { + installation = Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(InstallationVariable, installation, EnvironmentVariableTarget.User); + } + + InstallationId = installation; + } + + /// + /// Gets the current SponsorLink-authenticated access token. + /// + public static string? AccessToken => Environment.GetEnvironmentVariable(AccessTokenVariable, EnvironmentVariableTarget.User); + + /// + /// Gets a unique identifier for this installation, which can be used for salting + /// hashes to preserve privacy. + /// + public static string InstallationId { get; private set; } + + /// + /// Authenticates with SponsorLink and returns the user claims. + /// + public static async Task AuthenticateAsync() + { + // We cache the token in an environment variable to avoid having to re-authenticate + // unless the token is expired or invalid. + if (Environment.GetEnvironmentVariable(AccessTokenVariable, EnvironmentVariableTarget.User) is string token && + await ValidateTokenAsync(token) is ClaimsPrincipal principal) + { + return principal; + } + + var client = new AuthenticationApiClient(new Uri(Issuer)); + var verifier = Guid.NewGuid().ToString("N"); + var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(verifier))); + + var uri = client.BuildAuthorizationUrl() + .WithAudience("https://sponsorlink.devlooped.com") + .WithClient("ZUMhc9T4TdJtTsjGaKwEnVZALgpw0fF9") + .WithScopes("openid", "profile", "email") + .WithResponseType(AuthorizationResponseType.Code) + .WithNonce(challenge) + .WithRedirectUrl("http://localhost:4242") + .Build(); + + var listener = new HttpListener(); + listener.Prefixes.Add("http://localhost:4242/"); + listener.Start(); + + var getCode = Task.Run(() => + { + var context = listener.GetContext(); + var code = context.Request.QueryString["code"]; + + context.Response.StatusCode = 200; + context.Response.Redirect("https://devlooped.com?cli"); + context.Response.Close(); + + return code; + }); + + System.Diagnostics.Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true }); + + var code = await getCode; + listener.Stop(); + + // Exchange the code for a token + var response = await client.GetTokenAsync(new AuthorizationCodePkceTokenRequest + { + ClientId = "ZUMhc9T4TdJtTsjGaKwEnVZALgpw0fF9", + Code = code, + CodeVerifier = verifier, + RedirectUri = "http://localhost:4242", + }); + + token = response.AccessToken; + + if (await ValidateTokenAsync(token) is ClaimsPrincipal validated) + { + Environment.SetEnvironmentVariable(AccessTokenVariable, token, EnvironmentVariableTarget.User); + return validated; + } + + return default; + } + + static async Task ValidateTokenAsync(string token) + { + var config = await configuration.GetConfigurationAsync(CancellationToken.None); + + var validationParameter = new TokenValidationParameters + { + RequireSignedTokens = true, + ValidAudience = Audience, + ValidateAudience = true, + ValidIssuer = Issuer, + ValidateIssuer = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + IssuerSigningKeys = config.SigningKeys + }; + + ClaimsPrincipal? result = default; + var tries = 0; + + while (result == null && tries <= 1) + { + try + { + var handler = new JwtSecurityTokenHandler(); + return handler.ValidateToken(token, validationParameter, out var _); + } + catch (SecurityTokenSignatureKeyNotFoundException) + { + // This exception is thrown if the signature key of the JWT could not be found. + // This could be the case when the issuer changed its signing keys, so we trigger a + // refresh and retry validation. + configuration.RequestRefresh(); + tries++; + } + catch (SecurityTokenException) + { + return null; + } + } + + return result; + } +} diff --git a/src/Commands/ShowCommand.cs b/src/Commands/ShowCommand.cs index 46b534b..01e5efe 100644 --- a/src/Commands/ShowCommand.cs +++ b/src/Commands/ShowCommand.cs @@ -5,9 +5,9 @@ namespace Devlooped.SponsorLink; -public class ShowCommand : GitHubCommand +public class ShowCommand(Account account) : Command { - protected override int OnExecute(Account account, CommandContext context) + public override int Execute(CommandContext context) { var json = JsonSerializer.Serialize(account, JsonOptions.Default); AnsiConsole.Write( diff --git a/src/Commands/SyncCommand.cs b/src/Commands/SyncCommand.cs new file mode 100644 index 0000000..488080b --- /dev/null +++ b/src/Commands/SyncCommand.cs @@ -0,0 +1,189 @@ +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Xml.Linq; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Devlooped.SponsorLink; + +public partial class SyncCommand(Account user) : AsyncCommand +{ + record Organization(string Login, string Email, string WebsiteUrl); + + public override async Task ExecuteAsync(CommandContext context) + { + // Authenticated user must match GH user + var principal = await Session.AuthenticateAsync(); + if (!int.TryParse(principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value.Split('|')?[1], out var id)) + { + AnsiConsole.MarkupLine("[red]Could not determine SponsorLink user id."); + return -1; + } + + if (user.Id != id) + { + AnsiConsole.MarkupLine($"[red]SponsorLink authenticated user id ({id}) does not match GitHub CLI user id ({user.Id})."); + return -1; + } + + // TODO: we'll need to account for pagination after 100 sponsorships is commonplace :) + if (!GitHub.TryQuery( + """ + query { + viewer { + sponsorshipsAsSponsor(activeOnly: true, first: 100, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + sponsorable { + ... on Organization { + login + } + ... on User { + login + } + } + } + } + } + } + """, + """ + [.data.viewer.sponsorshipsAsSponsor.nodes.[].sponsorable.login] + """, out var json) || string.IsNullOrEmpty(json)) + { + AnsiConsole.MarkupLine("[red]Could not query GitHub for user sponsorships."); + return -1; + } + + var usersponsored = JsonSerializer.Deserialize>(json, JsonOptions.Default) ?? new HashSet(); + + // It's unlikely that any account would belong to more than 100 orgs. + if (!GitHub.TryQuery( + """ + query { + viewer { + organizations(first: 100) { + nodes { + login + isVerified + email + websiteUrl + } + } + } + } + """, + """ + [.data.viewer.organizations.nodes.[] | select(.isVerified == true)] + """, out json) || string.IsNullOrEmpty(json)) + { + AnsiConsole.MarkupLine("[red]Could not query GitHub for user organizations."); + return -1; + } + + var orgs = JsonSerializer.Deserialize(json, JsonOptions.Default) ?? Array.Empty(); + var domains = new HashSet(); + // Collect unique domains from verified org website and email + foreach (var org in orgs) + { + // NOTE: should we automatically also collect subdomains? + if (Uri.TryCreate(org.WebsiteUrl, UriKind.Absolute, out var uri)) + domains.Add(uri.Host); + + if (string.IsNullOrEmpty(org.Email)) + continue; + + var domain = org.Email.Split('@')[1]; + if (string.IsNullOrEmpty(domain)) + continue; + + domains.Add(domain); + } + + var orgsponsored = new HashSet(); + + // Collect org-sponsored accounts. NOTE: these must be public sponsorships + // since the current user would typically NOT be an admin of these orgs. + foreach (var org in orgs) + { + // TODO: we'll need to account for pagination after 100 sponsorships is commonplace :) + if (GitHub.TryQuery( + $$""" + query($login: String!) { + organization(login: $login) { + sponsorshipsAsSponsor(activeOnly: true, first: 100) { + nodes { + sponsorable { + ... on Organization { + login + } + ... on User { + login + } + } + } + } + } + } + """, + """ + [.data.organization.sponsorshipsAsSponsor.nodes.[].sponsorable.login] + """, out json, ("login", org.Login)) && + !string.IsNullOrEmpty(json) && + JsonSerializer.Deserialize(json, JsonOptions.Default) is { } sponsored) + { + foreach (var login in sponsored) + { + orgsponsored.Add(login); + } + } + } + + // If we end up with no sponsorships whatesover, no-op and exit. + if (usersponsored.Count == 0 && orgsponsored.Count == 0) + { + AnsiConsole.MarkupLine($"[yellow]User {user.Login} (or any of the organizations they long to) is not currently sponsoring any accounts."); + return 0; + } + + AnsiConsole.MarkupLine($"[grey]Found {usersponsored.Count} personal sponsorships and {orgsponsored.Count} organization sponsorships.[/]"); + + // Create unsigned manifest locally, for back-end validation + var manifest = Manifest.Create(Session.InstallationId, user.Id.ToString(), user.Emails, domains.ToArray(), + new HashSet(usersponsored.Concat(orgsponsored)).ToArray()); + + using var http = new HttpClient(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Session.AccessToken); + + // NOTE: to test the local flow end to end, run the SponsorLink functions App project locally. You will + var url = Debugger.IsAttached ? "http://localhost:7288/sign" : "https://sponsorlink.devlooped.com/sign"; + var response = await http.PostAsync(url, new StringContent(manifest.Token)); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + AnsiConsole.MarkupLine("[red]Could not sign manifest: unauthorized.[/]"); + return -1; + } + else if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Request installing GH SponsorLink App (records acceptance of email sharing). + AnsiConsole.MarkupLine("[red]Could not sign manifest: not found.[/]"); + return -1; + } + else if (!response.IsSuccessStatusCode) + { + AnsiConsole.MarkupLine($"[red]Could not sign manifest: {response.StatusCode} ({await response.Content.ReadAsStringAsync()})."); + return -1; + } + + var signed = await response.Content.ReadAsStringAsync(); + + // Make sure we can read it back + Debug.Assert(manifest.Hashes.SequenceEqual(Manifest.Read(signed, Session.InstallationId).Hashes)); + + Environment.SetEnvironmentVariable("SPONSORLINK_MANIFEST", signed, EnvironmentVariableTarget.User); + + return 0; + } +} diff --git a/src/Commands/ValidateCommand.cs b/src/Commands/ValidateCommand.cs index 83c17e8..f434bfc 100644 --- a/src/Commands/ValidateCommand.cs +++ b/src/Commands/ValidateCommand.cs @@ -4,34 +4,34 @@ namespace Devlooped.SponsorLink; -public partial class ValidateCommand : GitHubCommand +public partial class ValidateCommand : Command { - protected override int OnExecute(Account user, CommandContext context) + public override int Execute(CommandContext context) { - var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".sponsorlink"); - if (!File.Exists(path)) + var token = Environment.GetEnvironmentVariable("SPONSORLINK_MANIFEST", EnvironmentVariableTarget.User); + if (string.IsNullOrEmpty(token)) { - AnsiConsole.MarkupLine("[red]No SponsorLink file found in your home directory.[/] Run [white]gh sponsors link[/] to initialize it."); + AnsiConsole.MarkupLine("[red]No SponsorLink manifest found.[/] Run [white]gh sponsors sync[/] to initialize it."); return -1; } try { - Manifest.Read(File.ReadAllText(path)); + Manifest.Read(token); } catch (SecurityTokenExpiredException) { - AnsiConsole.MarkupLine("[red]The manifest has expired.[/] Run [white]gh sponsors link[/] to generate a new one."); + AnsiConsole.MarkupLine("[red]The manifest has expired.[/] Run [white]gh sponsors sync[/] to generate a new one."); return -2; } catch (SecurityTokenInvalidSignatureException) { - AnsiConsole.MarkupLine("[red]The manifest signature is invalid.[/] Run [white]gh sponsors link[/] to generate a new one."); + AnsiConsole.MarkupLine("[red]The manifest signature is invalid.[/] Run [white]gh sponsors sync[/] to generate a new one."); return -3; } - catch (Exception ex) + catch (SecurityTokenException ex) { - AnsiConsole.MarkupLine($"[red]The manifest is invalid.[/] Run [white]gh sponsors link[/] to generate a new one."); + AnsiConsole.MarkupLine($"[red]The manifest is invalid.[/] Run [white]gh sponsors sync[/] to generate a new one."); AnsiConsole.WriteException(ex); return -4; } diff --git a/src/Extension/Extension.csproj b/src/Extension/Extension.csproj index 73f7907..77d7ebb 100644 --- a/src/Extension/Extension.csproj +++ b/src/Extension/Extension.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/Extension/Program.cs b/src/Extension/Program.cs index a1a10ac..a9d22af 100644 --- a/src/Extension/Program.cs +++ b/src/Extension/Program.cs @@ -1,14 +1,32 @@ using Devlooped.SponsorLink; +using Microsoft.Extensions.DependencyInjection; using Spectre.Console; using Spectre.Console.Cli; -var app = new CommandApp(); +if (!GitHub.IsInstalled) +{ + AnsiConsole.MarkupLine("[yellow]Please install GitHub CLI from [/][link]https://cli.github.com/[/]"); + return -1; +} + +if (GitHub.Authenticate() is not { } account) +{ + AnsiConsole.MarkupLine("Please run [yellow]gh auth login[/] to authenticate, [yellow]gh auth status -h github.com[/] to verify your status."); + return -1; +} + +// Provide the authenticated GH CLI user account via DI +var registrations = new ServiceCollection(); +registrations.AddSingleton(account); +var registrar = new TypeRegistrar(registrations); + +var app = new CommandApp(registrar); app.Configure(config => { - config.AddCommand(); - config.AddCommand(); - config.AddCommand(); - config.AddCommand(); + //config.AddCommand(); + config.AddCommand().WithDescription("Lists user and organization sponsorships"); + config.AddCommand().WithDescription("Synchronizes the sponsorships manifest"); + config.AddCommand().WithDescription("Validates the active sponsorships manifest, if any"); #if DEBUG //config.PropagateExceptions(); @@ -16,6 +34,7 @@ #endif }); +#if DEBUG if (args.Length == 0) { var command = AnsiConsole.Prompt( @@ -23,10 +42,13 @@ .Title("Command to run:") .AddChoices(new[] { - "show", "list", "link", "validate" + "list", + "sync", + "validate" })); args = new[] { command }; } +#endif return app.Run(args); diff --git a/src/Extension/TypeRegistrar.cs b/src/Extension/TypeRegistrar.cs new file mode 100644 index 0000000..a5b4807 --- /dev/null +++ b/src/Extension/TypeRegistrar.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Devlooped.SponsorLink; + +public sealed class TypeRegistrar(IServiceCollection builder) : ITypeRegistrar +{ + readonly IServiceCollection builder = builder; + + public ITypeResolver Build() => new TypeResolver(builder.BuildServiceProvider()); + + public void Register(Type service, Type implementation) => builder.AddSingleton(service, implementation); + + public void RegisterInstance(Type service, object implementation) => builder.AddSingleton(service, implementation); + + public void RegisterLazy(Type service, Func func) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + builder.AddSingleton(service, (provider) => func()); + } + + sealed class TypeResolver(IServiceProvider provider) : ITypeResolver, IDisposable + { + readonly IServiceProvider provider = provider ?? throw new ArgumentNullException(nameof(provider)); + + public object? Resolve(Type? type) => type == null ? null : provider.GetService(type); + + public void Dispose() => (provider as IDisposable)?.Dispose(); + } +} \ No newline at end of file diff --git a/src/Tests/ManifestTests.cs b/src/Tests/ManifestTests.cs index 96e30f6..45012c5 100644 --- a/src/Tests/ManifestTests.cs +++ b/src/Tests/ManifestTests.cs @@ -19,14 +19,20 @@ public void EndToEnd() var pub = RSA.Create(); pub.ImportRSAPublicKey(File.ReadAllBytes(@"../../../test.pub"), out _); + var salt = Guid.NewGuid().ToString("N"); - var manifest = Manifest.Create( + var manifest = Manifest.Create(salt, "1234", + // user email(s) new[] { "foo@bar.com" }, + // org domains new[] { "bar.com", "baz.com" }, - new[] { "devlooped" }, - key); + // sponsorables + new[] { "devlooped" }); - var validated = Manifest.Read(manifest.Token, pub); + // Turn it into a signed manifest + var signed = manifest.Sign(key); + + var validated = Manifest.Read(signed, salt, pub); // Direct sponsoring Assert.True(validated.IsSponsoring("foo@bar.com", "devlooped")); @@ -38,19 +44,4 @@ public void EndToEnd() // Wrong email domain Assert.False(validated.IsSponsoring("foo@contoso.com", "devlooped")); } - - [Fact] - public void WrongPublicKey() - { - var key = RSA.Create(); - key.ImportRSAPrivateKey(File.ReadAllBytes(@"../../../test.key"), out _); - - var manifest = Manifest.Create( - new[] { "foo@bar.com" }, - new[] { "bar.com", "baz.com" }, - new[] { "devlooped" }, - key); - - Assert.ThrowsAny(() => Manifest.Read(manifest.Token)); - } } diff --git a/src/Tests/Signing.cs b/src/Tests/Signing.cs index 8e07af6..2a01cb3 100644 --- a/src/Tests/Signing.cs +++ b/src/Tests/Signing.cs @@ -11,9 +11,12 @@ namespace Devlooped.SponsorLink; -public class Signing +public class Signing(ITestOutputHelper Output) { - [Fact] + // NOTE: if you want to locally regenerate the keys, uncomment the following line + // NOTE: if you want to run locally the SL Functions App, you need to set the public + // key as Base64 encoded string in the SPONSORLINK_KEY environment variable + //[Fact] public void CreateKeyPair() { // Generate key pair @@ -23,6 +26,9 @@ public void CreateKeyPair() File.WriteAllBytes(@"../../../test.key", rsa.ExportRSAPrivateKey()); } + [Fact] + public void WritePublicKey() => Output.WriteLine(Convert.ToBase64String(Manifest.PublicKey.ExportRSAPublicKey())); + [Fact] public void SignFile() {