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
21 changes: 16 additions & 5 deletions .github/workflows/wasm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ on:
type: string
default: '1.2.2'
cosign-version:
description: 'Cosign version'
description: 'Cosign version (v3+ required: on v2.x, sign-blob needs --new-bundle-format)'
required: false
type: string
default: 'v2.4.1'
default: 'v3.1.2'

permissions:
contents: read
Expand Down Expand Up @@ -67,8 +67,19 @@ jobs:
"${{ inputs.image-ref }}:latest" \
"${{ inputs.wasm-file }}:application/wasm"

- name: Sign with cosign
# sign-blob, not sign: sigstore-java cannot yet parse cosign's DSSE payloads.
- name: Sign with cosign and attach the bundle
if: inputs.sign
run: |
cosign sign --yes "${{ inputs.image-ref }}:${{ inputs.version }}"
cosign sign --yes "${{ inputs.image-ref }}:latest"
BUNDLE="$(basename "${{ inputs.wasm-file }}").sigstore.json"

cosign sign-blob --yes \
--bundle "$BUNDLE" \
"${{ inputs.wasm-file }}"

for tag in "${{ inputs.version }}" latest; do
oras attach \
--artifact-type application/vnd.dev.sigstore.bundle.v0.3+json \
"${{ inputs.image-ref }}:${tag}" \
"$BUNDLE:application/vnd.dev.sigstore.bundle.v0.3+json"
done
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,23 +130,47 @@ Verification runs inline after fetch — no separate step. Configure on each mod
<imageRef>ghcr.io/roastedroot/sqlite4j-wasm:3.51.0</imageRef>
<outputFile>${project.build.directory}/wasm/libsqlite3.wasm</outputFile>
<sigstoreIssuer>https://token.actions.githubusercontent.com</sigstoreIssuer>
<sigstoreIdentity>https://github.com/roastedroot/*</sigstoreIdentity>
<sigstoreIdentity>https://github.com/OWNER/REPO/.github/workflows/wasm-publish.yml@refs/heads/main</sigstoreIdentity>
</module>
```

Uses [sigstore-java](https://github.com/sigstore/sigstore-java) keyless verification. Expects a `.sigstore.json` bundle alongside the artifact.
`sigstoreIssuer` and `sigstoreIdentity` are matched **exactly** — a glob such as `https://github.com/myorg/*` never matches, because the `*` is compared as a literal character. For patterns, use `sigstoreIssuerRegex` / `sigstoreIdentityRegex` instead:

```xml
<sigstoreIssuer>https://token.actions.githubusercontent.com</sigstoreIssuer>
<sigstoreIdentityRegex>https://github\.com/myorg/.*</sigstoreIdentityRegex>
```

Setting both the exact and the regex form of the same field is an error. Setting neither leaves that field unconstrained.

To read the right values off an existing signature, inspect the signing certificate in the bundle: the identity is its subject alternative name, and the issuer is the OIDC issuer extension (`1.3.6.1.4.1.57264.1.1`). An artifact signed locally rather than by CI has the issuer `https://github.com/login/oauth` and the signer's email address as its identity.

> **A reusable workflow's identity is shared.** For a job run through a reusable workflow, the certificate identity is the *reusable* workflow's ref — for the publisher below, `https://github.com/roastedroot/inlay/.github/workflows/wasm-publish.yml@refs/heads/main` — and it is the same for **every** repository that calls it. Pinning it proves the artifact was signed by something using that workflow, not that it came from the repository you expect. The calling repository appears only in other certificate extensions, which inlay does not currently check. If that distinction matters to you, sign from a workflow of your own instead.

Uses [sigstore-java](https://github.com/sigstore/sigstore-java) keyless verification. The bundle is discovered through OCI referrers on the resolved manifest digest — nothing needs to sit next to the artifact. If several sigstore bundles are attached, each is tried in a stable order and any one that verifies against the configured identity is accepted.

Verification binds to the digest recorded in `wkg.lock`, and the artifact is fetched by that digest, so re-pointing a tag cannot change what you get.

> **Sign with `cosign sign-blob`, not `cosign sign`.** `cosign sign` binds the signature to the OCI manifest digest inside a DSSE envelope, and sigstore-java (through 2.2.0) cannot parse those payloads: it requires an in-toto subject `name`, which is optional per the spec and which cosign omits. inlay reports this explicitly rather than failing obscurely. Once [sigstore-java](https://github.com/sigstore/sigstore-java) makes `InTotoPayload.Subject#getName` `@Nullable`, `cosign sign` will verify with no change to inlay.

## Publishing wasm to OCI

Requires [oras CLI](https://oras.land/docs/installation) and optionally [cosign](https://docs.sigstore.dev/cosign/system_config/installation/). Packages appear at `https://github.com/orgs/<org>/packages` — [set visibility to public](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility) after first push.
Requires [oras CLI](https://oras.land/docs/installation) and optionally [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) v3+ (on cosign v2.x, add `--new-bundle-format` to `sign-blob`). Packages appear at `https://github.com/orgs/<org>/packages` — [set visibility to public](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility) after first push.

```sh
echo $GHCR_TOKEN | oras login ghcr.io -u $GHCR_USER --password-stdin

oras push ghcr.io/roastedroot/sqlite4j-wasm:3.51.0 \
libsqlite3.wasm:application/wasm

cosign sign --yes ghcr.io/roastedroot/sqlite4j-wasm:3.51.0
cosign sign-blob --yes \
--bundle libsqlite3.wasm.sigstore.json \
libsqlite3.wasm

oras attach \
--artifact-type application/vnd.dev.sigstore.bundle.v0.3+json \
ghcr.io/roastedroot/sqlite4j-wasm:3.51.0 \
libsqlite3.wasm.sigstore.json:application/vnd.dev.sigstore.bundle.v0.3+json
```

### GitHub Actions — publisher
Expand Down
65 changes: 53 additions & 12 deletions core/src/main/java/io/roastedroot/inlay/InlayClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
import land.oras.ArtifactType;
import land.oras.ContainerRef;
Expand Down Expand Up @@ -64,7 +66,8 @@ public Path pullByDigest(String imageRef, String digest, Path outputFile) {
}
}

ContainerRef containerRef = ContainerRef.parse(imageRef);
// Pull by digest, not by the mutable tag in imageRef.
ContainerRef containerRef = ContainerRef.parse(imageRef).withDigest(digest);

try {
Path tempDir = Files.createTempDirectory("inlay-pull-");
Expand Down Expand Up @@ -96,43 +99,81 @@ public Path pullByDigest(String imageRef, String digest, Path outputFile) {
}

public Path fetchSigstoreBundle(String imageRef, String digest, Path bundlePath) {
if (bundlePath == null) {
throw new InlayException("bundlePath must not be null");
}
List<byte[]> bundles = fetchSigstoreBundleBlobs(imageRef, digest);
if (bundles.isEmpty()) {
return null;
}
return writeBundle(bundles.get(0), bundlePath);
}

/** Every attached bundle, ordered by referrer digest so verification is deterministic. */
public List<Path> fetchSigstoreBundles(String imageRef, String digest, Path targetDir) {
if (targetDir == null) {
throw new InlayException("targetDir must not be null");
}
List<byte[]> bundles = fetchSigstoreBundleBlobs(imageRef, digest);
List<Path> paths = new ArrayList<>();
for (int i = 0; i < bundles.size(); i++) {
paths.add(writeBundle(bundles.get(i), targetDir.resolve(i + ".sigstore.json")));
}
return paths;
}

private List<byte[]> fetchSigstoreBundleBlobs(String imageRef, String digest) {
if (imageRef == null || imageRef.isEmpty()) {
throw new InlayException("imageRef must not be null or empty");
}
if (digest == null || digest.isEmpty()) {
throw new InlayException("digest must not be null or empty");
}
if (bundlePath == null) {
throw new InlayException("bundlePath must not be null");
}
List<byte[]> bundles = new ArrayList<>();
try {
ContainerRef containerRef = ContainerRef.parse(imageRef);
ContainerRef digestRef = containerRef.withDigest(digest);
Referrers referrers =
registry.getReferrers(
digestRef, ArtifactType.from(Const.SIGSTORE_BUNDLE_MEDIA_TYPE));

List<ManifestDescriptor> matching = new ArrayList<>();
for (ManifestDescriptor referrer : referrers.getManifests()) {
if (!Const.SIGSTORE_BUNDLE_MEDIA_TYPE.equals(referrer.getArtifactType())) {
continue;
if (Const.SIGSTORE_BUNDLE_MEDIA_TYPE.equals(referrer.getArtifactType())) {
matching.add(referrer);
}
}
matching.sort(Comparator.comparing(ManifestDescriptor::getDigest));

for (ManifestDescriptor referrer : matching) {
Descriptor descriptor =
registry.getDescriptor(digestRef.withDigest(referrer.getDigest()));
Manifest signatureManifest = Manifest.fromJson(descriptor.getJson());
for (Layer layer : signatureManifest.getLayers()) {
if (Const.SIGSTORE_BUNDLE_MEDIA_TYPE.equals(layer.getMediaType())) {
byte[] bundle = registry.getBlob(digestRef.withDigest(layer.getDigest()));
Files.createDirectories(bundlePath.getParent());
Files.write(bundlePath, bundle);
return bundlePath;
bundles.add(registry.getBlob(digestRef.withDigest(layer.getDigest())));
}
}
}
} catch (InlayException e) {
throw e;
} catch (RuntimeException | IOException e) {
} catch (RuntimeException e) {
throw new InlayException("Failed to fetch sigstore bundle for: " + imageRef, e);
}
return null;
return bundles;
}

private static Path writeBundle(byte[] bundle, Path bundlePath) {
try {
Path parent = bundlePath.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.write(bundlePath, bundle);
return bundlePath;
} catch (IOException e) {
throw new InlayException("Failed to write sigstore bundle: " + bundlePath, e);
}
}

public String getDigest(String imageRef) {
Expand Down
102 changes: 102 additions & 0 deletions core/src/test/java/io/roastedroot/inlay/InlayClientIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import land.oras.ArtifactType;
import land.oras.ContainerRef;
import land.oras.Descriptor;
import land.oras.Layer;
import land.oras.LocalPath;
import land.oras.Manifest;
import land.oras.ManifestDescriptor;
import land.oras.Registry;
import land.oras.Subject;
import land.oras.utils.Const;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -162,6 +173,97 @@ void lockFileRoundTripWithRealDigest() throws Exception {
assertEquals(digest, pkg.getDigest());
}

@Test
void pullByDigestPrefersTheDigestOverTheTag() throws Exception {
byte[] pinned = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00};
byte[] other = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x42};

String pinnedRef = pushTaggedWasm("digest-pin", "pinned", pinned);
String otherRef = pushTaggedWasm("digest-pin", "other", other);

InlayClient client = InlayClient.builder().insecure().noCache().build();
String pinnedDigest = client.getDigest(pinnedRef);
assertNotEquals(pinnedDigest, client.getDigest(otherRef));

// Ask for the other tag while pinning the first digest: the digest must win.
Path outputFile = tempDir.resolve("pinned.wasm");
client.pullByDigest(otherRef, pinnedDigest, outputFile);

assertArrayEquals(pinned, Files.readAllBytes(outputFile));
}

private String pushTaggedWasm(String name, String tag, byte[] content) throws Exception {
Path wasmFile = tempDir.resolve(name + "-" + tag + ".wasm");
Files.write(wasmFile, content);
String ref = registryUrl + "/test/" + name + ":" + tag;
insecureRegistry()
.pushArtifact(ContainerRef.parse(ref), LocalPath.of(wasmFile, "application/wasm"));
return ref;
}

@Test
void fetchSigstoreBundlesReturnsEveryAttachedBundleInStableOrder() throws Exception {
byte[] content = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00};
String ref = pushTestWasm("multi-bundle", content);

InlayClient client = InlayClient.builder().insecure().noCache().build();
String digest = client.getDigest(ref);

byte[] first = "{\"bundle\":\"one\"}".getBytes(StandardCharsets.UTF_8);
byte[] second = "{\"bundle\":\"two\"}".getBytes(StandardCharsets.UTF_8);
attachSigstoreBundle(ref, digest, first);
attachSigstoreBundle(ref, digest, second);

List<Path> bundles = client.fetchSigstoreBundles(ref, digest, tempDir.resolve("bundles"));
assertEquals(2, bundles.size());

List<String> contents = new ArrayList<>();
for (Path bundle : bundles) {
contents.add(Files.readString(bundle));
}
assertTrue(contents.contains(new String(first, StandardCharsets.UTF_8)));
assertTrue(contents.contains(new String(second, StandardCharsets.UTF_8)));

List<Path> again = client.fetchSigstoreBundles(ref, digest, tempDir.resolve("bundles2"));
List<String> againContents = new ArrayList<>();
for (Path bundle : again) {
againContents.add(Files.readString(bundle));
}
assertEquals(contents, againContents);
}

private void attachSigstoreBundle(String ref, String subjectDigest, byte[] bundle) {
Registry registry = insecureRegistry();
ContainerRef containerRef = ContainerRef.parse(ref);

Layer layer =
registry.pushBlob(containerRef, bundle)
.withMediaType(Const.SIGSTORE_BUNDLE_MEDIA_TYPE);
Descriptor subject = registry.getDescriptor(containerRef);

Manifest manifest =
Manifest.empty()
.withArtifactType(ArtifactType.from(Const.SIGSTORE_BUNDLE_MEDIA_TYPE))
.withLayers(List.of(layer))
.withSubject(
Subject.of(
subject.getMediaType(), subjectDigest, subject.getSize()));

registry.pushManifest(
containerRef.withDigest(ManifestDescriptor.of(manifest).getDigest()), manifest);
}

@Test
void fetchSigstoreBundlesReturnsEmptyWhenNoBundleExists() throws Exception {
byte[] content = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00};
String ref = pushTestWasm("no-sig-multi", content);

InlayClient client = InlayClient.builder().insecure().noCache().build();
String digest = client.getDigest(ref);

assertTrue(client.fetchSigstoreBundles(ref, digest, tempDir.resolve("bundles")).isEmpty());
}

@Test
void fetchSigstoreBundleReturnsNullWhenNoBundleExists() throws Exception {
byte[] content = {0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00};
Expand Down
Loading
Loading