Packages · Supply-chain security
k2gl/rekor-client
A PSR-18 client for the Rekor transparency log (v2 / rekor-tiles).
Submit and retrieve transparency-log entries with the HTTP client you already use.
Install
composer require k2gl/rekor-client Reach for it when
- You need to submit to or query a Rekor v2 transparency log directly.
- You want to bring your own PSR-18 HTTP client.
Look elsewhere when
- You want the full signing flow — sigstore-sign submits to Rekor for you.
- You need the legacy Rekor v1 REST API (this targets v2).
Rekor client for PHP
Submit entries to a Rekor transparency log from PHP — both the original
v1 REST log and
v2 (rekor-tiles) — and get back the
transparency-log entry Rekor integrated, the same value
k2gl/sigstore-bundle takes, so a signer goes
submit → add to bundle with no glue in between. And read a v2 log back: its signed
checkpoint, and any entry with an inclusion proof computed from the log's hash tiles.
Transport is any PSR-18 HTTP client you supply (Guzzle, Symfony HttpClient, …). This package speaks the Rekor API; it owns no socket.
Requirements
- PHP 8.1+
- A PSR-18 HTTP client and a PSR-17 factory (e.g.
nyholm/psr7+symfony/http-client) k2gl/sigstore-bundleandk2gl/signed-note(checkpoints)
Installation
composer require k2gl/rekor-client
Usage
use K2gl\RekorClient\RekorApiVersion;
use K2gl\RekorClient\RekorClient;
use K2gl\RekorClient\Verifier;
use K2gl\RekorClient\KeyDetails;
$rekor = new RekorClient(
httpClient: $psr18Client,
requestFactory: $psr17Factory,
streamFactory: $psr17Factory,
baseUrl: 'https://rekor.sigstore.dev',
apiVersion: RekorApiVersion::V1,
);
// A hashedrekord entry: the artifact digest, the signature, and the key or
// certificate that signed it.
$entry = $rekor->submitHashedRekord(
digest: $artifactSha256, // raw 32-byte digest
signature: $rawSignature,
verifier: Verifier::certificate($fulcioLeafDer, KeyDetails::PKIX_ECDSA_P256_SHA_256),
);
// $entry is a K2gl\SigstoreBundle\TransparencyLogEntry — drop it straight in:
$json = BundleBuilder::forMessageSignature($messageSignature)
->withCertificate($fulcioLeafDer)
->addTransparencyLogEntry($entry)
->toJson();
Which log version
Take both the URL and the version from the log entry in Sigstore's signing config
rather than assuming — that is what majorApiVersion there is for, and
RekorApiVersion::from() accepts it directly. It matters: Sigstore's default signing
config still lists only rekor.sigstore.dev at major version 1, and the v2 logs live
in a separate, opt-in config.
The two differ in more than the path. v1 takes the verifier as PEM and the digest as hex, answers with a map keyed by entry UUID, hex-encodes proof hashes, and stamps every entry with an integrated time and a signed entry timestamp. v2 takes raw DER and base64, answers with the entry itself, and has no per-entry time — a v2 bundle needs an RFC 3161 timestamp to be verifiable. All of that is handled here; the only choice you make is the version.
A v1 submission takes a SHA-256, SHA-384 or SHA-512 digest (hashedrekord 0.0.1 names the
algorithm, and it is read from the digest length).
DSSE attestations
This client submits hashedrekord entries. For a DSSE attestation, submit the PAE digest and the envelope signature — the entry Rekor returns is the one a DSSE bundle carries.
Signing identity
Verifier::publicKey($der, $keyDetails)— a bare public key.Verifier::certificate($der, $keyDetails)— a Fulcio (keyless) certificate.
KeyDetails names the algorithm (PKIX_ECDSA_P256_SHA_256, PKIX_ED25519, …).
Retries and duplicates
A submission is tried again when the log answers with something that means "busy, come
back" — a transport failure, or 408, 429, 499, 500, 502, 503, 504 — backing
off exponentially with jitter, and waiting exactly as long as a Retry-After header asks.
Two extra attempts by default; pass retries: 0 to send once, or a sleeper closure to
control the waiting (tests do).
A duplicate is not retried, because the entry is already in the log. What that means
differs by version, and both are handled: v1 answers 409 with a Location for the
entry that is already there, so the client follows it and returns that entry — which is
exactly the case a retry runs into when the first attempt reached the log but its answer
did not come back. v2 answers 409 with the entry's index in x-log-index and has no
write-side endpoint to read it from, so the client reports the index; LogReader::entry()
below fetches it from the read path.
Reading the log
A Rekor v2 log is served as tlog-tiles: a signed checkpoint
(the log's head), hash tiles 256 wide at every level of the Merkle tree, and entry bundles
holding the entries themselves. LogReader puts those together into the same
TransparencyLogEntry a submission returns — with an inclusion proof against the current
checkpoint — so an entry can go into a bundle whether you submitted it or found it.
use K2gl\RekorClient\LogReader;
$reader = new LogReader(
httpClient: $psr18Client,
requestFactory: $psr17Factory,
baseUrl: 'https://log2025-1.rekor.sigstore.dev',
origin: 'log2025-1.rekor.sigstore.dev', // the checkpoint's first line
publicKeyDer: $logPublicKeyDer, // tlogs[].publicKey.rawBytes in the trusted root
);
$checkpoint = $reader->checkpoint(); // K2gl\SignedNote\Checkpoint: origin, treeSize, rootHash
$entry = $reader->entry(114108965); // TransparencyLogEntry with an inclusion proof
$entries = $reader->entries(from: 114108900, count: 100); // the same, in bulk
The reader takes the log's name and key — both from Sigstore's trusted root — and every read starts from a checkpoint verified with that key. A proof is computed from the tiles the entry's path runs through (a handful of requests, whatever the log's size) and checked to reproduce the checkpoint's root before the entry is handed out; a tile that does not fit the tree, a bundle with the wrong number of entries or a checkpoint of another log are errors, never a best effort. Entries are stamped with the log id the trusted root lists: for an Ed25519 log the note key hash over origin and key, otherwise SHA-256 of the DER key.
A log keeps growing while you read it, so the partial tile at its head can be replaced by
a wider one between the checkpoint and the tiles. The reader notices (the tile is gone),
takes a fresh checkpoint and goes once more; entries() proves every entry of a call
against one and the same checkpoint. Reading past the head is not an error for
entries() (it returns what is there), while entry() insists on its index.
The test suite reads a recorded slice of the public log — checkpoint, head bundle and the
tiles of one proof, taken by tests/fixtures/rekor-v2/record.php — with the log key from the
trusted root, and an in-memory log of 70 000 entries laid out the way a real one is.
Errors
Everything thrown implements K2gl\RekorClient\Exception\RekorClientException:
RekorRequestException (transport failed / request could not be built),
RekorResponseException (Rekor answered with an error status or an unparseable body,
with the HTTP statusCode), and InvalidArgumentException (bad input).
Scope
This package covers submission (the write path a signer needs) against both log
versions, and reading a v2 log through the tlog-tiles API. Reading a v1 log's REST API
is not covered. Verifying an entry already in a bundle — identity, signature, timestamps —
is what k2gl/sigstore-verify does.
Pull requests are always welcome
API
Public classes and methods, generated from the source.
K2gl\RekorClient\Exception\InvalidArgumentException class
K2gl\RekorClient\Exception\RekorClientException interface
K2gl\RekorClient\Exception\RekorRequestException class
K2gl\RekorClient\Exception\RekorResponseException class
__construct(string $message, public readonly ?int $statusCode = null)
K2gl\RekorClient\KeyDetails enum
K2gl\RekorClient\LogReader class
__construct( ClientInterface $httpClient, RequestFactoryInterface $requestFactory, string $baseUrl, private readonly string $origin, string $publicKeyDer, int $retries = Http::DEFAULT_RETRIES, ?Closure $sleeper = null, )logId(): stringcheckpoint(): Checkpointentry(int $logIndex): TransparencyLogEntryentries(int $from, int $count): array
K2gl\RekorClient\RekorApiVersion enum
K2gl\RekorClient\RekorClient class
__construct( ClientInterface $httpClient, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, string $baseUrl, private readonly RekorApiVersion $apiVersion = RekorApiVersion::V2, int $retries = self::DEFAULT_RETRIES, ?Closure $sleeper = null, )submitHashedRekord(string $digest, string $signature, Verifier $verifier): TransparencyLogEntry
K2gl\RekorClient\Verifier class
publicKey(string $der, KeyDetails $keyDetails): selfcertificate(string $der, KeyDetails $keyDetails): selftoArray(): arraypem(): string