Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:

strategy:
matrix:
dotnet: ['8.0.x']
dotnet: ['10.0.300']

steps:
- name: Checkout code
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
dotnet 8.0.416
dotnet 10.0.300
90 changes: 90 additions & 0 deletions AccessGridTest/ConsoleServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@
}
""";

string capturedBody = null;

Check warning on line 267 in AccessGridTest/ConsoleServiceTests.cs

View workflow job for this annotation

GitHub Actions / test (10.0.300)

Converting null literal or possible null value to non-nullable type.
_mockHttpClient
.Setup(x => x.SendAsync(It.IsAny<HttpRequestMessage>()))
.Returns<HttpRequestMessage>(async req =>
Expand Down Expand Up @@ -391,6 +391,96 @@
await _client.Console.RevealTemplatePrivateKeyAsync(""));
}

[Test]
public void RevealTemplatePrivateKeyAsync_ThrowsInvalidEnvelopeOnMissingEnvelope()
{
_mockHttpClient
.Setup(x => x.SendAsync(It.IsAny<HttpRequestMessage>()))
.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<InvalidEnvelopeException>(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<HttpRequestMessage>()))
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, Encoding.UTF8, "application/json")
});

Assert.ThrowsAsync<InvalidEnvelopeException>(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<HttpRequestMessage>()))
.Returns<HttpRequestMessage>(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<DecryptException>(async () =>
await _client.Console.RevealTemplatePrivateKeyAsync("tmpl-42"));

await Task.CompletedTask;
}

private sealed class FakeServerEnvelope
{
public string EphemeralPublicKeyPem { get; set; } = "";
Expand Down Expand Up @@ -526,7 +616,7 @@
{
var json = """{ "id": "tmpl-123", "name": "Test" }""";

string capturedBody = null;

Check warning on line 619 in AccessGridTest/ConsoleServiceTests.cs

View workflow job for this annotation

GitHub Actions / test (10.0.300)

Converting null literal or possible null value to non-nullable type.
_mockHttpClient
.Setup(x => x.SendAsync(It.IsAny<HttpRequestMessage>()))
.Returns<HttpRequestMessage>(async req =>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/AccessGrid.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>8.0</LangVersion>
<PackageId>accessgrid</PackageId>
<Version>1.5.0</Version>
<Version>1.5.1</Version>
<Authors>AccessGrid</Authors>
<Company>AccessGrid</Company>
<Description>Official C# SDK for the AccessGrid API</Description>
Expand Down
2 changes: 1 addition & 1 deletion src/AccessGrid/ConsoleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public async Task<RevealTemplatePrivateKeyResponse> 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);
Expand Down
22 changes: 22 additions & 0 deletions src/AccessGrid/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,26 @@ public class AuthenticationException : AccessGridException
{
public AuthenticationException(string message) : base(message) { }
}

/// <summary>
/// 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.
/// </summary>
public class InvalidEnvelopeException : AccessGridException
{
public InvalidEnvelopeException(string message) : base(message) { }
public InvalidEnvelopeException(string message, Exception innerException) : base(message, innerException) { }
}

/// <summary>
/// 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).
/// </summary>
public class DecryptException : AccessGridException
{
public DecryptException(string message) : base(message) { }
public DecryptException(string message, Exception innerException) : base(message, innerException) { }
}
}
13 changes: 10 additions & 3 deletions src/AccessGrid/SmartTapRevealCrypto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
};
}

Expand All @@ -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;
Expand All @@ -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];
Expand Down
Loading