Skip to content
Merged
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
151 changes: 151 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 (8.0.x)

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 @@ -326,6 +326,157 @@

#endregion

#region RevealTemplatePrivateKeyAsync

[Test]
public async Task RevealTemplatePrivateKeyAsync_DecryptsServerEnvelope()
{
// Plaintext that the server pretends is the stored smart_tap_key.
const string plaintextPem = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBmlx2KqB7+RLMrHWLMm6hh3JwFrL2ZxZTLkW1yX8OabAoGCCqGSM49
AwEHoUQDQgAEs5bJrjEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXA
MPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEE=
-----END EC PRIVATE KEY-----
""";

// Capture the SDK's outgoing client_public_key, then encrypt against it.
_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!);
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")
};
});

var result = await _client.Console.RevealTemplatePrivateKeyAsync("tmpl-42");

Assert.That(result.KeyVersion, Is.EqualTo("tmpl-42"));
Assert.That(result.CollectorId, Is.EqualTo("12345678"));
Assert.That(result.Fingerprint, Has.Length.EqualTo(64));
Assert.That(result.PrivateKey, Is.EqualTo(plaintextPem));

_mockHttpClient.Verify(x => x.SendAsync(It.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Post &&
req.RequestUri!.ToString().Contains("/v1/console/card-templates/tmpl-42/smart-tap/reveal")
)), Times.Once);
}

[Test]
public void RevealTemplatePrivateKeyAsync_RejectsEmptyTemplateId()
{
Assert.ThrowsAsync<ArgumentException>(async () =>
await _client.Console.RevealTemplatePrivateKeyAsync(""));
}

private sealed class FakeServerEnvelope
{
public string EphemeralPublicKeyPem { get; set; } = "";
public byte[] Iv { get; set; } = Array.Empty<byte>();
public byte[] Ciphertext { get; set; } = Array.Empty<byte>();
public byte[] Tag { get; set; } = Array.Empty<byte>();
}

// Mirrors the server's SmartTap::RevealEncryption.encrypt using BouncyCastle so
// the round-trip is exercised end to end.
private static FakeServerEnvelope SimulateServerEncrypt(string plaintextPem, string clientPublicKeyPem)
{
var curve = Org.BouncyCastle.Asn1.X9.ECNamedCurveTable.GetByName("P-256");
var domain = new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(
curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());

// Generate ephemeral keypair (server side).
var keyGen = new Org.BouncyCastle.Crypto.Generators.ECKeyPairGenerator();
keyGen.Init(new Org.BouncyCastle.Crypto.Parameters.ECKeyGenerationParameters(
domain, new Org.BouncyCastle.Security.SecureRandom()));
var ephemeral = keyGen.GenerateKeyPair();

// Read client's public key.
Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters clientPub;
using (var reader = new System.IO.StringReader(clientPublicKeyPem))
{
var pem = new Org.BouncyCastle.OpenSsl.PemReader(reader);
clientPub = (Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters)pem.ReadObject();
}

// ECDH(server_ephemeral_priv, client_pub).
var agreement = new Org.BouncyCastle.Crypto.Agreement.ECDHBasicAgreement();
agreement.Init(ephemeral.Private);
var sharedBigInt = agreement.CalculateAgreement(clientPub);
var unsigned = sharedBigInt.ToByteArrayUnsigned();
var sharedSecret = new byte[32];
Buffer.BlockCopy(unsigned, 0, sharedSecret, 32 - unsigned.Length, unsigned.Length);

// HKDF-SHA256 → 32-byte AES key.
var hkdf = new Org.BouncyCastle.Crypto.Generators.HkdfBytesGenerator(
new Org.BouncyCastle.Crypto.Digests.Sha256Digest());
hkdf.Init(new Org.BouncyCastle.Crypto.Parameters.HkdfParameters(
sharedSecret, salt: new byte[0],
info: Encoding.UTF8.GetBytes("accessgrid-smart-tap-reveal-v1")));
var aesKey = new byte[32];
hkdf.GenerateBytes(aesKey, 0, aesKey.Length);

// AES-256-GCM encrypt.
var iv = new byte[12];
new Org.BouncyCastle.Security.SecureRandom().NextBytes(iv);
var gcm = new Org.BouncyCastle.Crypto.Modes.GcmBlockCipher(
new Org.BouncyCastle.Crypto.Engines.AesEngine());
gcm.Init(true, new Org.BouncyCastle.Crypto.Parameters.AeadParameters(
new Org.BouncyCastle.Crypto.Parameters.KeyParameter(aesKey),
macSize: 128, nonce: iv, associatedText: new byte[0]));
var plaintextBytes = Encoding.UTF8.GetBytes(plaintextPem);
var combined = new byte[gcm.GetOutputSize(plaintextBytes.Length)];
int written = gcm.ProcessBytes(plaintextBytes, 0, plaintextBytes.Length, combined, 0);
written += gcm.DoFinal(combined, written);
// BC concatenates ciphertext||tag in `combined`. Tag is final 16 bytes.
var ciphertext = new byte[combined.Length - 16];
var tag = new byte[16];
Buffer.BlockCopy(combined, 0, ciphertext, 0, ciphertext.Length);
Buffer.BlockCopy(combined, ciphertext.Length, tag, 0, 16);

// Serialize ephemeral public key as PEM (SPKI).
string ephemeralPubPem;
using (var writer = new System.IO.StringWriter())
{
var pemWriter = new Org.BouncyCastle.OpenSsl.PemWriter(writer);
pemWriter.WriteObject(ephemeral.Public);
pemWriter.Writer.Flush();
ephemeralPubPem = writer.ToString();
}

return new FakeServerEnvelope
{
EphemeralPublicKeyPem = ephemeralPubPem,
Iv = iv,
Ciphertext = ciphertext,
Tag = tag
};
}

#endregion

#region UpdateTemplateAsync

[Test]
Expand Down Expand Up @@ -375,7 +526,7 @@
{
var json = """{ "id": "tmpl-123", "name": "Test" }""";

string capturedBody = null;

Check warning on line 529 in AccessGridTest/ConsoleServiceTests.cs

View workflow job for this annotation

GitHub Actions / test (8.0.x)

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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,31 @@ public async Task PublishTemplateAsync()
}
```

### Revealing a SmartTap Private Key

```csharp
using AccessGrid;
using System;
using System.Threading.Tasks;

public async Task RevealTemplatePrivateKeyAsync()
{
var accountId = Environment.GetEnvironmentVariable("ACCOUNT_ID");
var secretKey = Environment.GetEnvironmentVariable("SECRET_KEY");

using var client = new AccessGridClient(accountId, secretKey);

// SDK generates a P-256 keypair locally, submits the public key, and
// decrypts the server's response. The private key never leaves the host.
var result = await client.Console.RevealTemplatePrivateKeyAsync("0xd3adb00b5");

Console.WriteLine($"Key version: {result.KeyVersion}");
Console.WriteLine($"Collector ID: {result.CollectorId}");
Console.WriteLine($"Fingerprint: {result.Fingerprint}");
Console.WriteLine(result.PrivateKey); // PEM — store in your reader/collector key vault
}
```

### Reading Event Logs

```csharp
Expand Down Expand Up @@ -1091,6 +1116,7 @@ public class AccessCardsApiTests
| PUT /v1/console/card-templates/{id} | `Console.UpdateTemplateAsync()` | Y |
| GET /v1/console/card-templates/{id} | `Console.ReadTemplateAsync()` | Y |
| POST /v1/console/card-templates/{id}/publish | `Console.PublishTemplateAsync()` | Y |
| POST /v1/console/card-templates/{id}/smart-tap/reveal | `Console.RevealTemplatePrivateKeyAsync()` | Y |
| GET /v1/console/card-templates/{id}/logs | `Console.EventLogAsync()` | Y |
| GET /v1/console/card-template-pairs | `Console.ListPassTemplatePairsAsync()` | Y |
| POST /v1/console/card-template-pairs | `Console.CreatePassTemplatePairAsync()` | Y |
Expand Down
1 change: 1 addition & 0 deletions src/AccessGrid.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="System.Text.Json" Version="9.0.9" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="System.Reflection.Emit" Version="4.7.0" />
Expand Down
44 changes: 44 additions & 0 deletions src/AccessGrid/ConsoleService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace AccessGrid
Expand Down Expand Up @@ -77,6 +79,48 @@ public async Task<PublishTemplateResponse> PublishTemplateAsync(string templateI
return response;
}

/// <summary>
/// Reveals the SmartTap private key for a card template.
///
/// The SDK generates a P-256 keypair locally, submits the public key to the
/// server, and decrypts the returned envelope (ECDH-ES + HKDF-SHA256 +
/// AES-256-GCM) so that the private key never leaves this host in plaintext.
///
/// Each call must use a fresh public key — the server rejects reuse.
/// </summary>
/// <param name="templateId">Card template id (must be a published Google SmartTap template)</param>
/// <returns>Decrypted private key plus key version, collector id, and fingerprint</returns>
public async Task<RevealTemplatePrivateKeyResponse> RevealTemplatePrivateKeyAsync(string templateId)
{
if (string.IsNullOrEmpty(templateId))
throw new ArgumentException("templateId is required", nameof(templateId));

var generated = SmartTapRevealCrypto.GenerateP256KeyPair();
var body = new { client_public_key = generated.PublicKeyPem };

var raw = await _apiService.PostAsync<SmartTapRevealRawResponse>(
$"/v1/console/card-templates/{templateId}/smart-tap/reveal", body);

if (raw?.EncryptedPrivateKey == null)
throw new InvalidOperationException("Server response missing encrypted_private_key envelope");

var envelope = raw.EncryptedPrivateKey;
var iv = Convert.FromBase64String(envelope.Iv);
var ciphertext = Convert.FromBase64String(envelope.Ciphertext);
var tag = Convert.FromBase64String(envelope.Tag);

var plaintext = SmartTapRevealCrypto.DecryptEnvelope(
generated.KeyPair, envelope.EphemeralPublicKey, iv, ciphertext, tag);

return new RevealTemplatePrivateKeyResponse
{
KeyVersion = raw.KeyVersion,
CollectorId = raw.CollectorId,
Fingerprint = raw.Fingerprint,
PrivateKey = Encoding.UTF8.GetString(plaintext)
};
}

/// <summary>
/// Gets event logs for a card template (enterprise only)
/// </summary>
Expand Down
56 changes: 56 additions & 0 deletions src/AccessGrid/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,62 @@ public class PublishTemplateResponse
public string Status { get; set; }
}

/// <summary>
/// Encrypted envelope returned by the SmartTap reveal endpoint.
/// Used internally to decrypt; not surfaced to SDK callers.
/// </summary>
internal class SmartTapRevealEnvelope
{
[JsonPropertyName("alg")]
public string Alg { get; set; }

[JsonPropertyName("ephemeral_public_key")]
public string EphemeralPublicKey { get; set; }

[JsonPropertyName("iv")]
public string Iv { get; set; }

[JsonPropertyName("ciphertext")]
public string Ciphertext { get; set; }

[JsonPropertyName("tag")]
public string Tag { get; set; }
}

/// <summary>
/// Raw response from POST /v1/console/card-templates/{id}/smart-tap/reveal.
/// Internal: callers receive RevealTemplatePrivateKeyResponse instead.
/// </summary>
internal class SmartTapRevealRawResponse
{
[JsonPropertyName("key_version")]
public string KeyVersion { get; set; }

[JsonPropertyName("collector_id")]
public string CollectorId { get; set; }

[JsonPropertyName("fingerprint")]
public string Fingerprint { get; set; }

[JsonPropertyName("encrypted_private_key")]
public SmartTapRevealEnvelope EncryptedPrivateKey { get; set; }
}

/// <summary>
/// Result of a SmartTap private key reveal. The private key has already been
/// decrypted client-side; the server-side encryption envelope is not exposed.
/// </summary>
public class RevealTemplatePrivateKeyResponse
{
public string KeyVersion { get; set; }
public string CollectorId { get; set; }
public string Fingerprint { get; set; }
/// <summary>
/// PEM-encoded private key. Sensitive — store in your reader/collector key vault.
/// </summary>
public string PrivateKey { get; set; }
}

/// <summary>
/// iOS In-App Provisioning preflight response
/// </summary>
Expand Down
Loading
Loading