- TypeScript 93.3%
- CSS 5.4%
- PLpgSQL 0.6%
- Dockerfile 0.4%
- Go Template 0.2%
|
All checks were successful
Build Image / build-image (push) Successful in 24s
Tagging now produces something installable. Before this, a tag got you images and a chart version and nothing else -- the CLI was compiled only inside the Dockerfile's demo-build stage and never published, so there was no way to put `aegis` on a machine, which is how secrets actually get consumed in a deploy. A tag push now also builds the CLI for linux-x64 (the only target asked for), creates the release through Forgejo's API, and attaches the binary plus a SHA256SUMS covering it. Branch pushes, pull requests and workflow_dispatch are untouched -- verified by running the real Compute tags script for all four shapes, where only refs/tags/* yields release=true. The binary is built with the bun the Dockerfile already pins by digest, reusing its deps stage, rather than a separately pinned download that could drift from what the app is built with. `--target=bun-linux-x64` is explicit even though the container is already x64, so an arm64 runner cannot silently publish an arm64 ELF under an x64 name. CLI gains --version, stamped at build time via `bun build --define`, so a downloaded binary can say what it is. A local `bun run build:cli` is unstamped and prints `aegis dev (unreleased local build)` rather than a fake 0.0.0. The handler runs before loadConfig, so --version needs no URL or token. API shapes were taken from this instance's own swagger.v1.json (v15.0.10), not from GitHub's API: the asset upload takes raw bytes with `name` as a QUERY parameter, not GitHub's upload_url. Every call states the statuses it accepts and fails loudly with method, path, status and body otherwise, because a release step that half-succeeds is worse than one that fails. The token reaches curl through a 0600 header file, never argv. Re-running a tag reuses the release and replaces both assets together, so the binary and its checksum can never disagree. Deliberately NOT a skip like the chart push: a chart version is immutable, while a release left with a truncated asset must be fixable by re-running. Auth prefers secrets.RELEASE_TOKEN and falls back to the automatic job token, logging which it used. Whether the job token can create releases here is UNVERIFIED -- it provably cannot write Packages (forgejo/forgejo#6904), which is why PACKAGES_TOKEN exists, but releases are repo-scoped. On a 401/403 the step says to mint a write:repository PAT as RELEASE_TOKEN, a name chosen because Forgejo rejects the reserved FORGEJO_/GITEA_/GITHUB_ prefixes. Verified: workflow schema valid at runner 13.2.0; the build step run for real produced an x86-64 ELF reporting `aegis 9.9.9-test` with `sha256sum -c` passing; local unstamped build prints the dev line; tsc clean; 892 pass / 0 fail. The release path was exercised against a local mock of the API for a fresh tag, a re-run, a stale asset and a bad token. A real release against Forgejo is unverified: no token was available and no tag was pushed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo | ||
| chart/aegis | ||
| cli | ||
| demo | ||
| docs | ||
| e2e | ||
| migrations | ||
| src | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| biome.json | ||
| bun.lock | ||
| bunfig.toml | ||
| compose.prod.yaml | ||
| compose.yaml | ||
| Dockerfile | ||
| package.json | ||
| playwright.config.ts | ||
| playwright.demo.config.ts | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
aegis
Single-user, self-hosted secrets manager. One binary plus Postgres. It holds the secrets your deploy pipelines need and hands them out on request.
Built with AI. aegis was designed and written with Claude Code. That is not a disclaimer, it is a fact you should price in: this is a secrets manager, so read
docs/crypto.mdanddocs/threat-model.mdbefore you trust it with yours. The test suite is 891 passing, the crypto and the threat model are written down in detail including what they do not cover — judge those documents, not this paragraph.
why
- Self-hosted Infisical is a lot of machine for one person. aegis is a compiled bun binary in a distroless image, plus Postgres. No shell, no package manager, no interpreter in the runtime container.
- Values are never at rest in plaintext. Every secret has its own DEK; the KEK that wraps them exists only in process memory while unlocked, and is never persisted in any form.
- A Postgres dump alone cannot be decrypted. Every wrap of the KEK opens only under material the server never stores.
- Minimal dependency surface by requirement: NestJS core,
pg,zod,@simplewebauthn/server. That is the list.
features
| Flat, Doppler-shaped model | project → env → KEY. No folders, no paths. Versioned: every write archives the prior value. |
${...} references |
A value can cite another key — ${KEY}, ${env.KEY}, ${project.env.KEY} — resolved server-side on read. --raw / ?raw=true returns the unresolved form, so export/import round-trips. |
| Three-dimensional machine tokens | Grants are {project, envs[], can[], keys?[]}: address × capability (read/write) × an optional exact/prefix key allowlist. Allow-list only — no deny list, no "all". |
| Write-only credentials | A can:["write"] token can overwrite the secret it rotates and can never read it, or anything else. |
| Audit log | Every read, write, mint, import and settings change, queryable by actor / action / target / project / date. |
| Changefeed | GET /v1/fetch serves a strong ETag over the bundle it served; a 304 writes no audit row. aegis run --exit-on-change polls it and exits 75 so your supervisor restarts the job. |
| Whole-vault backup | aegis backup seals everything into one encrypted JSON file — no plaintext, no KEK, no DEK in it. aegis restore rebuilds a fresh instance from it. |
| Optional passkeys | WebAuthn PRF unlock, off unless configured. Operator tokens and recovery codes work either way. |
references complete as you type
The add-secret dialog completes ${ against the vault's key catalog and refuses to let you save an unterminated reference.
minting a CI token is picking grants, one at a time
Label it, then add project + environment + capability + optional keys grants until the token reaches exactly what the job needs. A granted project leaves the picker.
The clip stops before the token itself: it is shown exactly once, at creation, and never again.
the audit log is filterable
rotate a secret, the deploy picks it up
aegis run --exit-on-change watches the keys it can read and exits 75 when one moves. It never restarts the child — systemd or Kubernetes already does that, with a budget and a way back.
security model
Envelope encryption, two keys deep:
- Per secret: a fresh 32-byte DEK AES-256-GCM-seals the value. The DEK is itself sealed under the KEK and stored only wrapped.
- One KEK per vault.
randomBytes(32), minted once at vault init, never persisted in plaintext — not in Postgres, not on disk, not in a log. It lives in process memory only while the vault is unlocked. A restart re-locks everything. - Both layers carry an AAD of
<projectId>/<environmentId>/<key>(row ids, not slugs), so a ciphertext moved to another project, environment or key will not open. Mismatch is a hard failure, never a fallback decrypt. - Sessions are two-state: authenticated (identity proven, metadata access) and unlocked (KEK in memory, plaintext reads). Reading a value needs both.
The operator token (aeg_op_…, 32 CSPRNG bytes, shown once) is the primary credential. It is both the authenticator and the source of the KEK unwrap key: HKDF(token, salt=tokenId, "aegis-operator-v1") opens that token's own wrap of the KEK. Multiple live tokens are normal — one per device — and revoking one is a single UPDATE that re-encrypts nothing. Recovery codes (10 per batch, 125 bits each) are wraps of the same KEK under scrypt. Passkeys, when configured, are a third kind of wrap.
What aegis does not protect against:
- Metadata. Project, environment and key names are plaintext, as is the whole audit log and every timestamp. If
STRIPE_LIVE_KEYmerely existing is sensitive, aegis does not hide that. - A stolen machine token plus the database file. A token's KEK wrap is a full-vault key offline.
envs,canandkeysnarrow the live API and nothing else — a write-only token is not cryptographically write-only. The grant list is an allowlist, never a cryptographic restriction. - A phished operator token. A bearer string a human stores can be pasted into the wrong window, committed, or synced to a breached password manager, none of which was true of hardware-sealed PRF output. There is no second factor unless you enroll a passkey.
- A compromised endpoint with an unlocked vault. aegis bounds the blast radius in time (15m idle auto-lock, slid only by real KEK use), not in value.
- Multi-user, roles, delegation and approval workflows are explicit non-goals.
docs/threat-model.md is the real document — case by case, including what each change cost. Read it rather than this summary.
quickstart
local, with docker compose
export AEGIS_SETUP_TOKEN="$(openssl rand -base64 24)" # min 16 chars; gates first-run enrollment
docker compose --profile tools up -d --build
--profile tools runs the one-shot migrate service first and makes aegis wait on it. Host ports bind 127.0.0.1 only (AEGIS_PORT, AEGIS_PG_PORT to move them). Postgres defaults to the password aegis-dev-only — dev only; override POSTGRES_PASSWORD for anything else.
Open http://localhost:3000 → /setup: present the setup token, take the operator token and the recovery codes (shown once), and you have a vault. Compose also sets AEGIS_ORIGIN=http://localhost:3000 / AEGIS_RP_ID=localhost, so passkeys are available locally — but a passkey enrolled against localhost will not work on a real hostname, so discard the trial volume (docker compose down -v) before deploying for real.
For a production compose deployment, overlay compose.prod.yaml, which makes POSTGRES_PASSWORD, AEGIS_IMAGE, AEGIS_MIGRATE_IMAGE, AEGIS_ORIGIN and AEGIS_RP_ID mandatory, publishes nothing to the host, and joins your reverse proxy's network:
docker compose -f compose.yaml -f compose.prod.yaml --profile tools up -d
minimum config to boot
| var | required | note |
|---|---|---|
POSTGRES_CONNECTION_URI |
yes | postgres://user:pass@host:5432/aegis. Secret; never logged unredacted |
AEGIS_SETUP_TOKEN |
first run | ≥16 chars. Gates first-run enrollment and a cold POST /api/backup/restore. Unset on a fresh vault = both refused (503). Ignored once the vault exists |
AEGIS_TRUSTED_PROXY |
yes behind a proxy | Who may set X-Forwarded-For. 1 = one trusted hop, or a comma list of IP/CIDR peers. Unset = header ignored, socket address is the client |
AEGIS_ORIGIN |
optional, paired | Public URL exactly as in the browser bar. Both-or-neither with AEGIS_RP_ID |
AEGIS_RP_ID |
optional, paired | Bare WebAuthn RP host. Changing it after enrollment kills every passkey |
AEGIS_ORIGIN + AEGIS_RP_ID are optional and both-or-neither. Leave both unset and passkeys are simply disabled — one INFO line at boot, not a warning; operator tokens and recovery codes are unaffected. Set both to enable passkeys. Setting exactly one is fatal at boot.
Behind an ingress, set AEGIS_TRUSTED_PROXY. Without it every caller on the internet is bucketed as the proxy — one shared rate-limit bucket — and every audit row records the proxy instead of the caller. Once set the header is required and an unusable chain is refused 400, deliberately.
Everything else (PORT, HOST, LOG_LEVEL, PG_POOL_MAX, AEGIS_DEFAULT_ENVS, AEGIS_WEB_DIST, AEGIS_RP_NAME, AEGIS_ALLOW_PENDING_MIGRATIONS) has a default. Full table: docs/config.md, or aegis-server --config-help. Every problem is reported together at boot, with the bad value masked.
a cluster, with Helm
The chart is chart/aegis. It never creates a Secret and never accepts a credential in values, so a rendered manifest cannot carry one.
kubectl create ns aegis
kubectl -n aegis create secret generic aegis-env \
--from-literal=POSTGRES_CONNECTION_URI='postgres://user:pass@host:5432/aegis' \
--from-literal=AEGIS_SETUP_TOKEN="$(openssl rand -base64 24)"
helm -n aegis install aegis chart/aegis \
--set secrets.existingSecret=aegis-env \
--set image.digest=sha256:... --set migration.image.digest=sha256:... \
--set config.origin=https://secrets.example.com --set config.rpId=secrets.example.com \
--set config.trustedProxy=1 \
--set ingress.enabled=true --set ingress.hosts[0].host=secrets.example.com
- Postgres is not in the chart. Your database, your backups.
replicaCount: 1is a hard constraint, not tuning. The KEK and unlock state are process memory; a second replica answers with a different lock state.updateStrategy: Recreatefor the same reason.- No PVC, by design. Vault, key wraps, tokens, audit log and the durable half of sessions are all in Postgres; the UI bundle is baked into the image. The only writable path is a 64Mi
emptyDirat/tmp, so thatreadOnlyRootFilesystemholds. - Migrations run as a
pre-install,pre-upgradehook Job from a separate bun-based image — the shipped image is distroless and has no bun. The server never auto-migrates. - Liveness is
GET /(SPA shell off the image: no DB, no KEK). A locked vault is a normal healthy state, and restarting on it would drop the KEK and re-lock everything. Readiness isGET /api/vault/status, which does touch Postgres.
Details, probe reasoning and reverse-proxy rules: docs/deploy.md.
cli
bun run build:cli → dist/aegis. Config from AEGIS_URL / AEGIS_TOKEN, else ~/.config/aegis/config.json (written 0600); env wins.
aegis login # browser round-trip, stores a session
aegis ls acme/prod # KEY<TAB>vN, metadata only
aegis get acme/prod/DATABASE_URL # one value to stdout
aegis set acme/prod/DATABASE_URL --value - # value from stdin; --value <literal> is refused
aegis import acme/prod --file .env # .env/JSON/YAML; always previews first
aegis export acme/prod > .env # refuses a TTY without --yes
aegis token new --label deploy --scopes acme:prod # read-only CI token
aegis token new --label rotate --scopes acme:prod --can write --keys STRIPE_KEY # write-only, one key
aegis fetch --targets acme:prod >> "$GITHUB_ENV" # machine token, READ
printf %s "$NEW" | aegis push acme/prod/STRIPE_KEY # machine token, WRITE
aegis run --targets acme:prod -- ./server --port 80 # secrets in the CHILD env only
aegis backup --out vault.json # whole vault, encrypted, written 0600
No command ever takes a secret or a token from argv — argv is world-readable via ps. Values come from stdin, a hidden prompt, --token-file or the environment. Exit codes: 0 ok, 1 usage, 2 auth, 3 not-found, 4 server/network, 75 a watched secret changed under run --exit-on-change.
Full reference, including every refusal and why: docs/cli.md.
ci integration
.forgejo/actions/aegis-env fetches a bundle and appends it to $GITHUB_ENV, masking every value first. The action lives in this repo, so check it out before using it.
jobs:
deploy:
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: ./.forgejo/actions/aegis-env
with:
url: https://secrets.example.com # or set AEGIS_URL
token: ${{ secrets.AEGIS_TOKEN }} # a machine token, from a Forgejo secret
targets: acme:prod,billing:prod # later target wins on key collision
- run: ./deploy.sh # secrets are in the environment
Inputs: token (required), targets, project, envs, url. targets and project+envs are mutually exclusive and setting both fails the step — neither the action nor the server picks a winner. There is no paths input; secrets are flat.
The token is passed to curl over stdin, so it never appears in argv or ps, and ::add-mask:: is emitted for every value before anything is exported.
docs
Terse, structured, written for agents and for whoever is paged next.
| file | what is in it |
|---|---|
architecture.md |
Envelope encryption, unlock methods, the two-state session model, data model, module graph |
crypto.md |
Primitives, KDFs, AAD identity binding, the KEK, operator tokens, recovery codes |
threat-model.md |
What an attacker gets from a dump / a stolen token / a live session. Residuals and non-goals |
auth.md |
Operator token as primary credential, guards, the /api vs /v1 split |
api.md |
Every route, its auth, its body, its audit row. /v1/fetch and PUT /v1/secrets |
cli.md |
Every command, flag, exit code and deliberate refusal |
config.md |
Every env var, boot sequence, fatal vs warn, redaction rules |
deploy.md |
Image stages, compose, Helm, migrations, first run, reverse proxy, WebAuthn on a real host |
backup.md |
What the backup file carries, what it deliberately omits, restore tiers |
import.md |
.env / JSON / YAML dialects, the auto sniff, refusal rules |
e2e.md |
Playwright suite and the demo recording |
conventions.md |
Code conventions: logging, naming, modules, validation, style |
file-map.md |
Directory map with ownership per area |
ui-style.md |
UI rules for web/** and the precedent each came from |
web-auth.md |
Auth/vault routes in the browser, PRF rules, no-PRF fallback, idle auto-lock |
web-secrets.md |
Secrets, import and audit screens |
media/README.md |
Manifest for the clips above: source range, dimensions, what each one shows |
development
bun install
cp .env.example .env # placeholders only; POSTGRES_CONNECTION_URI is the one you must set
docker compose up -d postgres # local Postgres on 127.0.0.1:5432
bun run migrate # idempotent, advisory-locked, aborts on checksum drift
bun run web:build # build the SPA into web/dist
bun run dev # watch mode on src/main.ts
| script | does |
|---|---|
bun run dev |
Watch-mode server |
bun run start |
Run the server once |
bun run build |
dist/aegis-server + dist/aegis (both compiled binaries) |
bun run migrate |
Apply DB migrations |
bun run web:dev |
Vite dev server, proxying /api + /v1 to :3000 |
bun run web:build |
Build the SPA into web/dist |
bun run test |
Unit + security suite (bun test src cli) — 891 passing |
bun run tsc |
Type-check the server |
bun run web:tsc |
Type-check the browser bundle |
bun run test:e2e |
Playwright suite (test:e2e:prf for the PRF path) |
bun run lint |
Biome check |
bun run format |
Biome check + write |
bun run tsc, bun run web:tsc, bun run lint and the test suite all pass before anything is called done.
CI: .forgejo/workflows/build-image.yml runs tsc + tests, then publishes the server and migrate images to Forgejo Packages (forgejo.dunemask.dev/dunemask/aegis[-migrate]) and prints the helm --set ...digest= fragment in the job summary. See docs/deploy.md.




