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).
- Script:
scripts/check-secrets.ts - Negative tests:
scripts/test-check-secrets.ts - Allowlist:
.secret-allowlist
pnpm check:secrets # scan every tracked file
pnpm test:secrets # prove the guard catches planted secretsWhat 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,.woffor.woff2; pnpm-lock.yaml.
It reads each remaining file line by line.
What it detects
| Check | Pattern | Applies to allowlisted files? |
|---|---|---|
| Stellar secret seed | A word of S followed by 55 base-32 characters that StrKey.isValidEd25519SecretSeed accepts as a valid ed25519 seed | Yes |
| PEM private key | A line containing -----BEGIN … PRIVATE KEY----- | Yes |
| Retired demo auditor key | Any 64-character hex value whose bytes hash, under SHA-256, to the retired key's hash | Yes |
| Named hex secret | A 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, mnemonic | No |
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:
| Glob | Reason recorded |
|---|---|
evidence/round-*/challenge.json | Donor 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.
| Case | File content | Expected |
|---|---|---|
| Clean file passes | A random 32-byte hex value under the name keyXHex | Exit 0 |
| Stellar seed is caught | A freshly generated Stellar secret seed | Non-zero exit |
| Named hex secret is caught | A random 32-byte hex value under the name secretHex | Non-zero exit |
| PEM private key is caught | A PEM private-key header | Non-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: secretsThe 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 orpnpm-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.
Key handling
Which keys exist in a Tally round, who holds each one, where each lives today, the retired demo auditor key and the history rewrite, and the planned threshold custody of the auditor key.
Known limits
Every known limit of Tally, stated plainly: testnet only, unaudited, built on an untagged preview branch, a seven-day verification window, no users, and what the planned operator design cannot avoid.