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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion ex.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/AccessGridClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class AccessGridClient
{
public const VERSION = '1.2.0';
public const VERSION = '1.3.0';

private string $accountId;
private string $secretKey;
Expand Down
88 changes: 88 additions & 0 deletions src/Crypto/SmartTapRevealCrypto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace AccessGrid\Crypto;

/**
* Internal crypto helpers for the SmartTap reveal flow.
*
* Driven by Console::revealSmartTap(); not part of the public API.
*
* @internal
*/
class SmartTapRevealCrypto
{
private const CURVE = 'prime256v1';
private const HKDF_INFO = 'accessgrid-smart-tap-reveal-v1';
private const KEY_LEN = 32;

/**
* Generate a fresh P-256 keypair for a reveal call.
*
* @return array Shaped: ['priv' => 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;
}
}
34 changes: 34 additions & 0 deletions src/Models/RevealTemplatePrivateKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace AccessGrid\Models;

/**
* Result of a SmartTap private key reveal.
*
* `privateKey` is the plaintext PEM, decrypted client-side by the SDK.
* The encrypted envelope is consumed internally and not exposed.
*/
class RevealTemplatePrivateKey
{
public ?string $keyVersion;
public ?string $collectorId;
public ?string $fingerprint;
public ?string $privateKey;

// snake_case aliases
public ?string $key_version;
public ?string $collector_id;
public ?string $private_key;

public function __construct(array $data)
{
$this->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;
}
}
36 changes: 36 additions & 0 deletions src/Services/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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
*/
Expand Down
119 changes: 119 additions & 0 deletions tests/Crypto/SmartTapRevealCryptoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace AccessGrid\Tests\Crypto;

use PHPUnit\Framework\TestCase;
use AccessGrid\Crypto\SmartTapRevealCrypto;

class SmartTapRevealCryptoTest extends TestCase
{
// Captured test vector: a real envelope produced by the server against a
// sentinel `smart_tap_key` value. Lets us verify the SDK's decrypt is
// wire-compatible without reproducing the server's encrypt in test code.
//
// The caller_private_key is ephemeral and single-use by design (the server
// rejects reuse on pubkey fingerprint), so committing it carries no
// credential risk.
private const FIXTURE_CALLER_PRIVATE_KEY_PEM = <<<PEM
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIou+Kk08kWAjhi0WyIx+L2GrgStGBCPODlwKYKd5BydoAoGCCqGSM49
AwEHoUQDQgAE+gnDxXJt1SBaCK8roKH8QvOa/ItdQUe85JIsUc6RvhD/udLaFtHY
m+MnOmeSdVaKTPWudH0+iGbleB3kS7lYxQ==
-----END EC PRIVATE KEY-----
PEM;

private const FIXTURE_EXPECTED_PLAINTEXT = 'FIXTURE-PLAINTEXT-NOT-A-CREDENTIAL';

private static function fixtureEnvelope(): array
{
return [
'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==',
];
}

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']);
}
}
4 changes: 2 additions & 2 deletions tests/Models/LedgerItemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function testConstructionWithFullData(): void
'name' => 'Employee Badge',
'protocol' => 'desfire',
'platform' => 'apple',
'use_case' => 'employee_badge',
'use_case' => 'corporate_id',
],
],
];
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/Models/TemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading