diff --git a/README.md b/README.md index c9e23a5..8591368 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ $client->accessCards->delete(['card_id' => '0xc4rd1d']); $template = $client->console->createTemplate([ 'name' => 'Employee Access Pass', 'platform' => 'apple', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', 'protocol' => 'desfire', 'allow_on_multiple_devices' => true, 'watch_count' => 2, @@ -200,6 +200,21 @@ echo "Protocol: {$template->protocol}\n"; echo "Multi-device: {$template->allow_on_multiple_devices}\n"; ``` +### Revealing a SmartTap Private Key + +Fetches the template's SmartTap private key, decrypted client-side. The SDK generates a fresh ephemeral P-256 keypair per call, submits the public half, and decrypts the server's response — you get the plaintext PEM back without touching any crypto. + +```php +$reveal = $client->console->revealSmartTap('0xd3adb00b5'); + +echo "Key version: {$reveal->keyVersion}\n"; +echo "Collector ID: {$reveal->collectorId}\n"; +echo "Fingerprint: {$reveal->fingerprint}\n"; +echo $reveal->privateKey; // PEM — store in your reader/collector key vault +``` + +The server enforces single-use on pubkey fingerprint and rate-limits to 1 per minute per account. The SDK uses a fresh keypair every call, so single-use is satisfied automatically. + ### Event Logs ```php @@ -385,6 +400,7 @@ MIT License | POST /v1/console/card-template-pairs | `console->createPassTemplatePair()` | Y | | POST /v1/console/card-templates/{id}/ios_preflight | `console->iosPreflight()` | Y | | POST /v1/console/card-templates/{id}/publish | `console->publishTemplate()` | Y | +| POST /v1/console/card-templates/{id}/smart-tap/reveal | `console->revealSmartTap()` | Y | | GET /v1/console/ledger-items | `console->ledgerItems()` | Y | | GET /v1/console/landing-pages | `console->listLandingPages()` | Y | | POST /v1/console/landing-pages | `console->createLandingPage()` | Y | diff --git a/ex.php b/ex.php index e5690a4..a73a176 100644 --- a/ex.php +++ b/ex.php @@ -131,7 +131,7 @@ $template = $client->console->createTemplate([ 'name' => 'Employee NFC key', 'platform' => 'apple', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', 'protocol' => 'desfire', 'allow_on_multiple_devices' => true, 'watch_count' => 2, diff --git a/src/AccessGridClient.php b/src/AccessGridClient.php index 658b937..596aa80 100644 --- a/src/AccessGridClient.php +++ b/src/AccessGridClient.php @@ -11,7 +11,7 @@ class AccessGridClient { - public const VERSION = '1.2.0'; + public const VERSION = '1.3.0'; private string $accountId; private string $secretKey; diff --git a/src/Crypto/SmartTapRevealCrypto.php b/src/Crypto/SmartTapRevealCrypto.php new file mode 100644 index 0000000..4994716 --- /dev/null +++ b/src/Crypto/SmartTapRevealCrypto.php @@ -0,0 +1,88 @@ + OpenSSL key, 'pub_pem' => SubjectPublicKeyInfo PEM string]. + */ + public static function generateKeypair(): array + { + $priv = openssl_pkey_new([ + 'curve_name' => self::CURVE, + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + if ($priv === false) { + throw new \RuntimeException('Failed to generate EC keypair: ' . openssl_error_string()); + } + + $details = openssl_pkey_get_details($priv); + + return ['priv' => $priv, 'pub_pem' => $details['key']]; + } + + /** + * Decrypt the encrypted_private_key envelope from the reveal endpoint. + * + * Performs ECDH(client_priv, server_ephemeral_pub) + HKDF-SHA256 + AES-256-GCM. + * Must match the server-side encryption parameters exactly. + * + * @param array $envelope The encrypted_private_key map (alg/ephemeral_public_key/iv/ciphertext/tag). + * @param mixed $privKey The caller's private key (OpenSSL key resource or OpenSSLAsymmetricKey on PHP 8+). + * @return string The plaintext SmartTap private key PEM. + * @throws \RuntimeException on bad envelope or auth-tag verification failure. + */ + public static function decryptEnvelope(array $envelope, $privKey): string + { + $serverPub = openssl_pkey_get_public($envelope['ephemeral_public_key'] ?? ''); + if ($serverPub === false) { + throw new \RuntimeException('Invalid ephemeral_public_key in envelope'); + } + + // Natural-length shared secret (32 bytes / P-256 X coord). The third + // arg was deprecated in PHP 8.4 as either ignored or truncating. + $sharedSecret = openssl_pkey_derive($serverPub, $privKey); + if ($sharedSecret === false) { + throw new \RuntimeException('ECDH derivation failed: ' . openssl_error_string()); + } + + $aesKey = hash_hkdf('sha256', $sharedSecret, self::KEY_LEN, self::HKDF_INFO, ''); + + $iv = base64_decode($envelope['iv'] ?? '', true); + $ciphertext = base64_decode($envelope['ciphertext'] ?? '', true); + $tag = base64_decode($envelope['tag'] ?? '', true); + if ($iv === false || $ciphertext === false || $tag === false) { + throw new \RuntimeException('Envelope iv/ciphertext/tag must be base64-encoded'); + } + + $plaintext = openssl_decrypt( + $ciphertext, + 'aes-256-gcm', + $aesKey, + OPENSSL_RAW_DATA, + $iv, + $tag, + '' + ); + + if ($plaintext === false) { + throw new \RuntimeException('AES-GCM decryption failed (auth tag verification)'); + } + + return $plaintext; + } +} diff --git a/src/Models/RevealTemplatePrivateKey.php b/src/Models/RevealTemplatePrivateKey.php new file mode 100644 index 0000000..015fbc0 --- /dev/null +++ b/src/Models/RevealTemplatePrivateKey.php @@ -0,0 +1,34 @@ +keyVersion = $data['key_version'] ?? null; + $this->collectorId = $data['collector_id'] ?? null; + $this->fingerprint = $data['fingerprint'] ?? null; + $this->privateKey = $data['private_key'] ?? null; + + $this->key_version = $this->keyVersion; + $this->collector_id = $this->collectorId; + $this->private_key = $this->privateKey; + } +} diff --git a/src/Services/Console.php b/src/Services/Console.php index e231364..e3ff331 100644 --- a/src/Services/Console.php +++ b/src/Services/Console.php @@ -9,6 +9,8 @@ use AccessGrid\Models\LandingPage; use AccessGrid\Models\CredentialProfile; use AccessGrid\Models\Webhook; +use AccessGrid\Models\RevealTemplatePrivateKey; +use AccessGrid\Crypto\SmartTapRevealCrypto; class Console { @@ -53,6 +55,40 @@ public function readTemplate(array $data): Template return new Template($this->client, $response); } + /** + * Reveal the SmartTap private key for a card template, decrypted client-side. + * + * The SDK generates a fresh ephemeral P-256 keypair per call, submits the + * public half, and decrypts the server's response. The returned + * RevealTemplatePrivateKey carries the plaintext PEM in `$privateKey`; + * the encrypted envelope is consumed internally and not exposed. + * + * @param string $templateId The card template ex_id (must be a published SmartTap template). + * @param array|null $keypairOverride Test/advanced opt-in: shape ['priv' => OpenSSL key, 'pub_pem' => PEM] + * to bypass internal keypair generation. Defaults to null (SDK generates). + */ + public function revealSmartTap(string $templateId, ?array $keypairOverride = null): RevealTemplatePrivateKey + { + $keypair = $keypairOverride ?? SmartTapRevealCrypto::generateKeypair(); + + $response = $this->client->post( + "/v1/console/card-templates/{$templateId}/smart-tap/reveal", + ['client_public_key' => $keypair['pub_pem']] + ); + + $plaintext = SmartTapRevealCrypto::decryptEnvelope( + $response['encrypted_private_key'], + $keypair['priv'] + ); + + return new RevealTemplatePrivateKey([ + 'key_version' => $response['key_version'] ?? null, + 'collector_id' => $response['collector_id'] ?? null, + 'fingerprint' => $response['fingerprint'] ?? null, + 'private_key' => $plaintext, + ]); + } + /** * Publish a card template */ diff --git a/tests/Crypto/SmartTapRevealCryptoTest.php b/tests/Crypto/SmartTapRevealCryptoTest.php new file mode 100644 index 0000000..8fe1718 --- /dev/null +++ b/tests/Crypto/SmartTapRevealCryptoTest.php @@ -0,0 +1,119 @@ + 'ECDH-ES+A256GCM', + 'ciphertext' => 'ckYyA3FdRYjOFI/FKz/QeR5Yf9nZZFzo73kDXKZSB/EgbQ==', + 'ephemeral_public_key' => "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7mg6i99GcIVutMPr/PXSBSQVlbLM\ntnJO10ZBjk9ZTfw6wwAVNBnDBiqY7VrdOG1JdFOYoac+NkAlyMRGYk2tVQ==\n-----END PUBLIC KEY-----\n", + 'iv' => '5X2OCht+kLB/xQmX', + 'tag' => '0vwkjVaCwi5zl37xvJPxeg==', + ]; + } + + private static function fixtureCallerPrivateKey() + { + // Heredoc preserved leading indentation; strip it before openssl reads. + $pem = preg_replace('/^ +/m', '', self::FIXTURE_CALLER_PRIVATE_KEY_PEM); + return openssl_pkey_get_private($pem); + } + + public function testDecryptsCapturedServerEnvelope(): void + { + $plaintext = SmartTapRevealCrypto::decryptEnvelope( + self::fixtureEnvelope(), + self::fixtureCallerPrivateKey() + ); + + $this->assertSame(self::FIXTURE_EXPECTED_PLAINTEXT, $plaintext); + } + + public function testTamperedTagFailsDecryption(): void + { + $envelope = self::fixtureEnvelope(); + $tag = base64_decode($envelope['tag']); + $tag[0] = chr(ord($tag[0]) ^ 0x01); + $envelope['tag'] = base64_encode($tag); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('AES-GCM decryption failed'); + + SmartTapRevealCrypto::decryptEnvelope($envelope, self::fixtureCallerPrivateKey()); + } + + public function testWrongPrivateKeyFailsDecryption(): void + { + $wrong = openssl_pkey_new([ + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('AES-GCM decryption failed'); + + SmartTapRevealCrypto::decryptEnvelope(self::fixtureEnvelope(), $wrong); + } + + public function testMissingEphemeralPublicKeyThrows(): void + { + $envelope = self::fixtureEnvelope(); + unset($envelope['ephemeral_public_key']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid ephemeral_public_key'); + + SmartTapRevealCrypto::decryptEnvelope($envelope, self::fixtureCallerPrivateKey()); + } + + public function testNonBase64IvThrows(): void + { + $envelope = self::fixtureEnvelope(); + $envelope['iv'] = 'not!base64!'; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('base64'); + + SmartTapRevealCrypto::decryptEnvelope($envelope, self::fixtureCallerPrivateKey()); + } + + public function testGenerateKeypairProducesUsablePemAndKey(): void + { + $keypair = SmartTapRevealCrypto::generateKeypair(); + + $this->assertArrayHasKey('priv', $keypair); + $this->assertArrayHasKey('pub_pem', $keypair); + $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $keypair['pub_pem']); + } + + public function testGenerateKeypairReturnsDistinctKeys(): void + { + $a = SmartTapRevealCrypto::generateKeypair(); + $b = SmartTapRevealCrypto::generateKeypair(); + + $this->assertNotSame($a['pub_pem'], $b['pub_pem']); + } +} diff --git a/tests/Models/LedgerItemTest.php b/tests/Models/LedgerItemTest.php index e7b08bb..aac4480 100644 --- a/tests/Models/LedgerItemTest.php +++ b/tests/Models/LedgerItemTest.php @@ -31,7 +31,7 @@ public function testConstructionWithFullData(): void 'name' => 'Employee Badge', 'protocol' => 'desfire', 'platform' => 'apple', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', ], ], ]; @@ -56,7 +56,7 @@ public function testConstructionWithFullData(): void $this->assertEquals('Employee Badge', $item->accessPass->passTemplate->name); $this->assertEquals('desfire', $item->accessPass->passTemplate->protocol); $this->assertEquals('apple', $item->accessPass->passTemplate->platform); - $this->assertEquals('employee_badge', $item->accessPass->passTemplate->useCase); + $this->assertEquals('corporate_id', $item->accessPass->passTemplate->useCase); } public function testConstructionWithNullAccessPass(): void diff --git a/tests/Models/TemplateTest.php b/tests/Models/TemplateTest.php index dc69fc1..42e0fe7 100644 --- a/tests/Models/TemplateTest.php +++ b/tests/Models/TemplateTest.php @@ -13,7 +13,7 @@ public function testConstructionWithFullData(): void 'id' => 'tmpl_123', 'name' => 'Employee Badge', 'platform' => 'apple', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', 'protocol' => 'desfire', 'created_at' => '2025-01-01T00:00:00Z', 'last_published_at' => '2025-06-01T00:00:00Z', @@ -31,7 +31,7 @@ public function testConstructionWithFullData(): void $this->assertEquals('tmpl_123', $template->id); $this->assertEquals('Employee Badge', $template->name); $this->assertEquals('apple', $template->platform); - $this->assertEquals('employee_badge', $template->useCase); + $this->assertEquals('corporate_id', $template->useCase); $this->assertEquals('desfire', $template->protocol); $this->assertEquals('2025-01-01T00:00:00Z', $template->createdAt); $this->assertEquals('2025-06-01T00:00:00Z', $template->lastPublishedAt); @@ -44,7 +44,7 @@ public function testConstructionWithFullData(): void $this->assertEquals(['version' => '2.1'], $template->metadata); // snake_case aliases - $this->assertEquals('employee_badge', $template->use_case); + $this->assertEquals('corporate_id', $template->use_case); $this->assertEquals('2025-01-01T00:00:00Z', $template->created_at); $this->assertEquals('2025-06-01T00:00:00Z', $template->last_published_at); $this->assertEquals(100, $template->issued_keys_count); diff --git a/tests/Services/ConsoleTest.php b/tests/Services/ConsoleTest.php index 609b65a..7ea06a0 100644 --- a/tests/Services/ConsoleTest.php +++ b/tests/Services/ConsoleTest.php @@ -25,7 +25,7 @@ public function testCreateTemplate(): void $template = $this->client->console->createTemplate([ 'name' => 'Employee Badge', 'platform' => 'apple', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', 'protocol' => 'desfire', ]); @@ -58,7 +58,7 @@ public function testReadTemplate(): void 'name' => 'Employee Badge', 'platform' => 'apple', 'protocol' => 'desfire', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', 'created_at' => '2025-01-01T00:00:00Z', 'last_published_at' => '2025-06-01T00:00:00Z', 'issued_keys_count' => 100, @@ -75,7 +75,7 @@ public function testReadTemplate(): void $this->assertInstanceOf(Template::class, $template); $this->assertEquals('tmpl_123', $template->id); - $this->assertEquals('employee_badge', $template->useCase); + $this->assertEquals('corporate_id', $template->useCase); $this->assertEquals(100, $template->issuedKeysCount); $this->assertEquals(85, $template->activeKeysCount); $this->assertEquals('#FFFFFF', $template->styleSettings['background_color']); @@ -135,6 +135,50 @@ public function testPublishTemplateSignsCardTemplateIdPayload(): void $this->client->console->publishTemplate(['card_template_id' => 'tmpl_123']); } + public function testRevealSmartTapDecryptsServerEnvelope(): void + { + // Captured wire-compat fixture — same one used in SmartTapRevealCryptoTest. + $fixtureCallerPrivPem = "-----BEGIN EC PRIVATE KEY-----\n" . + "MHcCAQEEIIou+Kk08kWAjhi0WyIx+L2GrgStGBCPODlwKYKd5BydoAoGCCqGSM49\n" . + "AwEHoUQDQgAE+gnDxXJt1SBaCK8roKH8QvOa/ItdQUe85JIsUc6RvhD/udLaFtHY\n" . + "m+MnOmeSdVaKTPWudH0+iGbleB3kS7lYxQ==\n" . + "-----END EC PRIVATE KEY-----\n"; + + $fixtureEnvelope = [ + 'alg' => 'ECDH-ES+A256GCM', + 'ciphertext' => 'ckYyA3FdRYjOFI/FKz/QeR5Yf9nZZFzo73kDXKZSB/EgbQ==', + 'ephemeral_public_key' => "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7mg6i99GcIVutMPr/PXSBSQVlbLM\ntnJO10ZBjk9ZTfw6wwAVNBnDBiqY7VrdOG1JdFOYoac+NkAlyMRGYk2tVQ==\n-----END PUBLIC KEY-----\n", + 'iv' => '5X2OCht+kLB/xQmX', + 'tag' => '0vwkjVaCwi5zl37xvJPxeg==', + ]; + + $priv = openssl_pkey_get_private($fixtureCallerPrivPem); + $pubPem = openssl_pkey_get_details($priv)['key']; + + $this->expectRequest( + 'POST', + '/v1/console/card-templates/tmpl-42/smart-tap/reveal', + 200, + [ + 'key_version' => 'tmpl-42', + 'collector_id' => '12345678', + 'fingerprint' => 'sha256:deadbeef', + 'encrypted_private_key' => $fixtureEnvelope, + ] + ); + + $result = $this->client->console->revealSmartTap('tmpl-42', [ + 'priv' => $priv, + 'pub_pem' => $pubPem, + ]); + + $this->assertInstanceOf(\AccessGrid\Models\RevealTemplatePrivateKey::class, $result); + $this->assertSame('tmpl-42', $result->keyVersion); + $this->assertSame('12345678', $result->collectorId); + $this->assertSame('sha256:deadbeef', $result->fingerprint); + $this->assertSame('FIXTURE-PLAINTEXT-NOT-A-CREDENTIAL', $result->privateKey); + } + public function testCreateTemplateWithCredentialProfilesAndLandingPages(): void { $this->mockHttpClient @@ -430,7 +474,7 @@ public function testListLedgerItems(): void 'name' => 'Employee Badge', 'protocol' => 'desfire', 'platform' => 'apple', - 'use_case' => 'employee_badge', + 'use_case' => 'corporate_id', ], ], ],