diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9269c..56fb663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.8.0 + +- Add typed, short-lived integration verification challenges delivered through signed Central webhooks. +- Allow applications to bind SDK evidence reports to a one-time challenge and validate Central's acknowledgement. + ## [2.7.0] - 2026-08-05 ### Security diff --git a/docs/INTEGRATION_EVIDENCE.md b/docs/INTEGRATION_EVIDENCE.md index 863b5fb..7b6f5e7 100644 --- a/docs/INTEGRATION_EVIDENCE.md +++ b/docs/INTEGRATION_EVIDENCE.md @@ -16,14 +16,14 @@ $evidence = IntegrationEvidence::fromManifest( $central->reportIntegration($evidence, $correlationId); ``` -The SDK sends the application key, a v2 contract marker, SDK package/version +The SDK sends the application key, its integration contract marker, SDK package/version and a deterministic SHA-256 manifest fingerprint. The request is signed using the configured application credential and has an `X-Correlation-ID`. Central marks an external application **SDK ready** only when all of the following are independently true: -1. The authenticated heartbeat reports this SDK with a compatible v2 contract. +1. The authenticated heartbeat reports an SDK package, version and contract allowed by Central's policy for that application. 2. The evidence is recent. 3. Central has a matching, active module catalogue for that application. 4. The application's base runtime configuration is healthy. @@ -31,3 +31,24 @@ following are independently true: Never put secrets, tenant information, module payloads, URLs containing credentials, or a caller-provided `ready=true` field in the evidence payload. An unaccepted or stale heartbeat must remain visibly pending in Central. + +## Active verification + +Central can request fresh proof through the application's signed webhook. The +application must validate the webhook signature and timestamp first, then bind +its evidence response to the short-lived one-time challenge: + +```php +use Novvor\CentralSdk\IntegrationVerificationChallenge; + +$challenge = IntegrationVerificationChallenge::fromWebhookPayload( + $verifiedWebhookPayload, + $configuredAppKey, +); + +$central->reportIntegration($evidence, $eventId, $challenge); +``` + +The challenge is application-bound, expires within minutes, cannot be reused, +and never substitutes for webhook authentication. Central independently checks +the reported SDK version and catalog fingerprint before recording success. diff --git a/src/CentralClient.php b/src/CentralClient.php index 8e599da..4a67940 100644 --- a/src/CentralClient.php +++ b/src/CentralClient.php @@ -21,7 +21,7 @@ final class CentralClient { - public const VERSION = 'v2.6.1'; + public const VERSION = 'v2.8.0'; /** @var array|null */ private ?array $jwks = null; @@ -189,21 +189,30 @@ public function exchangeLaunchCode(string $code, ?string $correlationId = null): * status: string, * app_key: string, * accepted_contract_version: string, - * integration_evidence_recorded: bool + * integration_evidence_recorded: bool, + * verification?: array{id:string,status:string,error_code:?string}|null * } */ - public function reportIntegration(IntegrationEvidence $evidence, ?string $correlationId = null): array - { + public function reportIntegration( + IntegrationEvidence $evidence, + ?string $correlationId = null, + ?IntegrationVerificationChallenge $challenge = null, + ): array { $correlationId = $this->correlationId($correlationId); + $requestPayload = [ + 'key' => $this->config->appId, + 'contract_version' => IntegrationEvidence::CONTRACT_VERSION, + 'integration' => ['central_sdk' => $evidence->toArray()], + ]; + if ($challenge !== null) { + $requestPayload['verification'] = $challenge->toArray(); + } + $payload = $this->json( 'POST', '/api/center/v1/apps/heartbeat', $correlationId, - [ - 'key' => $this->config->appId, - 'contract_version' => IntegrationEvidence::CONTRACT_VERSION, - 'integration' => ['central_sdk' => $evidence->toArray()], - ], + $requestPayload, ); if ( @@ -218,7 +227,21 @@ public function reportIntegration(IntegrationEvidence $evidence, ?string $correl ); } - /** @var array{status: string, app_key: string, accepted_contract_version: string, integration_evidence_recorded: bool} $payload */ + if ($challenge !== null) { + $verification = is_array($payload['verification'] ?? null) ? $payload['verification'] : []; + $status = (string) ($verification['status'] ?? ''); + if ( + ! hash_equals($challenge->id, (string) ($verification['id'] ?? '')) + || ! in_array($status, ['succeeded', 'failed', 'expired'], true) + ) { + throw new CentralProtocolException( + 'Central did not acknowledge the integration verification challenge.', + correlationId: $correlationId, + ); + } + } + + /** @var array{status: string, app_key: string, accepted_contract_version: string, integration_evidence_recorded: bool, verification?: array{id:string,status:string,error_code:?string}|null} $payload */ return $payload; } diff --git a/src/IntegrationVerificationChallenge.php b/src/IntegrationVerificationChallenge.php new file mode 100644 index 0000000..979af94 --- /dev/null +++ b/src/IntegrationVerificationChallenge.php @@ -0,0 +1,79 @@ +id) !== 1) { + throw new InvalidArgumentException('Integration verification ID must be a ULID.'); + } + if (preg_match('/^[A-Za-z0-9]{64,128}$/', $this->nonce) !== 1) { + throw new InvalidArgumentException('Integration verification nonce is invalid.'); + } + } + + /** @param array $payload */ + public static function fromWebhookPayload( + array $payload, + string $expectedAppKey, + ?DateTimeImmutable $now = null, + ): self { + $expectedAppKey = trim($expectedAppKey); + $event = trim((string) ($payload['event'] ?? '')); + $appKey = trim((string) ($payload['app_key'] ?? '')); + $verification = is_array($payload['verification'] ?? null) ? $payload['verification'] : []; + + if ( + $event !== 'integration.evidence.requested' + || $expectedAppKey === '' + || ! hash_equals($expectedAppKey, $appKey) + ) { + throw new InvalidArgumentException( + 'Webhook is not an integration verification challenge for this application.', + ); + } + + try { + $expiresAt = new DateTimeImmutable((string) ($verification['expires_at'] ?? '')); + } catch (\Throwable $exception) { + throw new InvalidArgumentException('Integration verification expiry is invalid.', previous: $exception); + } + + $now ??= new DateTimeImmutable(); + if ($expiresAt <= $now) { + throw new InvalidArgumentException('Integration verification challenge has expired.'); + } + if ($expiresAt > $now->modify('+15 minutes')) { + throw new InvalidArgumentException( + 'Integration verification expiry exceeds the supported security window.', + ); + } + + return new self( + trim((string) ($verification['id'] ?? '')), + trim((string) ($verification['nonce'] ?? '')), + $expiresAt, + ); + } + + /** @return array{id:string,nonce:string} */ + public function toArray(): array + { + return ['id' => $this->id, 'nonce' => $this->nonce]; + } +} diff --git a/tests/IntegrationEvidenceTest.php b/tests/IntegrationEvidenceTest.php index 5016993..cd75fe5 100644 --- a/tests/IntegrationEvidenceTest.php +++ b/tests/IntegrationEvidenceTest.php @@ -12,6 +12,7 @@ use Novvor\CentralSdk\Exception\CentralSnapshotValidationException; use Novvor\CentralSdk\EntitlementSnapshotContract; use Novvor\CentralSdk\IntegrationEvidence; +use Novvor\CentralSdk\IntegrationVerificationChallenge; use Novvor\CentralSdk\Tests\Support\QueueHttpClient; use PHPUnit\Framework\TestCase; @@ -34,7 +35,7 @@ public function test_it_reports_authenticated_versioned_integration_evidence(): self::assertTrue($response['integration_evidence_recorded']); self::assertSame('corr-integration-01', $http->requests[0]->getHeaderLine('X-Correlation-ID')); - self::assertSame('novvor-central-sdk-php/v2.6.1', $http->requests[0]->getHeaderLine('User-Agent')); + self::assertSame('novvor-central-sdk-php/v2.8.0', $http->requests[0]->getHeaderLine('User-Agent')); self::assertSame('app-secret', $http->requests[0]->getHeaderLine('X-Platform-App-Key')); $payload = json_decode((string) $http->requests[0]->getBody(), true, flags: JSON_THROW_ON_ERROR); @@ -43,6 +44,57 @@ public function test_it_reports_authenticated_versioned_integration_evidence(): self::assertSame('v2', $payload['integration']['central_sdk']['contract_version']); } + public function test_it_answers_a_signed_one_time_integration_challenge(): void + { + $id = '01K2B4Q3PA7TTG6C1R8K9M5N2X'; + $challenge = IntegrationVerificationChallenge::fromWebhookPayload([ + 'event' => 'integration.evidence.requested', + 'app_key' => 'enix_platform', + 'verification' => [ + 'id' => $id, + 'nonce' => str_repeat('A', 64), + 'expires_at' => '2026-08-14T15:05:00+00:00', + ], + ], 'enix_platform', new \DateTimeImmutable('2026-08-14T15:00:00+00:00')); + + $http = new QueueHttpClient(new Response(200, [], json_encode([ + 'status' => 'ok', + 'app_key' => 'enix_platform', + 'accepted_contract_version' => 'v2', + 'integration_evidence_recorded' => true, + 'verification' => ['id' => $id, 'status' => 'succeeded', 'error_code' => null], + ], JSON_THROW_ON_ERROR))); + $client = new CentralClient($http, new HttpFactory(), $this->configuration()); + + $response = $client->reportIntegration( + new IntegrationEvidence('2.8.0', str_repeat('b', 64)), + 'corr-challenge-01', + $challenge, + ); + + $verification = $response['verification'] ?? null; + self::assertIsArray($verification); + self::assertSame('succeeded', $verification['status']); + $payload = json_decode((string) $http->requests[0]->getBody(), true, flags: JSON_THROW_ON_ERROR); + self::assertSame($id, $payload['verification']['id']); + self::assertSame(str_repeat('A', 64), $payload['verification']['nonce']); + } + + public function test_it_rejects_expired_or_cross_application_challenges(): void + { + $this->expectException(InvalidArgumentException::class); + + IntegrationVerificationChallenge::fromWebhookPayload([ + 'event' => 'integration.evidence.requested', + 'app_key' => 'another_app', + 'verification' => [ + 'id' => '01K2B4Q3PA7TTG6C1R8K9M5N2X', + 'nonce' => str_repeat('A', 64), + 'expires_at' => '2026-08-14T15:05:00+00:00', + ], + ], 'enix_platform', new \DateTimeImmutable('2026-08-14T15:00:00+00:00')); + } + public function test_it_rejects_non_canonical_runtime_evidence(): void { $this->expectException(InvalidArgumentException::class);