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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 23 additions & 2 deletions docs/INTEGRATION_EVIDENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,39 @@ $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.

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.
43 changes: 33 additions & 10 deletions src/CentralClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

final class CentralClient
{
public const VERSION = 'v2.6.1';
public const VERSION = 'v2.8.0';

/** @var array<string, mixed>|null */
private ?array $jwks = null;
Expand Down Expand Up @@ -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 (
Expand All @@ -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;
}

Expand Down
79 changes: 79 additions & 0 deletions src/IntegrationVerificationChallenge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Novvor\CentralSdk;

use DateTimeImmutable;
use InvalidArgumentException;

/**
* A short-lived, one-time challenge issued by Central through the application's
* authenticated webhook channel. Applications must verify the webhook signature
* before constructing this value.
*/
final readonly class IntegrationVerificationChallenge
{
public function __construct(
public string $id,
public string $nonce,
public DateTimeImmutable $expiresAt,
) {
if (preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $this->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<string, mixed> $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];
}
}
54 changes: 53 additions & 1 deletion tests/IntegrationEvidenceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading