From d07838322457c62938560a9f244cbbbbf9859d6e Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Wed, 27 May 2026 19:47:28 -0400 Subject: [PATCH 1/3] throw typed exceptions from reveal crypto path --- AccessGridTest/ConsoleServiceTests.cs | 90 ++++++++++++++++++++++++++ src/AccessGrid/ConsoleService.cs | 2 +- src/AccessGrid/Exceptions.cs | 22 +++++++ src/AccessGrid/SmartTapRevealCrypto.cs | 13 +++- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/AccessGridTest/ConsoleServiceTests.cs b/AccessGridTest/ConsoleServiceTests.cs index 8787b79..9b1a892 100644 --- a/AccessGridTest/ConsoleServiceTests.cs +++ b/AccessGridTest/ConsoleServiceTests.cs @@ -391,6 +391,96 @@ public void RevealTemplatePrivateKeyAsync_RejectsEmptyTemplateId() await _client.Console.RevealTemplatePrivateKeyAsync("")); } + [Test] + public void RevealTemplatePrivateKeyAsync_ThrowsInvalidEnvelopeOnMissingEnvelope() + { + _mockHttpClient + .Setup(x => x.SendAsync(It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"key_version\":\"tmpl-42\",\"collector_id\":\"12345678\",\"fingerprint\":\"" + new string('a', 64) + "\"}", + Encoding.UTF8, "application/json") + }); + + var ex = Assert.ThrowsAsync(async () => + await _client.Console.RevealTemplatePrivateKeyAsync("tmpl-42")); + Assert.That(ex.Message, Does.Contain("encrypted_private_key")); + } + + [Test] + public void RevealTemplatePrivateKeyAsync_ThrowsInvalidEnvelopeOnMalformedEphemeralPubKey() + { + var responseJson = System.Text.Json.JsonSerializer.Serialize(new + { + key_version = "tmpl-42", + collector_id = "12345678", + fingerprint = new string('a', 64), + encrypted_private_key = new + { + alg = "ECDH-ES+A256GCM", + ephemeral_public_key = "NOT A PEM", + iv = "AAAAAAAAAAAAAAAA", + ciphertext = "AAAA", + tag = "AAAAAAAAAAAAAAAAAAAAAA==" + } + }); + + _mockHttpClient + .Setup(x => x.SendAsync(It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }); + + Assert.ThrowsAsync(async () => + await _client.Console.RevealTemplatePrivateKeyAsync("tmpl-42")); + } + + [Test] + public async Task RevealTemplatePrivateKeyAsync_ThrowsDecryptExceptionOnTamperedTag() + { + const string plaintextPem = "SENTINEL-NOT-A-CREDENTIAL"; + + _mockHttpClient + .Setup(x => x.SendAsync(It.IsAny())) + .Returns(async req => + { + var requestBody = await req.Content!.ReadAsStringAsync(); + var requestJson = System.Text.Json.JsonDocument.Parse(requestBody); + var clientPublicKeyPem = requestJson.RootElement.GetProperty("client_public_key").GetString(); + + var envelope = SimulateServerEncrypt(plaintextPem, clientPublicKeyPem!); + // Flip a bit in the auth tag. + envelope.Tag[0] ^= 0x01; + + var responseJson = System.Text.Json.JsonSerializer.Serialize(new + { + key_version = "tmpl-42", + collector_id = "12345678", + fingerprint = new string('a', 64), + encrypted_private_key = new + { + alg = "ECDH-ES+A256GCM", + ephemeral_public_key = envelope.EphemeralPublicKeyPem, + iv = Convert.ToBase64String(envelope.Iv), + ciphertext = Convert.ToBase64String(envelope.Ciphertext), + tag = Convert.ToBase64String(envelope.Tag) + } + }); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }; + }); + + Assert.ThrowsAsync(async () => + await _client.Console.RevealTemplatePrivateKeyAsync("tmpl-42")); + + await Task.CompletedTask; + } + private sealed class FakeServerEnvelope { public string EphemeralPublicKeyPem { get; set; } = ""; diff --git a/src/AccessGrid/ConsoleService.cs b/src/AccessGrid/ConsoleService.cs index c0b8dab..eb44fce 100644 --- a/src/AccessGrid/ConsoleService.cs +++ b/src/AccessGrid/ConsoleService.cs @@ -102,7 +102,7 @@ public async Task RevealTemplatePrivateKeyAsyn $"/v1/console/card-templates/{templateId}/smart-tap/reveal", body); if (raw?.EncryptedPrivateKey == null) - throw new InvalidOperationException("Server response missing encrypted_private_key envelope"); + throw new InvalidEnvelopeException("Server response missing encrypted_private_key envelope"); var envelope = raw.EncryptedPrivateKey; var iv = Convert.FromBase64String(envelope.Iv); diff --git a/src/AccessGrid/Exceptions.cs b/src/AccessGrid/Exceptions.cs index 7c398be..2d58f0d 100644 --- a/src/AccessGrid/Exceptions.cs +++ b/src/AccessGrid/Exceptions.cs @@ -18,4 +18,26 @@ public class AuthenticationException : AccessGridException { public AuthenticationException(string message) : base(message) { } } + + /// + /// Thrown when a SmartTap reveal envelope is missing required fields, + /// contains non-base64 / non-PEM data, or otherwise can't be parsed + /// before the cryptographic operations begin. + /// + public class InvalidEnvelopeException : AccessGridException + { + public InvalidEnvelopeException(string message) : base(message) { } + public InvalidEnvelopeException(string message, Exception innerException) : base(message, innerException) { } + } + + /// + /// Thrown when AES-GCM auth-tag verification fails while decrypting a + /// SmartTap reveal envelope (wrong key, tampered envelope, or wire-format + /// drift between server and SDK). + /// + public class DecryptException : AccessGridException + { + public DecryptException(string message) : base(message) { } + public DecryptException(string message, Exception innerException) : base(message, innerException) { } + } } \ No newline at end of file diff --git a/src/AccessGrid/SmartTapRevealCrypto.cs b/src/AccessGrid/SmartTapRevealCrypto.cs index 6f7cf1b..809cf4c 100644 --- a/src/AccessGrid/SmartTapRevealCrypto.cs +++ b/src/AccessGrid/SmartTapRevealCrypto.cs @@ -75,7 +75,7 @@ private static ECPublicKeyParameters ReadEcPublicKeyFromPem(string pem) { ECPublicKeyParameters ecPub => ecPub, AsymmetricCipherKeyPair pair when pair.Public is ECPublicKeyParameters pairPub => pairPub, - _ => throw new InvalidOperationException("ephemeral_public_key is not a valid EC public key PEM") + _ => throw new InvalidEnvelopeException("ephemeral_public_key is not a valid EC public key PEM") }; } @@ -93,7 +93,7 @@ private static byte[] BigIntegerToFixedBytes(Org.BouncyCastle.Math.BigInteger va var unsigned = value.ToByteArrayUnsigned(); if (unsigned.Length == length) return unsigned; if (unsigned.Length > length) - throw new InvalidOperationException("ECDH shared secret exceeds expected length"); + throw new InvalidEnvelopeException("ECDH shared secret exceeds expected length"); var padded = new byte[length]; Buffer.BlockCopy(unsigned, 0, padded, length - unsigned.Length, unsigned.Length); return padded; @@ -120,7 +120,14 @@ private static byte[] DecryptAesGcm(byte[] key, byte[] iv, byte[] ciphertext, by cipher.Init(false, new AeadParameters(new KeyParameter(key), tag.Length * 8, iv, associatedText: new byte[0])); var output = new byte[cipher.GetOutputSize(combined.Length)]; int len = cipher.ProcessBytes(combined, 0, combined.Length, output, 0); - len += cipher.DoFinal(output, len); + try + { + len += cipher.DoFinal(output, len); + } + catch (Org.BouncyCastle.Crypto.InvalidCipherTextException ex) + { + throw new DecryptException("AES-GCM decryption failed (auth tag verification)", ex); + } if (len == output.Length) return output; var trimmed = new byte[len]; From 4f1a7d053c25d851e4f8e0606de9d16a9b72bc29 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Wed, 27 May 2026 20:04:16 -0400 Subject: [PATCH 2/3] bump dotnet to v10.0.300 --- .github/workflows/ci.yml | 2 +- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1bb6e5..94100fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - dotnet: ['8.0.x'] + dotnet: ['10.0.300'] steps: - name: Checkout code diff --git a/.tool-versions b/.tool-versions index 08f3985..ae01a19 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -dotnet 8.0.416 +dotnet 10.0.300 From 9d11dd2e259c061510dbc1fb21503fdd1b4bf40a Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Wed, 27 May 2026 20:04:19 -0400 Subject: [PATCH 3/3] bump version to 1.5.1 --- README.md | 2 +- src/AccessGrid.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4c27f29..dcd5be8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Official C# SDK for interacting with the AccessGrid API. ## Installation ``` -Install-Package accessgrid -Version 1.5.0 +Install-Package accessgrid -Version 1.5.1 ``` ## Authentication diff --git a/src/AccessGrid.csproj b/src/AccessGrid.csproj index dfe30d0..4396b46 100644 --- a/src/AccessGrid.csproj +++ b/src/AccessGrid.csproj @@ -4,7 +4,7 @@ netstandard2.0 8.0 accessgrid - 1.5.0 + 1.5.1 AccessGrid AccessGrid Official C# SDK for the AccessGrid API