Skip to content

Commit

Permalink
Introduce TrustChainBag
Browse files Browse the repository at this point in the history
  • Loading branch information
cicnavi committed Dec 6, 2024
1 parent f091749 commit bd38a2c
Show file tree
Hide file tree
Showing 8 changed files with 277 additions and 31 deletions.
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ for given leaf entity (subject) and trusted anchors:

try {
/** @var \SimpleSAML\OpenID\Federation $federationTools */
/** @var \SimpleSAML\OpenID\Federation\TrustChain $trustChain */
$trustChain = $federationTools->trustChainResolver()->for(
/** @var \SimpleSAML\OpenID\Federation\TrustChainBag $trustChainBag */
$trustChainBag = $federationTools->trustChainResolver()->for(
'https://leaf-entity-id.example.org/', // Trust chain subject (leaf entity).
[
'https://trust-achor-id.example.org/', // List of valid trust anchors.
[
// List of valid trust anchors.
'https://trust-achor-id.example.org/',
'https://other-trust-achor-id.example.org/',
],
);
} catch (\Throwable $exception) {
Expand All @@ -105,8 +107,30 @@ try {

```

If the trust chain is successfully resolved, this will return an instance of `\SimpleSAML\OpenID\Federation\TrustChain`.
Otherwise, exception will be thrown.
If the trust chain is successfully resolved, this will return an instance of
`\SimpleSAML\OpenID\Federation\TrustChainBag`. Otherwise, exception will be thrown.
From the TrustChainBag you can get the TrustChain using several methods.

```php

// ...

try {
/** @var \SimpleSAML\OpenID\Federation\TrustChain $trustChain */
/** @var \SimpleSAML\OpenID\Federation\TrustChainBag $trustChainBag */
// Simply get the shortest available chain.
$trustChain = $trustChainBag->getShortest();
// Get the shortest chain, but take into account the Trust Anchor priority.
$trustChain = $trustChainBag->getShortestByTrustAnchorPriority(
'https://other-trust-achor-id.example.org/', // Get chain for this Trust Anchor even if the chain is longer.
'https://trust-achor-id.example.org/',
);
} catch (\Throwable $exception) {
$this->loggerService->error('Could not resolve trust chain: ' . $exception->getMessage())
return;
}

```

Once you have the Trust Chain, you can try and get the resolved metadata for particular entity type. Resolved metadata
means that all metadata policies from all intermediates have been successfully applied. Here is one example for trying
Expand Down
3 changes: 3 additions & 0 deletions src/Federation.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use SimpleSAML\OpenID\Federation\EntityStatementFetcher;
use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory;
use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory;
use SimpleSAML\OpenID\Federation\Factories\TrustChainBagFactory;
use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory;
use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory;
use SimpleSAML\OpenID\Federation\MetadataPolicyResolver;
Expand Down Expand Up @@ -66,6 +67,7 @@ public function __construct(
DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = new DateIntervalDecoratorFactory(),
CacheDecoratorFactory $cacheDecoratorFactory = new CacheDecoratorFactory(),
HttpClientDecoratorFactory $httpClientDecoratorFactory = new HttpClientDecoratorFactory(),
protected readonly TrustChainBagFactory $trustChainBagFactory = new TrustChainBagFactory(),
) {
$this->maxCacheDuration = $dateIntervalDecoratorFactory->build($maxCacheDuration);
$this->timestampValidationLeeway = $dateIntervalDecoratorFactory->build($timestampValidationLeeway);
Expand Down Expand Up @@ -110,6 +112,7 @@ public function trustChainResolver(): TrustChainResolver
return $this->trustChainResolver ??= new TrustChainResolver(
$this->entityStatementFetcher(),
$this->trustChainFactory(),
$this->trustChainBagFactory,
$this->maxCacheDuration,
$this->cacheDecorator,
$this->logger,
Expand Down
16 changes: 16 additions & 0 deletions src/Federation/Factories/TrustChainBagFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Federation\Factories;

use SimpleSAML\OpenID\Federation\TrustChain;
use SimpleSAML\OpenID\Federation\TrustChainBag;

class TrustChainBagFactory
{
public function build(TrustChain $trustChain, TrustChain ...$trustChains): TrustChainBag
{
return new TrustChainBag($trustChain, ...$trustChains);
}
}
13 changes: 13 additions & 0 deletions src/Federation/TrustChain.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@ public function getResolvedMetadata(EntityTypesEnum $entityTypeEnum): ?array
return $this->resolvedMetadata[$entityTypeEnum->value] ?? null;
}

/**
* Get resolved chain length.
*
* @return int
* @throws \SimpleSAML\OpenID\Exceptions\TrustChainException
*/
public function getResolvedLength(): int
{
$this->validateIsResolved();

return count($this->entities);
}

/**
* @throws \SimpleSAML\OpenID\Exceptions\TrustChainException
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
Expand Down
74 changes: 74 additions & 0 deletions src/Federation/TrustChainBag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Federation;

use SimpleSAML\OpenID\Exceptions\TrustChainException;

class TrustChainBag
{
/** @var \SimpleSAML\OpenID\Federation\TrustChain[] */
protected array $trustChains;

public function __construct(TrustChain $trustChain, TrustChain ...$trustChains)
{
$this->add($trustChain, ...$trustChains);
}

public function add(TrustChain $trustChain, TrustChain ...$trustChains): void
{
$this->trustChains[] = $trustChain;
$this->trustChains += $trustChains;

// Order the chains from shortest to longest one.
usort($this->trustChains, function (TrustChain $a, TrustChain $b) {
return $a->getResolvedLength() <=> $b->getResolvedLength();
});
}

/**
* @throws \SimpleSAML\OpenID\Exceptions\TrustChainException
*/
public function getShortest(): TrustChain
{
($shortestChain = reset($this->trustChains)) || throw new TrustChainException('Invalid trust chain bag.');
return $shortestChain;
}

/**
* Get the shortest Trust Chain prioritized by Trust Anchor ID. Returns null if none of the given Trust Anchor IDs
* is found.
*
* @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException
* @throws \SimpleSAML\OpenID\Exceptions\TrustChainException
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
*/
public function getShortestByTrustAnchorPriority(string $trustAnchorId, string ...$trustAnchorIds): ?TrustChain
{
// Map of trust anchor identifiers to their order.
$prioritizedTrustAnchorIds = array_flip([$trustAnchorId, ...$trustAnchorIds]);

$prioritizedChains = $this->trustChains;

usort($prioritizedChains, function (TrustChain $a, TrustChain $b) use ($prioritizedTrustAnchorIds) {
// Get defined position, or default to high value if not found.
$posA = $prioritizedTrustAnchorIds[$a->getResolvedTrustAnchor()->getIssuer()] ?? PHP_INT_MAX;
$posB = $prioritizedTrustAnchorIds[$b->getResolvedTrustAnchor()->getIssuer()] ?? PHP_INT_MAX;

return $posA <=> $posB;
});

($prioritizedChain = reset($prioritizedChains)) || throw new TrustChainException('Invalid trust chain bag.');
return array_key_exists($prioritizedChain->getResolvedTrustAnchor()->getIssuer(), $prioritizedTrustAnchorIds) ?
$prioritizedChain : null;
}

/**
* @return \SimpleSAML\OpenID\Federation\TrustChain[]
*/
public function getAll(): array
{
return $this->trustChains;
}
}
59 changes: 34 additions & 25 deletions src/Federation/TrustChainResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use SimpleSAML\OpenID\Decorators\CacheDecorator;
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
use SimpleSAML\OpenID\Exceptions\TrustChainException;
use SimpleSAML\OpenID\Federation\Factories\TrustChainBagFactory;
use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory;
use Throwable;

Expand All @@ -20,6 +21,7 @@ class TrustChainResolver
public function __construct(
protected readonly EntityStatementFetcher $entityStatementFetcher,
protected readonly TrustChainFactory $trustChainFactory,
protected readonly TrustChainBagFactory $trustChainBagFactory,
protected readonly DateIntervalDecorator $maxCacheDuration,
protected readonly ?CacheDecorator $cacheDecorator = null,
protected readonly ?LoggerInterface $logger = null,
Expand All @@ -33,14 +35,14 @@ public function __construct(
/**
* @param non-empty-string $leafEntityId ID of the leaf (subject) entity for which to resolve the trust chain.
* @param non-empty-array<non-empty-string> $validTrustAnchorIds IDs of the valid trust anchors.
* @return \SimpleSAML\OpenID\Federation\TrustChain
* @return \SimpleSAML\OpenID\Federation\TrustChainBag
*
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
* @throws \SimpleSAML\OpenID\Exceptions\TrustChainException
* @throws \Psr\SimpleCache\InvalidArgumentException
*/
public function for(string $leafEntityId, array $validTrustAnchorIds): TrustChain
public function for(string $leafEntityId, array $validTrustAnchorIds): TrustChainBag
{
$this->validateStart($leafEntityId, $validTrustAnchorIds);

Expand Down Expand Up @@ -68,7 +70,7 @@ public function for(string $leafEntityId, array $validTrustAnchorIds): TrustChai
'Trust chain resolved from cache, returning.',
compact('leafEntityId', 'validTrustAnchorId'),
);
return $trustChain;
return $this->trustChainBagFactory->build($trustChain);
}
} catch (Throwable $exception) {
$this->logger?->warning(
Expand Down Expand Up @@ -128,33 +130,17 @@ public function for(string $leafEntityId, array $validTrustAnchorIds): TrustChai
}

$this->logger?->debug(
'Trust chains exist, finding the shortest one.',
'Trust chains exist, building its bag.',
compact('leafEntityId', 'validTrustAnchorIds'),
);

// Order the chains from shortest to longest one.
usort($resolvedChains, function (array $a, array $b) {
return count($a) - count($b);
});
($shortestChain = reset($resolvedChains)) || throw new TrustChainException('Invalid trust chain.');
$trustChain = $this->trustChainFactory->fromStatements(...$shortestChain);
$trustChainBag = $this->trustChainBagFactory->build($this->prepareTrustChain(array_pop($resolvedChains)));

$resolvedTrustAnchorId = $trustChain->getResolvedTrustAnchor()->getIssuer();
$chainTokens = $trustChain->jsonSerialize();

$this->logger?->debug(
'Trust chain has been resolved. Setting it in cache.',
compact('leafEntityId', 'resolvedTrustAnchorId', 'chainTokens'),
);

$this->cacheDecorator?->set(
$chainTokens,
$this->maxCacheDuration->lowestInSecondsComparedToExpirationTime($trustChain->getResolvedExpirationTime()),
$trustChain->getResolvedLeaf()->getIssuer(),
$resolvedTrustAnchorId,
);
while ($chainStatements = array_pop($resolvedChains)) {
$trustChainBag->add($this->prepareTrustChain($chainStatements));
}

return $trustChain;
return $trustChainBag;
}

/**
Expand Down Expand Up @@ -304,4 +290,27 @@ public function getMaxTrustChainDepth(): int
{
return $this->maxTrustChainDepth;
}

/**
* @param \SimpleSAML\OpenID\Federation\EntityStatement[] $chainStatements
* @throws \Psr\SimpleCache\InvalidArgumentException
* @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
* @throws \SimpleSAML\OpenID\Exceptions\TrustChainException
*/
public function prepareTrustChain(array $chainStatements): TrustChain
{
$trustChain = $this->trustChainFactory->fromStatements(...$chainStatements);
$resolvedTrustAnchorId = $trustChain->getResolvedTrustAnchor()->getIssuer();
$trustChainTokens = $trustChain->jsonSerialize();

$this->cacheDecorator?->set(
$trustChainTokens,
$this->maxCacheDuration->lowestInSecondsComparedToExpirationTime($trustChain->getResolvedExpirationTime()),
$trustChain->getResolvedLeaf()->getIssuer(),
$resolvedTrustAnchorId,
);

return $trustChain;
}
}
101 changes: 101 additions & 0 deletions tests/src/Federation/TrustChainBagTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\Test\OpenID\Federation;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use SimpleSAML\OpenID\Federation\EntityStatement;
use SimpleSAML\OpenID\Federation\TrustChain;
use SimpleSAML\OpenID\Federation\TrustChainBag;

#[CoversClass(TrustChainBag::class)]
class TrustChainBagTest extends TestCase
{
protected MockObject $trustChainMock;

protected function setUp(): void
{
$this->trustChainMock = $this->createMock(TrustChain::class);
}

protected function sut(
?TrustChain $trustChain = null,
): TrustChainBag {
$trustChain ??= $this->trustChainMock;

return new TrustChainBag($trustChain);
}

public function testCanCreateInstance(): void
{
$this->assertInstanceOf(TrustChainBag::class, $this->sut());
}

public function testCanAdd(): void
{
$sut = $this->sut();
$this->assertCount(1, $sut->getAll());
$sut->add($this->trustChainMock);
$this->assertCount(2, $sut->getAll());
}

public function testCanGetShortest(): void
{
$shortest = $this->createMock(TrustChain::class);
$shortest->method('getResolvedLength')->willReturn(2);
$mid = $this->createMock(TrustChain::class);
$mid->method('getResolvedLength')->willReturn(3);
$longest = $this->createMock(TrustChain::class);
$longest->method('getResolvedLength')->willReturn(4);

$sut = $this->sut($longest);

$this->assertCount(1, $sut->getAll());
$this->assertSame(4, $sut->getShortest()->getResolvedLength());

$sut->add($mid);
$this->assertCount(2, $sut->getAll());
$this->assertSame(3, $sut->getShortest()->getResolvedLength());

$sut->add($shortest);
$this->assertCount(3, $sut->getAll());
$this->assertSame(2, $sut->getShortest()->getResolvedLength());
}

public function testCanGetShortestByTrustAnchorPriority(): void
{
$trustAnchor1 = $this->createMock(EntityStatement::class);
$trustAnchor1->method('getIssuer')->willReturn('ta1');
$chain1 = $this->createMock(TrustChain::class);
$chain1->method('getResolvedTrustAnchor')->willReturn($trustAnchor1);
$chain1->method('getResolvedLength')->willReturn(2);

$trustAnchor2 = $this->createMock(EntityStatement::class);
$trustAnchor2->method('getIssuer')->willReturn('ta2');
$chain2 = $this->createMock(TrustChain::class);
$chain2->method('getResolvedTrustAnchor')->willReturn($trustAnchor2);
$chain2->method('getResolvedLength')->willReturn(3);

$sut = $this->sut($chain2);
$sut->add($chain1);

$this->assertSame($chain1, $sut->getShortestByTrustAnchorPriority('ta1'));
$this->assertSame($sut->getShortestByTrustAnchorPriority('ta1', 'ta2')
->getResolvedTrustAnchor()->getIssuer(), 'ta1');

Check warning on line 87 in tests/src/Federation/TrustChainBagTest.php

View workflow job for this annotation

GitHub Actions / Quality control

PossiblyNullReference

tests/src/Federation/TrustChainBagTest.php:87:15: PossiblyNullReference: Cannot call method getResolvedTrustAnchor on possibly null value (see https://psalm.dev/083)

$this->assertSame($chain2, $sut->getShortestByTrustAnchorPriority('ta2'));
$this->assertSame($sut->getShortestByTrustAnchorPriority('ta2', 'ta1')
->getResolvedTrustAnchor()->getIssuer(), 'ta2');

Check warning on line 91 in tests/src/Federation/TrustChainBagTest.php

View workflow job for this annotation

GitHub Actions / Quality control

PossiblyNullReference

tests/src/Federation/TrustChainBagTest.php:91:15: PossiblyNullReference: Cannot call method getResolvedTrustAnchor on possibly null value (see https://psalm.dev/083)

// Can get chain even if some trust anchors are unknown.
$this->assertSame($chain2, $sut->getShortestByTrustAnchorPriority('unknown', 'ta2'));
$this->assertSame($sut->getShortestByTrustAnchorPriority('unknown', 'ta2', 'ta1')
->getResolvedTrustAnchor()->getIssuer(), 'ta2');

Check warning on line 96 in tests/src/Federation/TrustChainBagTest.php

View workflow job for this annotation

GitHub Actions / Quality control

PossiblyNullReference

tests/src/Federation/TrustChainBagTest.php:96:15: PossiblyNullReference: Cannot call method getResolvedTrustAnchor on possibly null value (see https://psalm.dev/083)

// Returns null if Trust Anchor is unknown.
$this->assertNull($sut->getShortestByTrustAnchorPriority('unknown'));
}
}
Loading

0 comments on commit bd38a2c

Please sign in to comment.