Security

Secret guard

What scripts/check-secrets.ts detects, how the allowlist works, how the retired demo key is matched by hash, the negative tests, and how CI runs the guard before anything else.

The secret guard is a script that fails the build if a secret appears in any tracked file. It was added on 2026-10-05, after a demo auditor key had been committed in August (see Key handling).

pnpm check:secrets    # scan every tracked file
pnpm test:secrets     # prove the guard catches planted secrets

What it scans

The guard lists files with git ls-files, so it scans only files git tracks. It skips:

  • everything under vendor/ (the pinned OpenZeppelin submodule);
  • binary files ending in .wasm, .bin, .png, .jpg, .ico, .woff or .woff2;
  • pnpm-lock.yaml.

It reads each remaining file line by line.

What it detects

CheckPatternApplies to allowlisted files?
Stellar secret seedA word of S followed by 55 base-32 characters that StrKey.isValidEd25519SecretSeed accepts as a valid ed25519 seedYes
PEM private keyA line containing -----BEGIN … PRIVATE KEY-----Yes
Retired demo auditor keyAny 64-character hex value whose bytes hash, under SHA-256, to the retired key's hashYes
Named hex secretA hex value of 64 or more characters assigned with : or = to a secret-looking name: any name containing secret or private, a name ending in _sk, or sk, seed, mnemonicNo

The seed check validates the checksum, so a random string that merely looks like a seed does not trigger it.

Matching the retired key by hash

The guard needs to recognise the retired demo auditor key without containing it. It stores only the SHA-256 hash of the key's bytes:

const RETIRED_SHA256 = new Set([
  // demo auditor secret once in demo/deployment.testnet.json (originally 83f3f8c) — retired
  "2038a1462bb366e2e2f04d80d5ffc8260ed94e364f2d5b319383461e7ae03e90",
]);

For every 64-character hex value on every line, with or without a 0x prefix, the guard hashes the decoded bytes and compares the result with this set. A match is reported as RETIRED demo auditor key. This check runs on allowlisted files too.

The allowlist

Some files carry hex values under secret-looking names on purpose. Each line of .secret-allowlist is a path glob followed by a reason:

# <path-glob> <reason>
  • An entry without a reason is an error. The guard throws instead of running.
  • * matches within one path segment and ** matches across segments.
  • An allowlisted file is exempt from the named-hex check only. It is still scanned for Stellar seeds, PEM keys and the retired key.

There is one entry today:

GlobReason recorded
evidence/round-*/challenge.jsonDonor challenge keys published deliberately so anyone can re-verify a demo round; they reveal no funder or recipient secret (see evidence/README.md)

The evidence README explains why the donor key is published.

Output

If anything is found, the guard prints each finding as file:line with its kind, tells the reader to move the secret out of the repository or allowlist the file with a reason, and exits with code 1. If nothing is found, it prints how many tracked files were clean and how many allowlist patterns applied, and exits with code 0.

Negative tests

A guard that never fires looks the same as a guard that works. scripts/test-check-secrets.ts checks that it fires. For each case it creates a throwaway git repository, writes one file, stages it, and runs the guard there. Fixtures are generated at runtime, so no secret is ever committed.

CaseFile contentExpected
Clean file passesA random 32-byte hex value under the name keyXHexExit 0
Stellar seed is caughtA freshly generated Stellar secret seedNon-zero exit
Named hex secret is caughtA random 32-byte hex value under the name secretHexNon-zero exit
PEM private key is caughtA PEM private-key headerNon-zero exit

The test suite exits with code 1 if any case gives the wrong result. It has no case for the retired-key match.

How CI runs it

.github/workflows/ci.yml runs on every push to main and on every pull request.

# abridged from .github/workflows/ci.yml
jobs:
  secrets:
    # Runs first and alone: a secret in a tracked file fails the build.
    steps:
      - run: pnpm check:secrets
      - run: pnpm test:secrets

  typescript:
    needs: secrets
  circuits:
    needs: secrets
  contracts:
    needs: secrets

The typescript, circuits and contracts jobs all declare needs: secrets. None of them starts unless the guard and its negative tests pass.

Locally, pnpm test also starts with check:secrets and test:secrets before the type check, conformance vectors, proving tests, retention tests and contract tests.

What it does not cover

  • It scans the files in the working tree that git tracks. It does not scan git history. The retired key was removed from history separately (history rewrite).
  • It does not scan vendor/, binary files or pnpm-lock.yaml.
  • A hex value under a name that does not look secret passes. The clean test case relies on this.
  • Untracked and gitignored files, such as demo/last-round.json, are not scanned. That file holds testnet lane keys and is kept out of git by .gitignore.