From 2102d06aee100a59f6b2c85f65d1306e661d6652 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Wed, 11 Dec 2024 14:53:48 -0500 Subject: [PATCH 1/3] Add ApiKeyV5 based on HISv2 --- Directory.Packages.props | 1 + .../Authentication/ApiKeyV5.cs | 481 ++++++++++++++++++ .../NuGetGallery.Services.csproj | 1 + .../Authentication/ApiKeyV5Facts.cs | 260 ++++++++++ 4 files changed, 743 insertions(+) create mode 100644 src/NuGetGallery.Services/Authentication/ApiKeyV5.cs create mode 100644 tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index c1b272acb9..f76ff584ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -89,6 +89,7 @@ + diff --git a/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs b/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs new file mode 100644 index 0000000000..0f32e3fa2d --- /dev/null +++ b/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs @@ -0,0 +1,481 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Cryptography; +using System.Text; +using Base62; +using Microsoft.Security.Utilities; + +#nullable enable + +namespace NuGetGallery.Infrastructure.Authentication +{ + /// + /// A v5 API key for NuGetGallery. This API key format uses the "Highly Identifiable Secret" (HISv2) format provided + /// Microsoft.Security.Utilities.Core package. + /// + /// Here is an example value: + /// + /// aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaJQQJ99ALN5Z0000LZENeS003NUGT3OrW + /// + /// It is broken down as follows: + /// + /// Example data | Range | Description + /// ----------------------------------------------------- | ----- | ---------------------------------------------------- + /// aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0-51 | Random bytes needed for a strong secret. 312 bits of entropy. + /// JQQJ99 | 52-57 | HISv2 standard fixed signature + /// AL (A = 2024, L = December) | 58-59 | Allocation year and month as base62 digits, zero-indexed + /// N | 60 | NuGet platform specifier + /// 5 | 61 | Version 5 + /// Z | 62 | Environment code, any base62 character + /// 0000LZ (L = 21, Z = 35, 21 * 62^1 + 35 * 62^0 = 1337) | 63-68 | User ID integer as base62 + /// ENe (5th day, N = 13th hour, e = 30th minute) | 69-71 | Allocation day, hour, and minute, zero-indexed + /// S | 72 | API key type (S = short-lived, L = long-lived) + /// 003 (3 * 5 = 15 minutes) | 73-75 | Expiration of the key, in increments of 5 minutes, encoded as base62 + /// NUGT | 76-79 | NuGet provider signature + /// 30rW | 80-83 | Checksum + /// + /// A plaintext value can be parsed into the above metadata components. The entire API key value should be + /// considered as secret, and sensitive. + /// + /// The checksum can be used to do a fast pass of validation without a DB lookup. An invalid checksum means the + /// API key is invalid. A valid checksum does NOT mean the API key is valid and a database lookup is needed. + /// + /// Additionally, the plus timespan can be used to determine + /// if the API key is expired (barring clock skew). + /// + /// API keys can be rejected based on the code, if it is not the expected value (e.g. + /// a pre-production environment rejecting production API keys. + /// + /// The value stored in the database is base64 encoded SHA-512 hash of the plaintext API key. The corresponding + /// database value for the example plaintext API key above is: + /// + /// BWqhR33SkX0/BxG34nEZtByLp5uRz/H3lD89EDnFylq+peJ1EtGolGiUqOa44+5t0vlHd1joByP3rojeTF5scQ== + /// + /// The user ID is included in the API key so that a rate-limiting (i.e. throttling) layer can use the value as a + /// rate limit key. A stable user ID value can be extracted with a simple substring starting at index 63 (0-based) + /// and taking 6 characters. + /// + /// The user ID is the package owner scope of the API key, not the user that created the API key. This allows a user + /// to be rate-limited seperately from any organizations they are part of. In other words, the user ID is the + /// for the + /// entity, not the value. + /// + /// Key stretching used in previous API key versions (such as used in ) + /// is not needed since the API key contains a sufficient amount of random data. The plaintext value is not + /// persisted anywhere in NuGetGallery. + /// + /// An incoming plaintext API key can be parsed and hashed before querying the DB via + /// . Internally this method validates the format of the API key and + /// the checksum. A simple point read of the database for a matching value is all that is + /// needed for finding the matching API key record. + /// + public class ApiKeyV5 + { + private const char PlatformPrefix = 'N'; // this is to differentiate with other platforms, such as 'A' for Azure + private const char ApiKeyVersion = '5'; + internal const string ProviderSignature = "NUGT"; + + public static class KnownEnvironments + { + public const char Production = 'P'; + public const char Integration = 'I'; + public const char Development = 'D'; + public const char Local = 'L'; + } + + public static class KnownApiKeyTypes + { + public const char LongLived = 'L'; + public const char ShortLived = 'S'; + } + + private ApiKeyV5( + DateTime allocationTime, + int userKey, + char environment, + char type, + TimeSpan expiration, + string hashedApiKey, + string plaintextApiKey) + { + AllocationTime = allocationTime; + UserKey = userKey; + Environment = environment; + Type = type; + Expiration = expiration; + HashedApiKey = hashedApiKey; + PlaintextApiKey = plaintextApiKey; + } + + public DateTime AllocationTime { get; } + public int UserKey { get; } + public char Environment { get; } + public char Type { get; } + public TimeSpan Expiration { get; } + public string HashedApiKey { get; } + public string PlaintextApiKey { get; } + + /// + /// Creates a new v5 API key. The plaintext API key value will be available in the property. + /// + /// + /// The allocation (creation) time of the API key. This must be in UTC and the year must be 2024 or later. + /// The expiration time of the API key will be this value plus the parameter. + /// The allocation time must have a second and millisecond value of zero to allow a complete round trip from the encoded value. + /// + /// + /// The user key related to this API key. + /// This is the user or organization that will have their rate limit impacted by the usage of this API key. + /// + /// The NuGetGallery environment that generated this API key. + /// The type of this API key (e.g. short-lived vs. long-lived). + /// The expiration time. This must be 366 days or shorter must be a round minute value divisible by 5. + /// The created API key. + public static ApiKeyV5 Create(DateTime allocationTime, char environment, int userKey, char type, TimeSpan expiration) + { + return Create(allocationTime, environment, userKey, type, expiration, testChar: null); + } + + internal static ApiKeyV5 CreateTestKey(DateTime allocationTime, char environment, int userKey, char type, TimeSpan expiration, char testChar) + { + return Create(allocationTime, environment, userKey, type, expiration, testChar); + } + + private static ApiKeyV5 Create(DateTime allocationTime, char environment, int userKey, char type, TimeSpan expiration, char? testChar) + { + if (userKey <= 0) + { + throw new ArgumentOutOfRangeException(nameof(userKey), $"The {nameof(userKey)} must be greater than zero."); + } + + if (expiration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(expiration), $"The {nameof(expiration)} must be greater than zero."); + } + + if (allocationTime.Second != 0) + { + throw new ArgumentOutOfRangeException(nameof(allocationTime), $"The {nameof(allocationTime)} must have a second value of zero."); + } + + if (allocationTime.Millisecond != 0) + { + throw new ArgumentOutOfRangeException(nameof(allocationTime), $"The {nameof(allocationTime)} must have a millisecond value of zero."); + } + + if (!TryParseBase62Char(environment, out _)) + { + throw new ArgumentOutOfRangeException(nameof(environment), $"The {nameof(environment)} character must be alphanumeric."); + } + + if (!TryParseBase62Char(type, out _)) + { + throw new ArgumentOutOfRangeException(nameof(type), $"The {nameof(type)} character must be alphanumeric."); + } + + string encodedUserKey = EncodeUserKey(userKey); + string encodedTimeOfMonth = EncodeTimeOfMonth(allocationTime); + string platformAllocation = $"{PlatformPrefix}{ApiKeyVersion}{environment}{encodedUserKey}{encodedTimeOfMonth}"; + + string encodedExpiration = EncodeExpiration(expiration); + string providerAllocation = $"{type}{encodedExpiration}"; + + // TODO: pass in the allocation time when the HISv2 library supports it + // https://github.com/microsoft/security-utilities/pull/111 + string plaintextApiKey = IdentifiableSecrets.GenerateCommonAnnotatedKey( + base64EncodedSignature: ProviderSignature, + customerManagedKey: true, + platformReserved: Convert.FromBase64String(platformAllocation), + providerReserved: Convert.FromBase64String(providerAllocation), + testChar: testChar); + + // The first 52 characters of an HISv2 key contains 312 bits of entropy. The rest of the API key is + // composed of identifiers, checksum, and other supporting metadata. We will hash the entire thing and + // store the hash in the database. We could choose to just hash the first 52 characters, but and store the + // rest in plaintext in the DB for debugging purposes but we don't have a need for that right now. + var hashedApiKey = HashApiKey(plaintextApiKey); + + return new ApiKeyV5(allocationTime, userKey, environment, type, expiration, hashedApiKey, plaintextApiKey); + } + + /// + /// Parses a plaintext API key into an instance. The plaintext API key must be a valid v5 API key + /// otherwise this method will return false. + /// + /// The plaintext API key. + /// The parsed API key with extracted metadata. + /// True if the API key could be parsed, false otherwise. + public static bool TryParse(string plaintextApiKey, out ApiKeyV5? parsed) + { + parsed = null; + + if (plaintextApiKey.Length != IdentifiableSecrets.StandardEncodedCommonAnnotatedKeySize) + { + return false; + } + + try + { + if (!IdentifiableSecrets.TryValidateCommonAnnotatedKey(plaintextApiKey, ProviderSignature)) + { + return false; + } + } + catch (FormatException) + { + return false; + } + + if (plaintextApiKey[60] != PlatformPrefix) + { + return false; + } + + if (plaintextApiKey[61] != ApiKeyVersion) + { + return false; + } + + char environment = plaintextApiKey[62]; + if (!TryParseBase62Char(environment, out _)) + { + return false; + } + + char type = plaintextApiKey[72]; + if (!TryParseBase62Char(type, out _)) + { + return false; + } + + if (!TryParseAllocationTime(plaintextApiKey, out var allocationTime)) + { + return false; + } + + if (!TryParseUserKey(plaintextApiKey, out var userKey)) + { + return false; + } + + if (!TryParseExpiration(plaintextApiKey, out var expiration)) + { + return false; + } + + parsed = new ApiKeyV5( + allocationTime, + userKey, + environment, + type, + expiration, + HashApiKey(plaintextApiKey), + plaintextApiKey); + + return true; + } + + private static bool TryParseAllocationTime(string plaintextApiKey, out DateTime allocationTime) + { + allocationTime = default; + + try + { + allocationTime = new DateTime( + year: 2024 + ParseBase62Char(plaintextApiKey[58]), // zero-indexed per HISv2 spec + month: 1 + ParseBase62Char(plaintextApiKey[59]), // zero-indexed per HISv2 spec + day: 1 + ParseBase62Char(plaintextApiKey[69]), // zero-indexed base on implementation in this class + hour: ParseBase62Char(plaintextApiKey[70]), // zero-indexed is fine for DateTime + minute: ParseBase62Char(plaintextApiKey[71]), // zero-indexed is fine for DateTime + second: 0, + millisecond: 0, + DateTimeKind.Utc); + } + catch (ArgumentException) + { + return false; + } + + return true; + } + + private static bool TryParseUserKey(string plaintextApiKey, out int userKey) + { + userKey = default; + + var userKeyBase62 = plaintextApiKey.Substring(63, 6); + byte[] userKeyBytes; + try + { + userKeyBytes = userKeyBase62.FromBase62(); + } + catch (Exception) + { + return false; + } + + // ensure all but the 4 least significant bytes are 0 (little-endian) + // this is to ensure that the user key is not padded with extra data + for (var i = 0; i < userKeyBytes.Length - 4; i++) + { + if (userKeyBytes[i] != 0) + { + return false; + } + } + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(userKeyBytes); + } + + userKey = BitConverter.ToInt32(userKeyBytes, 0); + return true; + } + + private static bool TryParseExpiration(string plaintextApiKey, out TimeSpan expiration) + { + expiration = default; + + var expirationBase62 = plaintextApiKey.Substring(73, 3); + byte[] expirationBytes; + try + { + expirationBytes = expirationBase62.PadLeft(4, '0').FromBase62(); + } + catch (Exception) + { + return false; + } + + + // ensure all but the 4 least significant bytes are 0 (little-endian) + // this is to ensure that the expiration bytes is not padded with extra data + for (var i = 0; i < expirationBytes.Length - 4; i++) + { + if (expirationBytes[i] != 0) + { + return false; + } + } + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(expirationBytes); + } + + int expirationFiveMinutes = BitConverter.ToInt32(expirationBytes, 0); + + expiration = TimeSpan.FromMinutes(expirationFiveMinutes * 5); + return true; + } + + private static int ParseBase62Char(char input) + { + if (!TryParseBase62Char(input, out var value)) + { + throw new ArgumentException($"The character is not a valid base62 character.", nameof(input)); + } + + return value; + } + + private static bool TryParseBase62Char(char input, out int value) + { + value = default; + + if (input >= 'A' && input <= 'Z') + { + value = input - 'A'; + return true; + } + + if (input >= 'a' && input <= 'z') + { + value = 26 + (input - 'a'); + return true; + } + + if (input >= '0' && input <= '9') + { + value = 52 + (input - '0'); + return true; + } + + return false; + } + + private static string HashApiKey(string plaintextApiKey) + { + using var hasher = SHA512.Create(); + var hash = Convert.ToBase64String(hasher.ComputeHash(Encoding.UTF8.GetBytes(plaintextApiKey))); + return hash; + } + + private static string EncodeUserKey(int userKey) + { + byte[] userKeyBytes = BitConverter.GetBytes(userKey); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(userKeyBytes); + } + + // Pad out the base62 encoded string to 6 characters with a zero (zero bits). + // This allows the string to be interpreted as base64 with no needed padding. + string userKeyBase62Padded = userKeyBytes.ToBase62().PadLeft(6, '0'); + return userKeyBase62Padded; + } + + private static string EncodeTimeOfMonth(DateTime allocationTime) + { + byte zeroIndexedDay = (byte)(allocationTime.Day - 1); // zero-indexed to be consistent with HISv2 year and month + byte hour = (byte)allocationTime.Hour; + byte minute = (byte)allocationTime.Minute; + int? timeOfMonthPacked = zeroIndexedDay << 12 | hour << 6 | minute; + byte[] timeOfMonthBytes = BitConverter.GetBytes(timeOfMonthPacked.Value); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timeOfMonthBytes); + } + + // The first byte of the integer is completely unused. Start at index 1 and take the remaining 3 bytes of the 32-bit integer + // The first 6 bits of the remaining 3 bytes are unused. 3 bytes becomes 4 base64 characters, so we can skip the first. + string timeOfMonthEncoded = Convert.ToBase64String(timeOfMonthBytes, 1, 3).Substring(1, 3); + return timeOfMonthEncoded; + } + + private static string EncodeExpiration(TimeSpan expiration) + { + // We encode the expiration as a base62 string. The integer we encode is the total minutes of the expiration divided by 5. + // We can only use 3 ASCII characters to encode the expiration, so the integer value must be less than 62^3. We could support + // up to about 827 days but our long lived API keys only last up to a year so that's not needed. + if (expiration > TimeSpan.FromDays(366)) + { + throw new ArgumentOutOfRangeException(nameof(expiration), $"The {nameof(expiration)} must be 366 days or shorter."); + } + + int expirationMinutes = (int)expiration.TotalMinutes; + if (expiration.Ticks % TimeSpan.TicksPerMinute != 0 + || expirationMinutes % 5 != 0) + { + throw new ArgumentOutOfRangeException(nameof(expiration), $"The total minutes of the {nameof(expiration)} must be a whole number and be a multiple of five."); + } + + byte[] expirationBytes = BitConverter.GetBytes(expirationMinutes / 5); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(expirationBytes); + } + + string encodedExpiration = expirationBytes.ToBase62().PadLeft(4, '0'); + if (encodedExpiration.Length > 4 || encodedExpiration[0] != '0') + { + throw new ArgumentOutOfRangeException(nameof(expiration), "The expiration is too long to encode."); + } + + return encodedExpiration.Substring(1); + } + } +} diff --git a/src/NuGetGallery.Services/NuGetGallery.Services.csproj b/src/NuGetGallery.Services/NuGetGallery.Services.csproj index 6239351b97..8806108a3f 100644 --- a/src/NuGetGallery.Services/NuGetGallery.Services.csproj +++ b/src/NuGetGallery.Services/NuGetGallery.Services.csproj @@ -43,6 +43,7 @@ + diff --git a/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs b/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs new file mode 100644 index 0000000000..00199de76b --- /dev/null +++ b/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs @@ -0,0 +1,260 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Cryptography; +using System.Text; +using Base62; +using Microsoft.Security.Utilities; +using Xunit; + +#nullable enable + +namespace NuGetGallery.Infrastructure.Authentication +{ + public class ApiKeyV5Facts + { + private const string TestApiKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaJQQJ99ALN5Z0000LZENeS003NUGT3OrW"; + private const string HashedTestApiKey = "BWqhR33SkX0/BxG34nEZtByLp5uRz/H3lD89EDnFylq+peJ1EtGolGiUqOa44+5t0vlHd1joByP3rojeTF5scQ=="; + + public DateTime AllocationTime { get; set; } + public char Environment { get; set; } + public int UserKey { get; set; } + public char Type { get; set; } + public TimeSpan Expiration { get; set; } + + public class TheCreateMethod : ApiKeyV5Facts + { + [Fact] + public void UsesAllParameters() + { + // Act + var apiKey = ApiKeyV5.Create(AllocationTime, Environment, UserKey, Type, Expiration); + + // Assert + Assert.Equal(AllocationTime, apiKey.AllocationTime); + Assert.Equal(Environment, apiKey.Environment); + Assert.Equal(UserKey, apiKey.UserKey); + Assert.Equal(Type, apiKey.Type); + Assert.Equal(Expiration, apiKey.Expiration); + } + + [Fact] + public void ApiKeyMatchesExpectedPatterns() + { + // Act + var apiKey = ApiKeyV5.CreateTestKey(AllocationTime, Environment, UserKey, Type, Expiration, testChar: 'a'); + + // Assert + Assert.Equal(84, apiKey.PlaintextApiKey.Length); + Assert.Contains(IdentifiableSecrets.CommonAnnotatedKeySignature, apiKey.PlaintextApiKey, StringComparison.Ordinal); + + Assert.Equal(88, apiKey.HashedApiKey.Length); // 512 bits, base64 encoded + Assert.Equal(512 / 8, Convert.FromBase64String(apiKey.HashedApiKey).Length); // 512 bits from a SHA-512 hash + Assert.Contains("NUGT", apiKey.PlaintextApiKey, StringComparison.Ordinal); // our expected provider signature + Assert.Contains(Type, apiKey.PlaintextApiKey); + Assert.Contains(Environment, apiKey.PlaintextApiKey); + } + + [Fact] + public void MatchesExpectedFormat() + { + // Act + var apiKey = ApiKeyV5.CreateTestKey(AllocationTime, Environment, UserKey, Type, Expiration, testChar: 'a'); + + // Assert + Assert.Equal(TestApiKey, apiKey.PlaintextApiKey); + Assert.Equal(HashedTestApiKey, apiKey.HashedApiKey); + } + + [Fact] + public void EachApiKeyIsDifferent() + { + // Act + var apiKeyA = ApiKeyV5.Create(AllocationTime, Environment, UserKey, Type, Expiration); + var apiKeyB = ApiKeyV5.Create(AllocationTime, Environment, UserKey, Type, Expiration); + + // Assert + Assert.NotEqual(apiKeyA.PlaintextApiKey, apiKeyB.PlaintextApiKey); + Assert.NotEqual(apiKeyA.HashedApiKey, apiKeyB.HashedApiKey); + } + + [Fact] + public void HashIsSha512() + { + // Act + var apiKey = ApiKeyV5.Create(AllocationTime, Environment, UserKey, Type, Expiration); + + // Assert + using var sha512 = SHA512.Create(); + var hashBytes = sha512.ComputeHash(Encoding.UTF8.GetBytes(apiKey.PlaintextApiKey)); + var hashBase64 = Convert.ToBase64String(hashBytes); + Assert.Equal(hashBase64, apiKey.HashedApiKey); + } + } + + public class TheTryParseMethod : ApiKeyV5Facts + { + [Fact] + public void CanParseValidKey() + { + // Act + var result = ApiKeyV5.TryParse(TestApiKey, out var parsedKey); + + // Assert + Assert.True(result); + Assert.NotNull(parsedKey); + Assert.Equal(TestApiKey, parsedKey.PlaintextApiKey); + Assert.Equal(HashedTestApiKey, parsedKey.HashedApiKey); + Assert.Equal(AllocationTime, parsedKey.AllocationTime); + Assert.Equal(Environment, parsedKey.Environment); + Assert.Equal(UserKey, parsedKey.UserKey); + Assert.Equal(Type, parsedKey.Type); + Assert.Equal(Expiration, parsedKey.Expiration); + } + + [Fact] + public void RejectsInvalidString() + { + // Act + var result = ApiKeyV5.TryParse("invalid-key", out var parsedKey); + + // Assert + Assert.False(result); + Assert.Null(parsedKey); + } + + [Fact] + public void RejectsInvalidChecksum() + { + // Arrange + var input = TestApiKey.Substring(0, TestApiKey.Length - 4) + "XXXX"; + + // Act & Assert + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidPlatformPrefix() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(60, 1).Insert(60, "A")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidApiKeyVersion() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(61, 1).Insert(61, "4")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidEnvironmentCode() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(62, 1).Insert(62, "/")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidTypeCode() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(72, 1).Insert(72, "/")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidAllocationTimeDay() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(69, 1).Insert(69, "9")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidAllocationTimeHour() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(70, 1).Insert(70, "9")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidAllocationTimeMinute() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(71, 1).Insert(71, "9")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidUserKey() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(63, 1).Insert(63, "/")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + [Fact] + public void RejectsInvalidExpiration() + { + // Arrange + var input = FixChecksum(TestApiKey.Remove(73, 1).Insert(73, "/")); + + // Act & Assert + + Assert.False(ApiKeyV5.TryParse(input, out var parsedKey)); + } + + private static string FixChecksum(string input) + { + // source: https://github.com/microsoft/security-utilities/blob/a98b71ccdccf35d7d45b07095b75f6f05b4de9ad/src/Microsoft.Security.Utilities.Core/IdentifiableSecrets.cs#L429-L457 + var dataBase64 = input.Substring(0, 80); + var dataBytes = Convert.FromBase64String(dataBase64); + var checksumBytes = BitConverter.GetBytes(Marvin.ComputeHash32(dataBytes, IdentifiableSecrets.VersionTwoChecksumSeed, 0, dataBytes.Length)); + var checksumBase62 = checksumBytes.ToBase62(); + var checksumBase64 = checksumBase62 + new string('0', 6 - checksumBase62.Length) + "=="; + var checksumBase64Bytes = Convert.FromBase64String(checksumBase64); + var fixedChecksum = dataBase64 + Convert.ToBase64String(checksumBase64Bytes).Substring(0, 4); + Assert.True(IdentifiableSecrets.TryValidateCommonAnnotatedKey(fixedChecksum, ApiKeyV5.ProviderSignature), "The checksum was not fixed properly."); + return fixedChecksum; + } + } + + public ApiKeyV5Facts() + { + AllocationTime = new DateTime(2024, 12, 5, 13, 30, 0, DateTimeKind.Utc); + Environment = 'Z'; + UserKey = 1337; + Type = ApiKeyV5.KnownApiKeyTypes.ShortLived; + Expiration = TimeSpan.FromMinutes(15); + } + } +} From 895ef59dfccdc80e81e54d76103614246c6a155c Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Tue, 14 Jan 2025 10:58:43 -0500 Subject: [PATCH 2/3] Use provided allocation time --- Directory.Packages.props | 2 +- src/NuGetGallery.Services/Authentication/ApiKeyV5.cs | 11 +++++++---- .../Authentication/ApiKeyV5Facts.cs | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f76ff584ad..fdf2b6e7a8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -89,7 +89,7 @@ - + diff --git a/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs b/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs index 0f32e3fa2d..913fee3855 100644 --- a/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs +++ b/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs @@ -182,14 +182,17 @@ private static ApiKeyV5 Create(DateTime allocationTime, char environment, int us string encodedExpiration = EncodeExpiration(expiration); string providerAllocation = $"{type}{encodedExpiration}"; - // TODO: pass in the allocation time when the HISv2 library supports it - // https://github.com/microsoft/security-utilities/pull/111 - string plaintextApiKey = IdentifiableSecrets.GenerateCommonAnnotatedKey( + string plaintextApiKey = IdentifiableSecrets.GenerateCommonAnnotatedTestKey( + randomBytes: null, + IdentifiableSecrets.VersionTwoChecksumSeed, base64EncodedSignature: ProviderSignature, customerManagedKey: true, platformReserved: Convert.FromBase64String(platformAllocation), providerReserved: Convert.FromBase64String(providerAllocation), - testChar: testChar); + longForm: false, + testChar: testChar, + keyKindSignature: IdentifiableSecrets.CommonAnnotatedKeySignature[5], // JQQJ99 -> 9 + allocationTime: allocationTime); // The first 52 characters of an HISv2 key contains 312 bits of entropy. The rest of the API key is // composed of identifiers, checksum, and other supporting metadata. We will hash the entire thing and diff --git a/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs b/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs index 00199de76b..f02415cbdb 100644 --- a/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs +++ b/tests/NuGetGallery.Facts/Authentication/ApiKeyV5Facts.cs @@ -47,7 +47,7 @@ public void ApiKeyMatchesExpectedPatterns() // Assert Assert.Equal(84, apiKey.PlaintextApiKey.Length); - Assert.Contains(IdentifiableSecrets.CommonAnnotatedKeySignature, apiKey.PlaintextApiKey, StringComparison.Ordinal); + Assert.Contains("JQQJ99", apiKey.PlaintextApiKey, StringComparison.Ordinal); Assert.Equal(88, apiKey.HashedApiKey.Length); // 512 bits, base64 encoded Assert.Equal(512 / 8, Convert.FromBase64String(apiKey.HashedApiKey).Length); // 512 bits from a SHA-512 hash From cabe0512bc0e8ea15a729cd62bf225c4fbbdd380 Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Thu, 16 Jan 2025 17:53:05 -0500 Subject: [PATCH 3/3] Address comments --- src/NuGetGallery.Services/Authentication/ApiKeyV5.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs b/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs index 913fee3855..afb66006ef 100644 --- a/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs +++ b/src/NuGetGallery.Services/Authentication/ApiKeyV5.cs @@ -34,7 +34,7 @@ namespace NuGetGallery.Infrastructure.Authentication /// S | 72 | API key type (S = short-lived, L = long-lived) /// 003 (3 * 5 = 15 minutes) | 73-75 | Expiration of the key, in increments of 5 minutes, encoded as base62 /// NUGT | 76-79 | NuGet provider signature - /// 30rW | 80-83 | Checksum + /// 30rW | 80-83 | Part of a Marvin32 checksum, used for initial validation /// /// A plaintext value can be parsed into the above metadata components. The entire API key value should be /// considered as secret, and sensitive. @@ -219,6 +219,11 @@ public static bool TryParse(string plaintextApiKey, out ApiKeyV5? parsed) return false; } + if (plaintextApiKey.Substring(52, 6) != IdentifiableSecrets.CommonAnnotatedKeySignature) + { + return false; + } + try { if (!IdentifiableSecrets.TryValidateCommonAnnotatedKey(plaintextApiKey, ProviderSignature))