Skip to content

Security policy

This project handles payment-message construction and signing; we take vulnerability reports seriously and appreciate coordinated disclosure.

Do not open a public issue for security problems.

Preferred channel: GitHub private vulnerability reporting — use the “Report a vulnerability” button under the repository’s Security tab.

If you cannot use GitHub, email joaobuenosi@gmail.com with subject [SECURITY] fednow-oss.

What to include: affected crate and version/commit, reproduction steps or PoC, and impact assessment if you have one.

  • Acknowledgement within 72 hours.
  • A triage verdict (accepted / not a vulnerability / needs info) within 7 days.
  • Fix timeline agreed case by case; critical issues in released code get priority.
  • Credit in the release notes and a CVE requested via GitHub Security Advisories when applicable, unless you prefer to stay anonymous.
  • This project never ships with credentials, certificates or live endpoints; reports about the absence of those are out of scope.
  • The simulator (fednow-sim) is a development tool and is not hardened for exposure to untrusted networks; running it on the public internet is out of scope.

The gateway’s northbound REST API (fednow-gateway) requires Authorization: Bearer <key> on every route except the GET /healthz liveness probe. The gateway fails closed: with no key configured it refuses to start. It has no setting that turns authentication off. Keys come from the environment (FEDNOW_GW_API_KEYS for full access, FEDNOW_GW_READ_API_KEYS for read-only monitoring, FEDNOW_GW_OPERATOR_API_KEYS for the operators who release or cancel a payment the pre-send risk check held) and are never stored in this repository. The gateway keeps only their SHA-256 digests, compares in constant time, and never writes a key to a log line or a response. Missing or unknown keys get 401, and a key on a route outside its tier gets 403. A full-access key cannot release a held payment, so the application that submits payments cannot release its own holds. The details and the reasoning are in the gateway README.

The gateway does not terminate TLS. A bearer key sent over plain HTTP can be read by anyone on the path, so outside a single machine run the gateway behind a TLS-terminating proxy or service mesh (which is also where mTLS belongs).

Every release is built by a public GitHub Actions workflow (.github/workflows/release.yml) and ships with:

Asset What it is
fednow-oss-<tag>-x86_64-linux.tar.gz the binaries, LICENSE, README.md, CHANGELOG.md
fednow-oss-<tag>.cdx.json CycloneDX SBOM of the workspace, generated by cargo-cyclonedx from Cargo.lock
fednow-oss-<tag>.spdx.json SPDX SBOM of the same release
SHA256SUMS SHA-256 of the tarball and both SBOMs
<asset>.sigstore.json one Sigstore signature bundle per asset above
fednow-oss-<tag>.intoto.jsonl SLSA build provenance covering all four assets

Signing is keyless. There is no private key and no signing secret in this repository: the workflow exchanges its short-lived GitHub Actions OIDC token for an ephemeral certificate from Sigstore’s CA (Fulcio), signs with it, and records the signature in the public Rekor transparency log. The certificate binds the signature to this workflow, in this repository, at that tag — which is what you check when you verify.

The signature bundles are named <asset>.sigstore.json because that is the format they are in: since cosign v3.0, cosign sign-blob --bundle writes the standard Sigstore protobuf bundle, JSON-serialised, rather than the older cosign-specific envelope, and .sigstore.json is that format’s conventional extension.

v0.3.1 named the same files .cosign.bundle. The bytes were already the Sigstore bundle format; only the extension was non-standard. Releases from the next tag onwards use .sigstore.json. If you are verifying v0.3.1, substitute .cosign.bundle for .sigstore.json in every command below — nothing else changes.

Alongside the signatures, each release carries SLSA build provenance (fednow-oss-<tag>.intoto.jsonl), generated by actions/attest-build-provenance. The two are not redundant. A signature says this workflow, at this tag, signed these bytes. Provenance is a machine-readable in-toto statement saying these bytes were produced by this workflow run, from this commit, with these inputs — the thing a policy engine evaluates, rather than a human reading a certificate identity out of a command line.

Signing starts with v0.3.1, under the old .cosign.bundle name. Earlier releases ship no signature at all; there is nothing to verify on them, and this section does not claim otherwise. v0.3.0 is a special case: its release run failed in the SBOM step, before cosign ran, so that release carries no assets at all — not the tarball, not the SBOMs, not the checksums. Do not treat v0.3.0 as a release; use v0.3.1 or later. Build provenance starts with the first release after v0.3.1.

You need cosign v3 or newer — the version the release workflow signs with, and the one that reads the bundle format it writes. Nothing else: no key to fetch, no keyring to trust.

Download the asset you want and its .sigstore.json bundle, then run the following with TAG set to the release tag you downloaded:

Terminal window
$ TAG=v0.3.2 # the release you downloaded
$ cosign verify-blob \
--bundle "fednow-oss-$TAG-x86_64-linux.tar.gz.sigstore.json" \
--certificate-identity "https://github.com/joaoabuenosi/fednow-oss/.github/workflows/release.yml@refs/tags/$TAG" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"fednow-oss-$TAG-x86_64-linux.tar.gz"
Verified OK

Verified OK means: this exact byte sequence was signed by a run of this release workflow at that tag, and the signature is in the transparency log. Any other output is a failure — do not use the artifact.

The same command verifies the SBOMs and SHA256SUMS; swap both filenames:

Terminal window
$ cosign verify-blob \
--bundle "SHA256SUMS.sigstore.json" \
--certificate-identity "https://github.com/joaoabuenosi/fednow-oss/.github/workflows/release.yml@refs/tags/$TAG" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
SHA256SUMS

Two things worth being explicit about:

  • Do not verify with --certificate-identity-regexp and a permissive pattern. The point of the check is the exact workflow-and-tag identity above.
  • Checksums alone prove nothing about origin: SHA256SUMS is published through the same channel as the artifacts. Verify its signature, then check the sums against it.

Provenance answers a different question from the signature, and is checked with a different tool: the GitHub CLI, gh v2.49 or newer. No cosign and no bundle download — gh fetches the attestation from GitHub’s attestation API and checks it against the artifact you have in front of you:

Terminal window
$ gh attestation verify "fednow-oss-$TAG-x86_64-linux.tar.gz" \
--repo joaoabuenosi/fednow-oss \
--signer-workflow joaoabuenosi/fednow-oss/.github/workflows/release.yml
Loaded digest sha256:... for file://fednow-oss-...
✓ Verification succeeded!

--signer-workflow is the part that matters. Without it, --repo alone accepts an attestation signed by any workflow in this repository; with it, the statement must name the release workflow. It is the same claim --certificate-identity pins for cosign.

The provenance is also published as a release asset, so the check works with no network access to GitHub’s API — point gh at the downloaded .intoto.jsonl instead:

Terminal window
$ gh attestation verify "fednow-oss-$TAG-x86_64-linux.tar.gz" \
--bundle "fednow-oss-$TAG.intoto.jsonl" \
--repo joaoabuenosi/fednow-oss \
--signer-workflow joaoabuenosi/fednow-oss/.github/workflows/release.yml

One bundle covers all four assets — the tarball, both SBOMs and SHA256SUMS — so the same file verifies any of them; swap the filename in the first argument.

  • Third-party GitHub Actions are pinned to a commit SHA, not a tag — in every workflow, with a trailing # <version> comment recording which release the SHA stands for. A pin is reviewed by reading the pinned commit’s action.yml and confirming it accepts the inputs the workflow passes, not by re-resolving the version comment: some upstream tags (dtolnay/rust-toolchain’s v1, rustsec/audit-check’s v2) are moved as new versions ship, so the comment can drift from the tag while the pin itself stays exactly as intended.
  • Workflows declare permissions: {} at the top level; each job requests only the scopes it needs, and each scope carries a comment saying which call needs it.
  • Both of the above are enforced on every CI run, not just asserted here: .github/scripts/check-workflow-hardening.sh fails the build if any workflow gains a uses: that is not a 40-character commit SHA, or loses its top-level permissions: key.
  • Action pins are kept current by Dependabot, which bumps the SHA and rewrites the version comment with it (.github/dependabot.yml).
  • cargo audit runs on every lockfile change and daily (.github/workflows/audit.yml).
  • Static analysis: CodeQL runs on every pull request and every push to main, over all four languages in this repository — Rust, Python, Java and the GitHub Actions workflows themselves — with findings in the code scanning tab (.github/workflows/codeql.yml).
  • The ISO 20022 parsers and validators are fuzzed with cargo-fuzz: targets live in fuzz/fuzz_targets/, seeded from the repository’s own valid fixtures, and a nightly job runs each target time-boxed (.github/workflows/fuzz.yml). A crash is a bug report, not a CI annoyance: the corpus and the crashing input are uploaded as run artifacts.
  • OpenSSF Scorecard runs weekly and on pushes to main, publishes its results to the public OpenSSF API, and uploads findings to this repository’s code scanning tab (.github/workflows/scorecard.yml).
  • Container images are not yet published; when they are (see issue #64) they will be signed by digest with the same keyless flow.