diff --git a/AccessGridTest/ConsoleServiceTests.cs b/AccessGridTest/ConsoleServiceTests.cs index bc86fbd..8787b79 100644 --- a/AccessGridTest/ConsoleServiceTests.cs +++ b/AccessGridTest/ConsoleServiceTests.cs @@ -326,6 +326,157 @@ public async Task PublishTemplateAsync_PostsToPublishEndpointAndReturnsStatus() #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())) + .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!); + 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(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(async () => + await _client.Console.RevealTemplatePrivateKeyAsync("")); + } + + private sealed class FakeServerEnvelope + { + public string EphemeralPublicKeyPem { get; set; } = ""; + public byte[] Iv { get; set; } = Array.Empty(); + public byte[] Ciphertext { get; set; } = Array.Empty(); + public byte[] Tag { get; set; } = Array.Empty(); + } + + // 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] diff --git a/README.md b/README.md index ec3432a..4c27f29 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/src/AccessGrid.csproj b/src/AccessGrid.csproj index dcb0afe..dfe30d0 100644 --- a/src/AccessGrid.csproj +++ b/src/AccessGrid.csproj @@ -15,6 +15,7 @@ + diff --git a/src/AccessGrid/ConsoleService.cs b/src/AccessGrid/ConsoleService.cs index c573aeb..c0b8dab 100644 --- a/src/AccessGrid/ConsoleService.cs +++ b/src/AccessGrid/ConsoleService.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Text; using System.Threading.Tasks; namespace AccessGrid @@ -77,6 +79,48 @@ public async Task PublishTemplateAsync(string templateI return response; } + /// + /// 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. + /// + /// Card template id (must be a published Google SmartTap template) + /// Decrypted private key plus key version, collector id, and fingerprint + public async Task 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( + $"/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) + }; + } + /// /// Gets event logs for a card template (enterprise only) /// diff --git a/src/AccessGrid/Models.cs b/src/AccessGrid/Models.cs index 1c5c84c..5e1c407 100644 --- a/src/AccessGrid/Models.cs +++ b/src/AccessGrid/Models.cs @@ -818,6 +818,62 @@ public class PublishTemplateResponse public string Status { get; set; } } + /// + /// Encrypted envelope returned by the SmartTap reveal endpoint. + /// Used internally to decrypt; not surfaced to SDK callers. + /// + 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; } + } + + /// + /// Raw response from POST /v1/console/card-templates/{id}/smart-tap/reveal. + /// Internal: callers receive RevealTemplatePrivateKeyResponse instead. + /// + 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; } + } + + /// + /// Result of a SmartTap private key reveal. The private key has already been + /// decrypted client-side; the server-side encryption envelope is not exposed. + /// + public class RevealTemplatePrivateKeyResponse + { + public string KeyVersion { get; set; } + public string CollectorId { get; set; } + public string Fingerprint { get; set; } + /// + /// PEM-encoded private key. Sensitive — store in your reader/collector key vault. + /// + public string PrivateKey { get; set; } + } + /// /// iOS In-App Provisioning preflight response /// diff --git a/src/AccessGrid/SmartTapRevealCrypto.cs b/src/AccessGrid/SmartTapRevealCrypto.cs new file mode 100644 index 0000000..6f7cf1b --- /dev/null +++ b/src/AccessGrid/SmartTapRevealCrypto.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Agreement; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; + +namespace AccessGrid +{ + internal static class SmartTapRevealCrypto + { + private const string CurveName = "P-256"; + private const string HkdfInfo = "accessgrid-smart-tap-reveal-v1"; + private const int AesKeyLengthBytes = 32; + private const int SharedSecretLengthBytes = 32; + + internal sealed class GeneratedKeyPair + { + public AsymmetricCipherKeyPair KeyPair { get; } + public string PublicKeyPem { get; } + + public GeneratedKeyPair(AsymmetricCipherKeyPair keyPair, string publicKeyPem) + { + KeyPair = keyPair; + PublicKeyPem = publicKeyPem; + } + } + + public static GeneratedKeyPair GenerateP256KeyPair() + { + var curveParams = ECNamedCurveTable.GetByName(CurveName); + var domain = new ECDomainParameters(curveParams.Curve, curveParams.G, curveParams.N, curveParams.H, curveParams.GetSeed()); + var keyGen = new ECKeyPairGenerator(); + keyGen.Init(new ECKeyGenerationParameters(domain, new SecureRandom())); + var keyPair = keyGen.GenerateKeyPair(); + + string publicKeyPem; + using (var writer = new StringWriter()) + { + var pemWriter = new PemWriter(writer); + pemWriter.WriteObject(keyPair.Public); + pemWriter.Writer.Flush(); + publicKeyPem = writer.ToString(); + } + + return new GeneratedKeyPair(keyPair, publicKeyPem); + } + + public static byte[] DecryptEnvelope( + AsymmetricCipherKeyPair localKeyPair, + string serverEphemeralPublicKeyPem, + byte[] iv, + byte[] ciphertext, + byte[] tag) + { + var serverPublicKey = ReadEcPublicKeyFromPem(serverEphemeralPublicKeyPem); + + var sharedSecret = ComputeSharedSecret(localKeyPair.Private, serverPublicKey); + var aesKey = DeriveAesKey(sharedSecret); + return DecryptAesGcm(aesKey, iv, ciphertext, tag); + } + + private static ECPublicKeyParameters ReadEcPublicKeyFromPem(string pem) + { + using var reader = new StringReader(pem); + var pemReader = new PemReader(reader); + var obj = pemReader.ReadObject(); + return obj switch + { + 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") + }; + } + + private static byte[] ComputeSharedSecret(AsymmetricKeyParameter privateKey, ECPublicKeyParameters publicKey) + { + var agreement = new ECDHBasicAgreement(); + agreement.Init(privateKey); + var sharedBigInt = agreement.CalculateAgreement(publicKey); + // Standard ECDH "raw" shared secret = X coordinate as fixed-width big-endian bytes (32 for P-256). + return BigIntegerToFixedBytes(sharedBigInt, SharedSecretLengthBytes); + } + + private static byte[] BigIntegerToFixedBytes(Org.BouncyCastle.Math.BigInteger value, int length) + { + var unsigned = value.ToByteArrayUnsigned(); + if (unsigned.Length == length) return unsigned; + if (unsigned.Length > length) + throw new InvalidOperationException("ECDH shared secret exceeds expected length"); + var padded = new byte[length]; + Buffer.BlockCopy(unsigned, 0, padded, length - unsigned.Length, unsigned.Length); + return padded; + } + + private static byte[] DeriveAesKey(byte[] sharedSecret) + { + var hkdf = new Org.BouncyCastle.Crypto.Generators.HkdfBytesGenerator(new Sha256Digest()); + var info = System.Text.Encoding.UTF8.GetBytes(HkdfInfo); + hkdf.Init(new HkdfParameters(sharedSecret, salt: new byte[0], info: info)); + var output = new byte[AesKeyLengthBytes]; + hkdf.GenerateBytes(output, 0, output.Length); + return output; + } + + private static byte[] DecryptAesGcm(byte[] key, byte[] iv, byte[] ciphertext, byte[] tag) + { + var cipher = new GcmBlockCipher(new AesEngine()); + // BouncyCastle expects ciphertext||tag concatenated. + var combined = new byte[ciphertext.Length + tag.Length]; + Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length); + + 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); + + if (len == output.Length) return output; + var trimmed = new byte[len]; + Buffer.BlockCopy(output, 0, trimmed, 0, len); + return trimmed; + } + } +}