> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroset.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> Verifying Nebula Enterprise bundle integrity, service authentication, and per-image Sigstore build provenance

## Service authentication

Nebula accepts API keys and bearer JWTs through the standard `Authorization` header:

```sh theme={null}
Authorization: Bearer <token>
```

Enterprise deployments sign Nebula access tokens with RS256 and publish the active public keys through JWKS. This lets internal services and customer-controlled verifiers validate access tokens without sharing Nebula's symmetric server secret.

| Setting                               | Purpose                                                                                                                                                                                      |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEBULA_JWT_KID`                      | Key ID written into the JWT header as `kid`. Use a stable, deployment-scoped value such as `nebula-2026-05`.                                                                                 |
| `NEBULA_JWT_PRIVATE_KEY_PEM`          | Active RSA private key used to sign access tokens. Store this only in your deployment secret manager.                                                                                        |
| `NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON` | JSON array of retired public keys kept valid during a rotation overlap window.                                                                                                               |
| `NEBULA_ACCESS_LIFE_IN_MINUTES`       | Access-token lifetime. Shipped Enterprise configs set `60` minutes. If the TOML config omits `access_token_lifetime_in_minutes`, this env var overrides the code fallback of `3600` minutes. |
| `NEBULA_REFRESH_LIFE_IN_DAYS`         | Refresh-token lifetime. Defaults to `7` days if unset.                                                                                                                                       |

Access tokens are JWTs with:

* `alg: RS256`
* `kid: <NEBULA_JWT_KID>`
* `sub`: Nebula user UUID
* `email`: normalized user email
* `token_type: access`
* `exp`: token expiration

Refresh tokens and other internal tokens are server-internal and are not intended for external service verification.

### JWKS endpoint

The runtime exposes the public key set at:

```txt theme={null}
/.well-known/jwks.json
```

The endpoint is public and cacheable for one hour (`Cache-Control: public, max-age=3600`).

External verifiers should:

* Fetch keys from `/.well-known/jwks.json`
* Select the verification key by JWT `kid`
* Require `alg=RS256`
* Require and validate `exp`
* Reject HS256 access tokens
* Reject tokens with missing or unknown `kid`

### Key rotation

For a fresh Docker Compose deployment, `./enterprise/generate-secrets.sh` creates `NEBULA_JWT_KID` and the RSA-2048 private key used by the stack. For Kubernetes deployments, store the same values in your configured secret backend.

To rotate manually, generate a new RSA-2048 keypair and public key:

```sh theme={null}
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
  -out nebula-jwt-private.pem

openssl rsa -in nebula-jwt-private.pem -pubout \
  -out nebula-jwt-public.pem
```

Then:

1. Choose a new `NEBULA_JWT_KID`, for example `nebula-YYYY-MM`.
2. Add the new `NEBULA_JWT_KID` and private key contents as `NEBULA_JWT_PRIVATE_KEY_PEM` in your secret manager.
3. Move the previous public key into `NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON`.
4. Deploy the updated secrets to every Nebula runtime.
5. Keep retired public keys published for at least the access-token lifetime plus the JWKS cache lifetime and deployment skew.
6. Remove a retired key only after every token signed by that key must have expired.

`NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON` is an array of public keys:

```json theme={null}
[
  {
    "kid": "nebula-2026-04",
    "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
  }
]
```

<Note>
  Do not rotate by deleting the old public key immediately. Any service that cached the old JWKS, or any user still holding an unexpired access token signed by the old key, will receive authentication failures until the overlap window has elapsed.
</Note>

### Auth error semantics

| Status | Meaning                                                                                                   |
| ------ | --------------------------------------------------------------------------------------------------------- |
| `401`  | Credentials are missing, malformed, expired, signed by an unknown key, blacklisted, or otherwise invalid. |
| `403`  | Authentication succeeded, but the user is not authorized for the requested resource or operation.         |

## Bundle integrity

Every Nebula Enterprise bundle ships with three independent integrity layers:

1. **Bundle SHA256** — proves the bytes you downloaded match what we delivered (we publish the SHA of the delivered file, whether that's a `.tar.gz.gpg` from GPG-mode or a plain `.tar.gz` from clear-channel mode).
2. **Bundle decryption** — restores the original tarball using the GPG public key you provided at onboarding (we encrypt to that key). This gives confidentiality and proves possession of the agreed key material; sender authenticity ultimately rests on the integrity of that out-of-band key exchange combined with the SHA256 in (1), not on decryption alone.
3. **Per-image Sigstore attestations** — prove the Nebula runtime, graph-engine, and bundled Postgres images were built by our pinned image-build workflows on `zeroset-inc/nebula`, signed by GitHub's Sigstore identity. Workflow-level pinning requires `--signer-workflow` (see below); `--repo` alone scopes only to repository identity.

Step (1) is always required. Step (2) is required for GPG-encrypted deliveries; clear-channel deliveries skip it (integrity rests on (1) alone, and we only use that mode when the delivery URL is itself authenticated). Step (3) is optional and intended for security review / audit pipelines.

## 1. Bundle SHA256

The delivery email includes the SHA256 of the file you downloaded. The filename's suffix depends on the delivery mode we picked:

* GPG asymmetric: `.tar.gz.gpg`
* Clear-channel: `.tar.gz`

Verify before decryption:

```sh theme={null}
echo "<sha256-from-email>  <delivered-file>" | sha256sum -c
```

A mismatch means the bytes were tampered with in transit or storage; stop and contact us.

## 2. Decryption

Two delivery modes, picked per-customer by us at ship time:

<Tabs>
  <Tab title="GPG asymmetric">
    Email includes a `.tar.gz.gpg` file. Decrypt with the GPG private key matching the public key you provided us at onboarding:

    ```sh theme={null}
    gpg --decrypt nebula-enterprise-<version>-<customer>.tar.gz.gpg \
      > nebula-enterprise-<version>.tar.gz
    ```

    Possession of the decryption key proves the bundle was encrypted *to you* — no third party can read it.
  </Tab>

  <Tab title="Clear-channel">
    Email includes a `.tar.gz` directly. No decryption step; rely on the SHA256 check above for integrity. We only use this mode when the delivery URL is itself authenticated (e.g. a customer-controlled S3 with short-lived presigned URL).
  </Tab>
</Tabs>

## 3. Per-image build provenance (optional)

The bundle ships an `attestations/` directory:

| File                                  | Purpose                                                                                                                  |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `attestations/<image>.sigstore.jsonl` | Sigstore attestation bundle (SLSA build provenance) for one image, written by GitHub's `actions/attest-build-provenance` |
| `attestations/manifest.txt`           | Maps bundle filename → image digest → original image tag                                                                 |
| `attestations/trusted_root.jsonl`     | Sigstore TUF root snapshotted at bundle-build time, so verification runs fully offline                                   |

The canonical verification path uses the `gh` CLI (recipe below). `cosign 2.x` can also verify these bundles for environments where `gh` isn't available — see the "Cosign 2.x" section below. Both tools verify against an OCI image reference at a specific digest — they do not consult the local Docker daemon. Source the image into a registry the verifier can reach: either re-push the image directly into your internal registry, or `docker load -i images.tar` followed by a re-tag and `docker push` to that registry. Then run the verify command against the registry reference (`<registry>/<image>@sha256:<digest>`).

### gh CLI

`--repo` scopes the cert-identity check to a repository; `--signer-workflow` additionally pins which workflow produced the attestation. Use both for the strictest check. The right `--signer-workflow` depends on which image you're verifying — `attestations/manifest.txt` maps each bundled file to its original image tag:

```sh theme={null}
# nebula runtime + migration images (signed by build-and-push-ecr.yml):
gh attestation verify \
  oci://<registry>/<image>@sha256:<digest> \
  --bundle attestations/<image>.sigstore.jsonl \
  --custom-trusted-root attestations/trusted_root.jsonl \
  --repo zeroset-inc/nebula \
  --signer-workflow zeroset-inc/nebula/.github/workflows/build-and-push-ecr.yml

# graph-engine images (signed by build-graph-engine-ecr.yml):
gh attestation verify \
  oci://<registry>/<image>@sha256:<digest> \
  --bundle attestations/<image>.sigstore.jsonl \
  --custom-trusted-root attestations/trusted_root.jsonl \
  --repo zeroset-inc/nebula \
  --signer-workflow zeroset-inc/nebula/.github/workflows/build-graph-engine-ecr.yml

# Postgres image (signed by build-postgres-ecr.yml):
gh attestation verify \
  oci://<registry>/<image>@sha256:<digest> \
  --bundle attestations/<image>.sigstore.jsonl \
  --custom-trusted-root attestations/trusted_root.jsonl \
  --repo zeroset-inc/nebula \
  --signer-workflow zeroset-inc/nebula/.github/workflows/build-postgres-ecr.yml
```

A successful run prints `Loaded digest sha256:... ... ✓ Verification succeeded!`. Failure exit codes are non-zero with a SAN / cert-claim diagnostic. Dropping `--signer-workflow` is safe but weakens the check to "some workflow in `zeroset-inc/nebula` signed this image" rather than "the expected build workflow signed this image".

### Cosign 2.x

Cosign's image-attestation flow expects the attestation to be discoverable as an OCI referrer alongside the image. Push the bundle to your internal registry as the image's referrer (one-time per image), then run `cosign verify-attestation` against the OCI reference. The exact `cosign attach attestation` and `cosign verify-attestation --type slsaprovenance` invocations are documented upstream:

* [`cosign attach attestation`](https://docs.sigstore.dev/cosign/verifying/attestation/#sign-and-attach-an-attestation) — push the bundled `attestations/<image>.sigstore.jsonl` as a referrer of the image.
* [`cosign verify-attestation`](https://docs.sigstore.dev/cosign/verifying/attestation/#verifying-an-attestation) — verify with the same `--certificate-identity-regexp` and `--certificate-oidc-issuer` pinning shown in the gh recipe above (regex: `^https://github\.com/zeroset-inc/nebula/\.github/workflows/build-(and-push|graph-engine|postgres)-ecr\.yml@`, issuer: `https://token.actions.githubusercontent.com`). Pass `--trusted-root attestations/trusted_root.jsonl` to keep verification fully offline.

<Note>
  We recommend `gh attestation verify` (above) over cosign for routine verification — it accepts the shipped bundle file directly via `--bundle <path>` against an OCI image reference in your registry, and skips cosign's separate `attach attestation` step.
</Note>

### Offline vs online verification

Both recipes above use `--custom-trusted-root` / `--trusted-root` against the bundled `trusted_root.jsonl`. Without that flag, the tool fetches Sigstore's live TUF root online — that's fine for connected environments but breaks for air-gapped audits. Always prefer the bundled root for reproducible verification.

## SBOM

Each bundle includes `sbom.spdx.json` — an SPDX 2.3 SBOM covering every container image. Suitable for ingestion by Snyk, Trivy, Grype, or any other SPDX-compatible scanner.

## Responsible disclosure

The bundle's `SECURITY.md` carries the current responsible-disclosure contact and supported-version policy. Vulnerabilities affecting current shipped versions get an out-of-band patched bundle on the standard CVE timeline; see `SECURITY.md` for the specifics.
