PHP signs with Sigstore now
When k2gl/sigstore-verify first passed the
official conformance suite, it did so with skip-signing: true: PHP could check a
Sigstore bundle, but could not produce one. Every other language client on
docs.sigstore.dev does both, and for a client that is the line between “a
verifier” and “a client”. That line is behind us now.
k2gl/sigstore-sign signs an artifact or an
attestation, logs the entry to Rekor, gets an RFC 3161 timestamp, and assembles
the .sigstore.json bundle — and
sigstore-conformance v0.0.29
passes in full, signing included, against the live staging Fulcio, Rekor v2 and
timestamp authority.
The flow
Keyful signing, with a key you hold:
use K2gl\Dsse\EcdsaP256Signer;
use K2gl\RekorClient\{RekorClient, KeyDetails};
use K2gl\SigstoreSign\{SigstoreSigner, SigningKey, TsaClient};
$rekor = new RekorClient($psr18, $psr17, $psr17, 'https://rekor.sigstore.dev');
$tsa = new TsaClient($psr18, $psr17, $psr17, 'https://timestamp.sigstore.dev');
$key = SigningKey::publicKey(
signer: EcdsaP256Signer::fromPem($privateKeyPem, null),
publicKeyDer: $publicKeyDer,
keyDetails: KeyDetails::PKIX_ECDSA_P256_SHA_256,
hint: $hexSha256OfThePublicKey,
);
$bundleJson = new SigstoreSigner($rekor, $tsa)->signArtifact($artifact, $key)->toJson();
Keyless signing, the way CI does it — no long-lived key at all:
use K2gl\SigstoreSign\{AmbientCredentials, FulcioClient, FulcioSigningKey, SigstoreSigner};
$oidcToken = AmbientCredentials::githubActions($psr18, $psr17); // or ::gitlabCi()
$fulcio = new FulcioClient($psr18, $psr17, $psr17, 'https://fulcio.sigstore.dev');
$key = FulcioSigningKey::create($fulcio, $oidcToken); // ephemeral P-256 key + Fulcio certificate
$bundleJson = new SigstoreSigner($rekor, $tsa)->signArtifact($artifact, $key)->toJson();
FulcioSigningKey generates the ephemeral key, proves possession of it to
Fulcio by signing the token’s subject, and hands back the same SigningKey
type the keyful path uses, so nothing downstream changes. The bundle comes
from k2gl/sigstore-bundle, the log entry from
k2gl/rekor-client, the signature from
k2gl/dsse — each usable on its own.
Three things the conformance suite taught us
Passing the verification half had already shaped the family; passing the signing half surfaced a handful of interoperability bugs, each reproduced locally and pinned with a regression test. Three are worth passing on to anyone writing a client.
Proof of possession is over the email claim, not sub. For an email
identity, Fulcio expects the ephemeral key to sign the token’s email value;
signing sub, which is what a first reading of the flow suggests, yields a
certificate request Fulcio rejects.
Signatures in a bundle are DER. A DSSE signer naturally emits raw r||s
ECDSA signatures, the JOSE convention. Sigstore carries ASN.1 DER. The bundle
builder converts; the verifier accepts both, and tells them apart by length.
signedTimestamp is the whole response. The bundle field holds the full DER
TimeStampResponse — status info plus token — not the extracted token, which is
what the protobuf field name invites you to store.
And one rule rather than a bug: the timestamp is not optional with Rekor v2. A
v2 entry carries no integrated time, so without an RFC 3161 timestamp the bundle
logs and assembles but has no verifiable signing time, and a verifier will
reject it. Pass a TsaClient when you sign against v2; a v1 entry brings its
own time.
Where to sign
One more thing the suite does not test but production does: the default
Sigstore signing configuration, published as a TUF target next to the trusted
root, still lists only Rekor v1. Rekor v2 lives in a separate opt-in target.
A client that hard-codes a v2 URL cannot sign against the public instance by
default. SigningConfig reads the target and applies the spec’s selection rules
— validity window, supported API version, the ALL/ANY/EXACT selector — so
the client asks for what it supports and gets the right log:
$config = SigningConfig::fromJson($updater->downloadTarget($updater->getTargetInfo('signing_config.v0.2.json')));
$log = $config->rekorLog();
$rekor = new RekorClient($psr18, $psr17, $psr17, baseUrl: $log->url, apiVersion: RekorApiVersion::from($log->majorApiVersion));
rekor-client speaks both versions and, since 1.2.0, retries a submission the
log could not take — with the one refinement that makes retrying safe: a
duplicate is handled, not retried. Rekor answers a resubmission with 409; v1
points at the existing entry and the client follows it, v2 reports the index.
That is exactly the “entry landed, response got lost” case a retry exists for.
Shorter still
If your package lives on GitHub, none of the above is necessary to publish provenance:
- uses: k2gl/composer-attest-action@v1
The Action builds and attests the release tarball and the exact dist zipball
Composer installs, so k2gl/composer-attest on the
consumer’s side has something to verify — the walkthrough
covers that loop. sigstore-sign is for everything the Action is not: other
CI systems, other artifacts, your own keys, your own bundles.
composer require k2gl/sigstore-sign