# Plan: Runtime Docker image for n_signer with TCP + auth-envelope onboarding

Status: **draft v1 — for review**

This plan documents a path for new users to download and run [`n_signer`](../README.md) inside a Docker container with minimal friction, while preserving the project's existing security posture (zero filesystem footprint at runtime, mlock'd in-memory secrets, deny-by-default approvals, crash-equals-total-wipe). It complements — and does not replace — the native binary distribution.

The companion work breakdown is tracked as todos in this task; this document captures the rationale and architectural decisions behind those todos.

Related plans and references:

- [`plans/caller_token_identity.md`](caller_token_identity.md) — the auth envelope spec that makes TCP a first-class authenticated transport.
- [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md) — deferred relay-based transport that would offer a Docker-friendly future alternative.
- [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) — current build-only Dockerfile that this plan extends.
- [`build_static.sh`](../build_static.sh) — host-side wrapper around the build image.
- [`increment_and_push.sh`](../increment_and_push.sh) — existing release flow that this plan extends.
- [`src/server.c`](../src/server.c) — current transport implementation (`unix`, `stdio`, `qrexec`, `tcp:HOST:PORT`).
- [`src/auth_envelope.c`](../src/auth_envelope.c) — verifier of NIP-01-shaped `kind:27235` auth envelopes.

---

## 1. Why this plan exists

Today a new user has three realistic on-ramps to `n_signer`:

1. Download the static binary from a release and run it natively.
2. Clone the repo and rebuild via [`build_static.sh`](../build_static.sh), which itself uses Docker as a build environment but produces a host binary.
3. Hand-roll a `Dockerfile` and `docker run` invocation around the static binary.

None of those produce a one-command `docker pull` + `docker run` experience. [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) ends with `FROM scratch` and copies the binary out — it is a build artifact, not a runnable image. This plan closes that gap.

The Docker path also gives users defense-in-depth that the native path cannot offer: PID-namespace isolation prevents same-user processes on the host from `ptrace`'ing the signer, mount-namespace isolation hides the binary, default seccomp shrinks the kernel attack surface, and `--cap-drop ALL` plus `--security-opt no-new-privileges` constrain a hypothetical compromise of the signer itself. None of this defends against host root, kernel exploits, or side channels — that boundary requires a VM-class isolation like the Qubes path described in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md).

---

## 2. Goals

1. **One-command run.** A new user with Docker installed should be able to `docker pull` and `docker run` to get a working interactive `n_signer` TUI, with no source checkout and no manual binary placement.
2. **Hardened by default.** The recommended invocation in the documentation must include `--read-only`, `--cap-drop ALL`, `--security-opt no-new-privileges`, resource limits, and `127.0.0.1`-only port publishing. Users who copy-paste the docs land on the safer configuration without having to think.
3. **TCP is the supported transport for the image.** Out of the four current transports, TCP is the only one that crosses the container/host network-namespace boundary cleanly today, and it is the only one with a working caller-authentication story per [`plans/caller_token_identity.md`](caller_token_identity.md). The image's `ENTRYPOINT` reflects this.
4. **No new transport code in v1.** This plan uses transports that already exist in [`src/server.c`](../src/server.c). Adding a `unix-path` transport variant for bind-mounted UNIX sockets is a separate plan that this one explicitly does not depend on.
5. **Preserve the runtime invariants.** Zero filesystem footprint at runtime, mlock'd secrets, crash-equals-total-wipe, and human-attended TUI all carry over. The image runs `--read-only`; no state files; SIGTERM from `docker stop` triggers the same wipe path as Ctrl-C in a terminal.
6. **Verifiable provenance.** Pulled images must be cryptographically verifiable to a release tag, with an SBOM and vulnerability scan attached as release assets so users can inspect what they are running.
7. **No regression for the native path.** The static-binary distribution and the `build_static.sh` build flow continue to work unchanged. The Docker image is an *additional* on-ramp, not a replacement.

---

## 3. Non-goals

- **No multi-user, long-running daemon mode.** The TUI-attended, foreground-only design from [`README.md`](../README.md) is preserved. The image is run with `-it`, attached to a terminal. Background `docker run -d` is explicitly unsupported and will be documented as such.
- **No bundled clients.** The image contains only the signer binary. Clients (browser extension, native apps, examples) ship separately.
- **No persistent volume.** No mnemonic-on-disk, no policy-on-disk. The signer's "no runtime config or state files" rule from [`README.md`](../README.md) applies unchanged inside the container.
- **No network exposure beyond loopback by default.** The documented invocation publishes ports to `127.0.0.1` only. LAN/WAN exposure is out of scope and would require a separate threat model plus TLS termination, which is its own plan.
- **No NIP-46 relay transport.** That remains deferred per [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md).
- **No host-UNIX-socket bridging.** Bind-mounting a UNIX socket from the container to the host is appealing for UID-based identity but requires a `--listen unix-path:` mode that does not exist today. Tracked as a follow-up note (todo 20), out of scope here.
- **No image-internal privilege separation.** The signer runs as a single non-root user inside the container; further compartmentalization (separate uid for TUI vs network handling) is not in scope for v1.

---

## 4. Threat model delta vs. the native binary

The Docker image inherits the same in-process threat model as the native binary (mlock'd secrets, no disk artifacts, deny-by-default approvals). It adds:

| Boundary | Native terminal | Docker image (this plan) |
|---|---|---|
| Same-UID host process reads signer memory | Vulnerable to `ptrace`, `/proc/<pid>/mem` | Blocked by PID namespace |
| Compromised signer escalates on host | Has full UID privileges on host | Constrained by `--cap-drop ALL`, `--security-opt no-new-privileges`, default seccomp |
| Compromised signer writes persistent files | Can write anywhere UID has access | `--read-only` rootfs blocks writes; no volumes mounted |
| Core dump leaks key material | Possible if `ulimit` or systemd allows | `--read-only` plus no writable paths means no core file path |
| Host root reads signer memory | Unconstrained | Still unconstrained — `nsenter`, `docker exec`, host `/proc` all work. **Documented as a non-defense.** |
| Kernel exploit | Full host compromise | Full host compromise — shared kernel. **Documented as a non-defense.** |
| Network reachability | Bound by socket file perms or abstract namespace | Bound by Docker port publishing; `127.0.0.1` only by docs |
| TCP caller identity | Auth envelope (per [`plans/caller_token_identity.md`](caller_token_identity.md)) | Same — unchanged inside the container |

The user-facing security message in [`documents/SECURITY.md`](../documents/SECURITY.md) gets a small subsection (todo 12) calling out exactly this — what Docker buys you and what it does not — so users do not over-trust container isolation.

---

## 5. Image architecture

### 5.1 Multi-stage Dockerfile

Extend [`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl) with a third stage that produces a runnable image. The existing two stages — `builder` and the `FROM scratch AS output` extraction stage — are preserved unchanged so [`build_static.sh`](../build_static.sh) keeps working.

```mermaid
graph LR
    A[Stage: builder<br/>alpine:3.19 + toolchain] --> B[Stage: output<br/>FROM scratch<br/>used by build_static.sh]
    A --> C[Stage: runtime<br/>FROM scratch<br/>ENTRYPOINT nsigner]
    B -.-> D[host binary<br/>build/nsigner_static_*]
    C -.-> E[OCI image<br/>nsigner:vX.Y.Z]

    style C fill:#9f9
    style E fill:#9f9
```

The runtime stage:

- Starts `FROM scratch` (no shell, no libc, no busybox — the binary is fully static MUSL).
- Copies in the stripped static binary as `/nsigner`.
- Sets `ENTRYPOINT ["/nsigner"]` and a default `CMD` of `["--listen", "tcp:0.0.0.0:7777"]`. Users can override CMD to pick another transport, but the ENTRYPOINT remains fixed at the binary.
- Declares OCI image labels (todo 3) — source URL, version, revision (git SHA), licenses, title, description — sourced from the existing `NSIGNER_VERSION` macro that [`increment_and_push.sh`](../increment_and_push.sh) already maintains.
- Runs as a non-root UID. Because `FROM scratch` has no `/etc/passwd`, this is done with a numeric UID via `USER 65532`. The static binary has no UID-name dependencies.
- `EXPOSE 7777` for documentation only (does not affect runtime).

### 5.2 Default `--listen` choice

The image defaults to `--listen tcp:0.0.0.0:7777`. Rationale (todo 15):

- It is the only existing transport that crosses the container netns boundary without `--network=host` (which would defeat the isolation we are running Docker for).
- It is the only transport with a working caller-authentication story today, via the auth envelope verified in [`src/auth_envelope.c`](../src/auth_envelope.c) and required for TCP per [`src/server.c`](../src/server.c) lines around 1313.
- `0.0.0.0` inside the container is safe **only** when the host publishes to `127.0.0.1`. The documentation makes this binding explicit and warns against `-p 7777:7777` (which publishes to all host interfaces). Todo 16 covers this in detail.
- Users who want stdio (one-shot) or qrexec (Qubes-specific) override `CMD` themselves; the default targets the dominant Docker-on-laptop case.

### 5.3 Image size and layers

Static MUSL binary, stripped, is roughly single-digit MB. The runtime image will be that plus the OCI metadata — well under 10 MB. `docker pull` is a few seconds on any reasonable connection. This is documented as a property, not just a coincidence, because it directly supports the "easy for new users" goal.

---

## 6. Build, tag, and publish flow

```mermaid
graph LR
    Dev[Developer<br/>./increment_and_push.sh -r] --> Bump[Bump version macros]
    Bump --> Tag[git tag vX.Y.Z]
    Tag --> Build[Build static binaries<br/>x86_64 + arm64]
    Build --> Test[Run make test inside builder]
    Test --> Img[Build runtime image<br/>buildx multiarch]
    Img --> Sbom[Generate SBOM + vuln scan]
    Sbom --> Sign[cosign sign image]
    Sign --> Push[Push image and assets<br/>to registry]
    Push --> Release[Create Gitea release<br/>with binary, sbom, image digest, verify cmd]
```

### 6.1 Registry choice

Decision pending (todo 4). Three viable destinations:

1. **The existing Gitea registry at `git.laantungir.net`.** Aligns with the existing release flow that already uses `~/.gitea_token`. Self-hosted, under project control. Downside: less familiar to new users; they may need to log in to pull.
2. **GitHub Container Registry (GHCR).** Free, public, well-known, integrates with cosign keyless signing using GitHub OIDC. Requires a GitHub mirror.
3. **Docker Hub.** Most familiar to new users. Rate limits on anonymous pulls. Requires a Docker Hub account and token.

The plan recommends GHCR as primary because keyless cosign signing dramatically simplifies the trust story for new users (`cosign verify ghcr.io/...` with no key management). The Gitea registry is a viable alternative if the project prefers to stay self-hosted; in that case, key-based cosign signing replaces keyless. Either way, the choice is documented up front so users know where to pull from.

### 6.2 Multi-arch

Built via `docker buildx build --platform linux/amd64,linux/arm64`. The existing builder stage already handles both architectures (it picks `libnostr_core_x64.a` vs `libnostr_core_arm64.a` based on `uname -m`). A `Makefile` target (todo 2) wraps this so the developer command is `make docker-image-multiarch`.

### 6.3 Tags

- `vX.Y.Z` — immutable, matches git tag and binary release.
- `latest` — moves to newest stable release.
- `vX` and `vX.Y` — moving tags for users who want major/minor pinning.

All tags are signed; `latest` is signed at each release.

### 6.4 Provenance

Every release attaches:

- Image digest (`sha256:...`) in release notes.
- `cosign verify` command line as a copy-pasteable block.
- SBOM in SPDX or CycloneDX format (syft) as a release asset and as an OCI attestation attached to the image.
- Vulnerability scan output (grype or trivy) as a release asset.

This makes "is the image I pulled the one the project published?" a one-command answer. Todos 6 and 7 cover this.

### 6.5 Hooks into the existing release script

[`increment_and_push.sh`](../increment_and_push.sh) currently:

1. Bumps the version macros in `src/main.h` or `src/main.c`.
2. Tags the commit.
3. Builds x86_64 and arm64 static binaries.
4. Verifies `--version` of each binary matches the new tag.
5. Creates a Gitea release and uploads the binaries and a source tarball.

The image flow plugs in between steps 4 and 5 (todo 5):

5a. Build the runtime image for both architectures via buildx.
5b. Generate SBOM and vuln scan.
5c. Sign with cosign.
5d. Push the image and signatures to the chosen registry.
5e. Capture the resulting digest for the release notes.

Each of these steps is gated on a credential being present, mirroring the existing `~/.gitea_token` check. A developer without registry credentials still gets a successful binary release; the image steps emit warnings and skip. This keeps the local release flow usable in offline or partial-credential setups.

### 6.6 CI integration

A CI smoke test (todo 13) boots the just-built runtime image with the default `--listen tcp:0.0.0.0:7777`, drives a single `get_public_key` request from a host client over the published port, and asserts a clean shutdown. This catches regressions where the binary builds but cannot actually serve TCP from inside a container — for example, a future code change that defaults to abstract sockets in a way that breaks netns separation.

The existing `make test` suite is also run inside the builder stage (todo 14) before the artifact is extracted, so we have a tested-then-shipped guarantee on the binary that ends up in the image.

---

## 7. New-user runbook (target end-state)

This is the experience the plan is optimizing for. After the work is done, the [`documents/DOCKER.md`](../documents/DOCKER.md) (todo 8) opens with effectively this:

```bash
# 1. Verify the image you are about to run
cosign verify ghcr.io/laantungir/nsigner:v0.X.Y \
  --certificate-identity-regexp 'https://github.com/laantungir/n_signer' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# 2. Run with hardened defaults
docker run -it --rm \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --pids-limit 64 \
  --memory 128m \
  -p 127.0.0.1:7777:7777 \
  ghcr.io/laantungir/nsigner:v0.X.Y
```

The TUI then comes up exactly as it does for the native binary — mnemonic prompt, role table, status display, approval prompts. From the user's perspective, the only differences are the longer command line and the fact that pressing Ctrl-C now also stops the container.

A second documentation block (todo 9) walks through writing a TCP client that builds a `kind:27235` auth envelope per [`plans/caller_token_identity.md`](caller_token_identity.md), signs it with BIP-340 Schnorr, and gets approved exactly once at the TUI. A worked example client is added to [`examples/`](../examples) (todo 10) so users have a copy-pasteable starting point that they can run on the host against the containerized signer.

---

## 8. Risks and decisions

### 8.1 TCP-only is a real limitation

UID-based identity is preserved on AF_UNIX abstract sockets, which is a strong primitive in the native deployment. In Docker, that primitive is gone — abstract sockets do not cross netns. This plan accepts that and shifts to pubkey-based identity via the auth envelope. The trade-off is documented; users who specifically want UID-based identity stay on the native binary, or wait for the future `unix-path` transport plan (todo 20) that would let them bind-mount a pathname socket and recover `SO_PEERCRED.uid`. UID identity inside the container is also affected by Docker user-namespace remapping, which is another reason this plan does not pretend AF_UNIX inside a container is equivalent to AF_UNIX on the host.

### 8.2 `0.0.0.0` inside the container is unforgiving of misconfiguration

If a user copies the image's default `CMD` but writes `-p 7777:7777` (forgetting the `127.0.0.1:` prefix), they expose the signer to any host on the network. This is documented prominently (todo 16) with the explicit warning. We considered defaulting the CMD to `tcp:127.0.0.1:7777`, but that breaks Docker's port-publishing model — the host-side `127.0.0.1:7777` proxy needs to talk to the container's network interface, which is not loopback. So the safer-looking default is actually broken. The chosen approach (default `0.0.0.0` inside, document loopback publishing on the host) is the only one that works with Docker's networking semantics, but it requires that warning.

### 8.3 TUI under `docker run -it` may have edge cases

The signer's TUI uses raw terminal input for keystroke approvals, ANSI status redraws, and signal-driven shutdown. Most of this works under `docker run -it` but the plan reserves a verification step (todo 19) to confirm:

- `a` / `y` / `n` / `q` / `l` keystrokes deliver the same response as on a native terminal.
- ANSI redraws do not get garbled by Docker's TTY layer.
- `docker stop` (which sends SIGTERM, then SIGKILL after grace) triggers the same total-state-wipe path as Ctrl-C, including mnemonic zeroization.
- `docker kill` (SIGKILL only) leaves no recoverable state — by the existing crash-equals-wipe property, this should already hold, but the plan explicitly tests it.

Any deviation discovered is documented as a caveat in [`documents/DOCKER.md`](../documents/DOCKER.md), not silently worked around.

### 8.4 Host root is not blocked

The plan documents this as a non-defense. Users whose threat model includes "another user on the same host has root" should not assume Docker isolates against that. They should run on a system where they trust root, or move to a Qubes-style VM compartment as documented in [`documents/QUBES_OS.md`](../documents/QUBES_OS.md). This is not a flaw in the plan; it is a property of containers that is easy to misunderstand and therefore worth stating up front.

### 8.5 Image sprawl across registries

If both Gitea and GHCR end up hosting the image, users may pull the wrong one or skip signature verification because "the other registry's image is the same anyway." The decision in §6.1 (one primary registry) is made specifically to avoid this. A secondary mirror is acceptable only if it carries the same digests and the same cosign signatures, and the documentation makes the mirror status explicit.

---

## 9. Out of scope, explicitly

Listed so the plan's scope is clear:

- TLS termination for TCP (a reverse-proxy-in-container pattern).
- Remote signing (binding TCP to non-loopback). Both require a separate threat model and are deferred.
- A `--listen unix-path:` transport that would let Docker users bind-mount a pathname socket. Tracked as todo 20 for a follow-up plan.
- A NIP-46 relay transport. Deferred per [`plans/nip46_bunker_mode.md`](nip46_bunker_mode.md).
- Reproducible builds in the strict bit-for-bit sense. The static-binary build is already close to deterministic, but full reproducibility (matching digests across rebuilds at a given tag) is its own engineering problem and is not promised here.
- Distro packages (deb, rpm, Homebrew). The native binary remains a release asset; OS-native packaging is its own track.

---

## 10. Acceptance criteria

The plan is considered delivered when **all** of the following are true:

1. A new user with only Docker installed can pull and run the image with one `docker run` command from [`documents/DOCKER.md`](../documents/DOCKER.md) and see the same TUI they would see from the native binary.
2. `cosign verify` against the published image passes for the chosen registry, and the verification command is in the release notes for that tag.
3. SBOM and vulnerability scan output are attached to the release.
4. Pressing Ctrl-C in the attached terminal, and `docker stop` from another shell, both result in the signer process exiting cleanly with the existing zeroization path having run. No leftover state on the container's writable layer (which is `--read-only` anyway).
5. A TCP client running on the host, using the example added in todo 10, can complete one `get_public_key` round trip after a single `[a]` approval at the TUI, and a second request from the same client pubkey is served silently.
6. The CI smoke test (todo 13) is green on every PR that touches `Dockerfile.alpine-musl`, the runtime stage, or `src/server.c`.
7. [`README.md`](../README.md) links to [`documents/DOCKER.md`](../documents/DOCKER.md) from a "Run in Docker" quickstart section, and [`documents/SECURITY.md`](../documents/SECURITY.md) carries the Docker-specific subsection from todo 12.
8. The native binary distribution and `make static` flow produce identical artifacts to before this plan landed. No regression.
