English | 繁體中文
Nested LSVID (Lightweight SVID) chains for SPIFFE workload identity — sign, extend, and validate self-contained nested JWS tokens that carry an end-to-end cryptographic identity chain across service hops.
An LSVID is a compact, JWS-like token minted with a workload's X.509-SVID
private key and carrying the leaf certificate (DER) in its x5c header, so any
verifier holding the SPIFFE trust bundle can validate it without a network
round-trip. Each hop nests the token it received inside a new one it signs
with its own SVID, producing a verifiable chain L0 → L1 → L2 → … that records
exactly which workloads relayed a request and to whom.
Gateway mints L0 (iss=gateway, aud=worker)
Worker extends L1 (nested=L0, iss=worker, aud=worker)
Filter extends L2 (nested=L1, iss=worker, aud=order-service)
Order-Service verifies the whole chain L0 → L1 → L2
- Install
- Requirements
- Core components
- Quick start
- SVID readers
- Replay protection
- Coroutine-local context
- PSR-15 middleware
- Security model
- Token format
- Testing
- License
composer require sdpm-lab/php-lsvid| Requirement | Version |
|---|---|
| PHP | ^8.1 |
ext-openssl |
* |
ext-json |
* |
Optional (only for the PSR-15 middleware): psr/http-server-middleware,
psr/http-message, psr/http-factory.
| Class | Role |
|---|---|
LSVIDSigner |
Mint L0 (createBase) and extend to L1/L2/… (extend) using the current SVID key. |
LSVIDValidator |
Verify a raw token: signatures, x5c-vs-trust-bundle, temporal claims, chain continuity, trust-domain, replay. |
LSVID |
Immutable parsed token — chain(), issuer(), audience(), expiresAt(), and the raw compact string via ->raw. |
SvidReader (interface) |
Supplies the workload's current X.509-SVID material to signer/validator. |
FileSvidReader / WorkloadSvidReader |
Filesystem-backed and SPIRE-Workload-API-backed reader implementations. |
JtiReplayCache |
In-process jti replay guard. |
LSVIDContext |
Coroutine-local holder for the token currently being handled. |
Middleware\LSVIDMiddleware |
PSR-15 middleware that validates inbound X-LSVID headers. |
All classes live in the SDPMlab\LSVID\ namespace.
The signer and validator never cache key material — they read the current
primary X.509-SVID from an SvidReader on every operation, so SVID rotation is
transparent. The reader returns:
interface SvidReader
{
/**
* @return array{
* spiffe_id: string, trust_domain: string,
* cert_pem: string, key_pem: string, bundle_pem: string,
* hint: string, updated_at: int,
* }|null
*/
public function readX509Primary(): ?array;
}Point FileSvidReader at PEM files a SPIFFE watcher keeps fresh:
use SDPMlab\LSVID\FileSvidReader;
$reader = new FileSvidReader(
certPath: '/tmp/spiffe-shared/svid.pem',
keyPath: '/tmp/spiffe-shared/svid_key.pem',
bundlePath: '/tmp/spiffe-shared/bundle.pem',
);use SDPMlab\LSVID\LSVIDSigner;
$signer = new LSVIDSigner($reader); // defaultTtl 1800s, cert-expiry grace 60s
$l0 = $signer->createBase(
audience: 'spiffe://zt.local/php-worker', // required next-hop SPIFFE ID → aud
subject: 'spiffe://zt.local/php-gateway',// optional; defaults to signer SVID
extraClaims: ['route' => 'OrderCreateRequestedEvent'],
);
$rawToken = $l0->raw; // compact string to put on the wire$l1 = $signer->extend(
priorRawToken: $inboundRawToken, // the token this hop received
audience: 'spiffe://zt.local/order-service',
);
$next = $l1->raw;use SDPMlab\LSVID\LSVIDValidator;
$validator = new LSVIDValidator(
reader: $reader, // supplies the trust bundle
trustDomain: 'zt.local', // enforce spiffe://zt.local/ on every claim
);
$lsvid = $validator->validate(
$rawToken,
expectedAudience: 'spiffe://zt.local/order-service',
);
$lsvid->level(); // 2 → L0 + two extensions
$lsvid->issuer(); // outermost iss
$lsvid->chain(); // [L0, L1, L2] as LSVID objects, root-first
foreach ($lsvid->chain() as $lvl) {
printf("%s → %s\n", $lvl->issuer(), $lvl->audience());
}A validation failure throws LSVIDException.
FileSvidReader — reads cert, key, and bundle PEM files (e.g. the
shared-memory files a SPIFFE watcher rotates). Returns null if any file is
missing or empty.
WorkloadSvidReader — fetches the primary X.509-SVID directly from the SPIRE
Agent Workload API over its UDS. Works both inside and outside a coroutine (wraps
itself in an OpenSwoole scheduler when called from a non-coroutine scope):
use SDPMlab\LSVID\WorkloadSvidReader;
$reader = WorkloadSvidReader::fromWorkloadAPI(
'unix:/run/spire/sockets/agent.sock',
timeout: 10.0,
);Custom — implement SvidReader::readX509Primary() to source SVIDs from
anywhere (Vault, an in-memory cache, a test fixture). Keep the return shape
exactly as specified.
Pass a JtiReplayCache to the validator to reject any jti it has already seen:
use SDPMlab\LSVID\JtiReplayCache;
$cache = new JtiReplayCache(maxEntries: 4096);
$validator = new LSVIDValidator(
reader: $reader,
jtiCache: $cache,
trustDomain: 'zt.local',
);The cache garbage-collects expired entries by each token's exp, so it stays
bounded without an external store. It is per-process — for multi-worker
deployments, back replay detection with a shared store keyed on jti.
LSVIDContext stores the token currently being handled in coroutine-local
storage (auto-detecting OpenSwoole / Swow, falling back to a static in plain CLI
or PHPUnit), so concurrent requests in one worker never clobber each other:
use SDPMlab\LSVID\LSVIDContext;
LSVIDContext::set($inboundRawToken);
try {
// Anything downstream — e.g. an extend() call — reads the active token:
$current = LSVIDContext::current();
} finally {
LSVIDContext::clear();
}LSVIDMiddleware validates the inbound X-LSVID header and attaches the parsed
result to request attributes (lsvid, lsvid.issuer, lsvid.subject):
use SDPMlab\LSVID\Middleware\LSVIDMiddleware;
$app->add(new LSVIDMiddleware(
validator: $validator,
responseFactory: $responseFactory, // PSR-17 ResponseFactoryInterface
required: true, // 401 if the header is absent
expectedAudience: getenv('SPIFFE_ID'),
headerName: 'X-LSVID',
));A missing header yields 401 (when required), and a validation failure yields
403 with a JSON error body.
- Authority-controlled claims. The signer always sets
iss,sub,aud,iat,exp,nbf,jti, andnesteditself. Any caller-supplied value for these reserved keys is silently stripped — callers cannot spoof identity, lifetime, or replay identifiers.nbfis settable only via the explicitnotBeforeparameter. - Self-contained verification. Each level embeds its leaf certificate (DER)
in the JOSE
x5cheader; the validator verifies that leaf against the SPIFFE trust bundle and then checks the signature — no key distribution needed. - Rotation-safe. Signer and validator read the current SVID from the
SvidReaderper operation and never cache private keys across a rotation. The signer refuses to sign with a certificate withincertExpiryGraceSecondsof expiry (default 60s). - Chain continuity. Every extension level must nest a token whose
audequals the enclosing level'siss; a broken link throws. - Trust-domain pinning. With
trustDomainset, everyiss/sub/audon every level must begin withspiffe://{trustDomain}/. - Temporal checks.
iat/exp/nbfare enforced with a configurableclockSkewSeconds(default 30s);requireNbfmakesnbfmandatory. - Bounded parsing.
LSVID::parse()caps nesting depth (default 16) so a malformed or malicious token cannot exhaust the stack.
A raw LSVID is three base64url segments — header.payload.signature — like a
JWS compact serialization, with:
- Header:
typ: "LSVID",alg(ES256/RS256, derived from the SVID key), andx5ccarrying the signer's leaf certificate (DER). - Payload: reserved claims
iss,sub,aud,iat,exp,nbf,jti, plusnested(the prior level's full raw token) on L1+, plus any non-reservedextraClaims. - Signature: over
base64url(header) . "." . base64url(payload)using the SVID private key.
composer install
vendor/bin/phpunitThe suite (tests/LSVID/) covers signing, parsing, full validation, the
negative-matrix of tampered/expired/replayed/cross-domain tokens, the replay
cache, and coroutine-context isolation.
Apache-2.0 — Copyright 2026 SDPM Lab, National Kaohsiung Normal University.