Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ Thumbs.db
.vscode/
.idea/

# Local issue/review drafts and helper scripts.
# Local issue/review/technical-review drafts and helper scripts.
.backlog-drafts/
.issue-review-drafts/
.technical-review-drafts/
.deep-research-drafts/
scripts/
.*-review/
Expand Down
10 changes: 7 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ Thumbs.db
.vscode/
.idea/

# Codex generated issues / issue review drafts / generated review directories
# Scripts directory should be local and ignored
# Codex generated issues / issue review drafts / technical review drafts /
# generated review directories
.backlog-drafts/
.issue-review-drafts/
.technical-review-drafts/
# Legacy local output from the former ChatGPT Deep Research workflow.
.deep-research-drafts/
scripts/
# Local operational/generated scripts and their artifacts. Public repository
# scripts belong in scripts/ and are reviewed like other tracked source files.
.local-scripts/
.*-review/
25 changes: 21 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,31 @@
- Media streams can be marked `open`, `complete`, or `failed`.
- Completed streams and incidents can be downloaded as encrypted ZIP evidence bundles.
- Simulator CLI exists for incident upload/check-in/encryption test flows.
- The current simulator encryption envelope is development/test oriented.
- The simulator produces the accepted post-quantum payload envelope by default;
its local key handling and decrypt verification remain development/test tools,
not a production client key-custody model.
- Future product scope includes emergency incidents, non-emergency interaction records, timed safety checks, and evidence notes.
- The current backend implements local username/password accounts, main `/v1` account/session authentication, admin account management routes, and owner/admin incident authorization.
- The current backend implements optional incident mode, capture profile, escalation policy, and sharing state metadata fields on private incident create/read routes, but these fields do not grant access, send notifications, change retention, change key custody, expose trusted-contact workflows, or change public viewer and bundle behavior.
- The current backend implements private owner-scoped and admin-global incident deletion routes, deletion tombstones, retryable blob deletion, and optional closed-incident retention through a background worker.
- The current backend does not yet implement mode-driven access, trusted-contact accounts, dead-man switch notifications, public account workflows, or a complete public `/v1` product deployment model beyond local account sessions, optional browser cookie sessions, and route-class limits.
- Planned production-cluster scope may add cluster-safe idempotent upload semantics and operation-level use of coordination. These additions must not remove SQLite, optional PostgreSQL metadata, local filesystem support, the optional S3-compatible blob backend, or the optional Valkey/Redis-compatible coordination backend.
- Regional stream-ingress relay work is planning-only unless explicitly scoped for implementation; any future relay must stay upload-only, temporary, ciphertext-only, and subordinate to the core API for authorization, idempotency, durable blob commits, and metadata.
- The current backend implements account/device recipient-key metadata,
account-to-account trusted-contact relationships, contact public-key metadata,
sharing grants, wrapped-key storage, and authenticated grant-scoped
trusted-contact wrapped-key reads. It does not yet implement mode-driven
access, trusted-contact incident reads or decrypt UX, dead-man switch
notifications, public account workflows, or a complete public `/v1` product
deployment model.
- Complete chunk uploads implement durable idempotency records and optional
short-lived Valkey/Redis-compatible coordination leases. Resumable or partial
upload sessions remain planning-only. These paths must preserve SQLite,
optional PostgreSQL metadata, local filesystem support, the optional
S3-compatible blob backend, and the optional Valkey/Redis-compatible
coordination backend.
- The regional stream-ingress relay implements a bounded complete encrypted
chunk upload, temporary staging, core preflight/commit, and optimistic
encrypted fanout surface. It must remain upload-only, temporary,
ciphertext-only, and subordinate to the core API for authorization,
idempotency, durable blob commits, and metadata.
- Future encryption direction should be a hybrid key custody model.
- Docker and GitHub Actions/GHCR publishing exist, but deployment expansion should not be added unless explicitly requested.

Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ preview readiness claim.
non-goals, and validation expectations in
`docs/private-admin-web-scope.md`.

- Migrated the public technical-review workflow to Codex maximum-reasoning
prompts while preserving an independent Phase 2 validation pass.

- Separated tracked repository tooling under `scripts/` from ignored local and
generated helpers under `.local-scripts/`, keeping the Markdown link checker
public while preventing local script artifacts from ordinary Git staging.

- Updated the repository, CI-selected toolchain, and pinned server/relay
builder image to Go 1.26.5 so the standard-library vulnerability scan uses
the current security-fix release.

- Consolidated duplicate key-metadata rate-limit classification, removed
unreachable internal helpers, and reconciled current implementation docs for
complete-upload coordination, the regional relay, trusted-contact wrapped-key
delivery, and the accepted post-quantum envelope.

- Updated the simulator for active-TOTP session verification through a
short-lived environment value that is never printed, and refreshed its
desktop-recorder, poor-network, and disposable-smoke guidance.

- Fixed active email challenge 2FA session enforcement so fresh primary-login
sessions must complete an email challenge before product-route or private
admin operator access alongside the existing TOTP and WebAuthn session gates.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS builder
FROM golang:1.26-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder

WORKDIR /src

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.ingress
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS builder
FROM golang:1.26-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder

WORKDIR /src

Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p align="center">
<a href="https://github.com/open-proofline/server/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/open-proofline/server/actions/workflows/ci.yml/badge.svg" /></a>
<a href="go.mod"><img alt="Go 1.26.4" src="https://img.shields.io/badge/go-1.26.4-00ADD8?logo=go&logoColor=white" /></a>
<a href="go.mod"><img alt="Go 1.26.5" src="https://img.shields.io/badge/go-1.26.5-00ADD8?logo=go&logoColor=white" /></a>
<a href="LICENSE"><img alt="License: AGPL-3.0-only" src="https://img.shields.io/badge/license-AGPL--3.0--only-blue.svg" /></a>
<a href="#current-status"><img alt="Status: Experimental" src="https://img.shields.io/badge/status-experimental-orange.svg" /></a>
<a href="SECURITY.md"><img alt="Security policy" src="https://img.shields.io/badge/security-policy-blue.svg" /></a>
Expand Down Expand Up @@ -381,7 +381,7 @@ For more diagrams and package-level details, see [docs/architecture.md](docs/arc

Requirements:

- Go 1.26.4
- Go 1.26.5
- SQLite via the bundled Go SQLite driver dependency
- TOTP generation and validation through the bundled Go OTP dependency
- WebAuthn/FIDO2 ceremony validation through the bundled go-webauthn dependency
Expand Down Expand Up @@ -447,7 +447,8 @@ In another terminal, run the simulator:
```bash
PROOFLINE_SIM_USERNAME=admin \
PROOFLINE_SIM_PASSWORD='replace-with-a-long-local-password' \
go run ./cmd/simclient --chunks 5 --interval 1s --download-bundle
go run ./cmd/simclient --chunks 5 --interval 1s --download-bundle \
--setup-totp-second-factor
```

The simulator creates an incident, creates a viewer token without printing the
Expand All @@ -459,6 +460,12 @@ desktop-recorder mode, local file input, ffmpeg segment capture, and
poor-network retry controls, and simulator-only contact-wrapped key metadata
artifacts.

The setup flag above is for a fresh disposable local account and does not print
or persist the generated TOTP seed. For an established account with active
TOTP, provide the current short-lived code through
`PROOFLINE_SIM_TOTP_CODE`; `simclient` verifies the session without printing the
code.

## Docker

Build from the repository root:
Expand Down Expand Up @@ -583,7 +590,7 @@ Use `80-backlog-scan-issue-drafts.md` to generate reviewed local issue drafts un

Review those drafts manually before creating GitHub issues. Drafts are generated review artifacts, not the long-term source of truth once GitHub issues exist.

Only after review, use `85-create-github-issues-from-drafts.md` to generate `scripts/create-backlog-issues.sh` and `.backlog-drafts/.../create-issues-review.md`. Do not run the generated script unless the maintainer explicitly asks for issue creation.
Only after review, use `85-create-github-issues-from-drafts.md` to generate `.local-scripts/create-backlog-issues.sh` and `.backlog-drafts/.../create-issues-review.md`. Do not run the generated script unless the maintainer explicitly asks for issue creation.

Do not let Codex create GitHub issues directly during the initial scan.

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Reports are in scope when they affect the current backend, documentation, or dep

The following are generally out of scope unless they demonstrate a concrete vulnerability in this repository:

- missing features already documented as absent, such as public account workflows, OAuth, JWT, SMS, push notifications, trusted-contact accounts, Android/iOS clients, web-client implementation in this server repository, mode-driven escalation behavior, or a public admin dashboard
- missing features already documented as absent, such as public account workflows, OAuth, JWT, SMS, push notifications, trusted-contact incident reads or decrypt UX, Android/iOS clients, web-client implementation in this server repository, mode-driven escalation behavior, or a public admin dashboard
- lack of production hardening already documented as a known limitation, without a new exploit path
- reports requiring unreviewed broad public exposure of main `/v1` route groups contrary to documented deployment guidance
- denial-of-service reports based only on unrealistic local access or unbounded physical access
Expand Down
21 changes: 16 additions & 5 deletions cmd/simclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ type createIncidentResponse struct {
}

type loginResponse struct {
Token string `json:"token"`
Token string `json:"token"`
SecondFactorVerificationRequired bool `json:"second_factor_verification_required"`
}

type totpEnrollmentResponse struct {
Expand Down Expand Up @@ -141,19 +142,19 @@ func (c client) createIncident(ctx context.Context) (string, error) {
return response.IncidentID, nil
}

func (c client) login(ctx context.Context, username, password string) (string, error) {
func (c client) login(ctx context.Context, username, password string) (loginResponse, error) {
request := map[string]string{
"username": username,
"password": password,
}
var response loginResponse
if err := c.postJSON(ctx, "/v1/auth/login", request, http.StatusCreated, &response); err != nil {
return "", fmt.Errorf("login: %w", err)
return loginResponse{}, fmt.Errorf("login: %w", err)
}
if response.Token == "" {
return "", fmt.Errorf("login: empty token in response")
return loginResponse{}, fmt.Errorf("login: empty token in response")
}
return response.Token, nil
return response, nil
}

func (c client) setupTOTPSecondFactor(ctx context.Context) error {
Expand All @@ -174,6 +175,16 @@ func (c client) setupTOTPSecondFactor(ctx context.Context) error {
return nil
}

func (c client) verifyTOTPSecondFactor(ctx context.Context, code string) error {
if strings.TrimSpace(code) == "" {
return fmt.Errorf("verify TOTP second factor: code is required")
}
if err := c.postJSON(ctx, "/v1/account/second-factor/totp/verify", map[string]string{"code": code}, http.StatusOK, nil); err != nil {
return fmt.Errorf("verify TOTP second factor: %w", err)
}
return nil
}

func (c client) createIncidentToken(ctx context.Context, incidentID string) (string, error) {
request := map[string]string{"label": "simclient"}
var response createIncidentTokenResponse
Expand Down
2 changes: 2 additions & 0 deletions cmd/simclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type config struct {
uploadMode string
username string
password string
totpCode string
chunks int
interval time.Duration
mediaType string
Expand Down Expand Up @@ -75,6 +76,7 @@ func parseConfig(args []string) (config, error) {
fs.StringVar(&cfg.relayBase, "relay-url", "", "Stream-ingress relay base URL when --upload-mode=relay")
fs.StringVar(&cfg.username, "username", os.Getenv("PROOFLINE_SIM_USERNAME"), "Proofline account username")
fs.StringVar(&cfg.password, "password", os.Getenv("PROOFLINE_SIM_PASSWORD"), "Proofline account password")
cfg.totpCode = strings.TrimSpace(os.Getenv("PROOFLINE_SIM_TOTP_CODE"))
fs.IntVar(&cfg.chunks, "chunks", defaultChunks, "Number of chunks to upload")
fs.DurationVar(&cfg.interval, "interval", defaultInterval, "Delay between chunk uploads")
fs.StringVar(&cfg.mediaType, "media-type", defaultMediaType, "Media type to upload")
Expand Down
5 changes: 1 addition & 4 deletions cmd/simclient/desktop_recorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ func runDesktopRecorder(ctx context.Context, out io.Writer, cfg config) error {
viewerBase: cfg.viewerBase,
}

fmt.Fprintln(out, "Logging in...")
sessionToken, err := sim.login(ctx, cfg.username, cfg.password)
if err != nil {
if err := authenticateSimulatorSession(ctx, out, &sim, cfg); err != nil {
return err
}
sim.sessionToken = sessionToken

manifest, err := prepareDesktopStage(ctx, out, sim, cfg, stage, encryption)
if err != nil {
Expand Down
34 changes: 34 additions & 0 deletions cmd/simclient/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@ func prepareEncryption(out io.Writer, cfg config) (simulatorEncryption, error) {
return encryption, nil
}

func authenticateSimulatorSession(ctx context.Context, out io.Writer, sim *client, cfg config) error {
fmt.Fprintln(out, "Logging in...")
login, err := sim.login(ctx, cfg.username, cfg.password)
if err != nil {
return err
}
sim.sessionToken = login.Token

if cfg.setupTOTPSecondFactor {
if login.SecondFactorVerificationRequired {
return fmt.Errorf("--setup-totp-second-factor cannot be used when an active factor already requires verification")
}
fmt.Fprintln(out, "Setting up TOTP second factor...")
if err := sim.setupTOTPSecondFactor(ctx); err != nil {
return err
}
fmt.Fprintln(out, "TOTP second factor verified.")
return nil
}

if !login.SecondFactorVerificationRequired {
return nil
}
if cfg.totpCode == "" {
return fmt.Errorf("account session requires second-factor verification; set the current TOTP code through PROOFLINE_SIM_TOTP_CODE")
}
fmt.Fprintln(out, "Verifying TOTP second factor...")
if err := sim.verifyTOTPSecondFactor(ctx, cfg.totpCode); err != nil {
return err
}
fmt.Fprintln(out, "TOTP second factor verified.")
return nil
}

func uploadChunks(ctx context.Context, out io.Writer, sim client, cfg config, incidentID, streamID string, encryption simulatorEncryption, relaySession relaySession) error {
startedAt := time.Now().UTC()
for i := 1; i <= cfg.chunks; i++ {
Expand Down
12 changes: 1 addition & 11 deletions cmd/simclient/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,9 @@ func run(ctx context.Context, out io.Writer, args []string) error {
relayBase: cfg.relayBase,
}

fmt.Fprintln(out, "Logging in...")
sessionToken, err := sim.login(ctx, cfg.username, cfg.password)
if err != nil {
if err := authenticateSimulatorSession(ctx, out, &sim, cfg); err != nil {
return err
}
sim.sessionToken = sessionToken
if cfg.setupTOTPSecondFactor {
fmt.Fprintln(out, "Setting up TOTP second factor...")
if err := sim.setupTOTPSecondFactor(ctx); err != nil {
return err
}
fmt.Fprintln(out, "TOTP second factor verified.")
}

fmt.Fprintln(out, "Creating incident...")
incidentID, err := sim.createIncident(ctx)
Expand Down
68 changes: 66 additions & 2 deletions cmd/simclient/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ func TestClientWriteRoutesUseMainAPIBase(t *testing.T) {
}
ctx := context.Background()

sessionToken, err := sim.login(ctx, "test-admin", "test-password")
login, err := sim.login(ctx, "test-admin", "test-password")
if err != nil {
t.Fatalf("login returned error: %v", err)
}
sim.sessionToken = sessionToken
sim.sessionToken = login.Token
incidentID, err := sim.createIncident(ctx)
if err != nil {
t.Fatalf("createIncident returned error: %v", err)
Expand Down Expand Up @@ -914,6 +914,70 @@ func TestSetupTOTPSecondFactor(t *testing.T) {
}
}

func TestAuthenticateSimulatorSessionVerifiesRequiredTOTP(t *testing.T) {
const code = "123456"
var gotPaths []string
httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotPaths = append(gotPaths, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/v1/auth/login":
return testResponse(http.StatusCreated, "application/json", `{"token":"session-token","second_factor_verification_required":true}`), nil
case "/v1/account/second-factor/totp/verify":
if r.Header.Get("Authorization") != "Bearer session-token" {
t.Fatal("TOTP verification omitted session Authorization header")
}
var request struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode TOTP verification request: %v", err)
}
if request.Code != code {
t.Fatalf("TOTP code = %q, want supplied code", request.Code)
}
return testResponse(http.StatusOK, "application/json", `{"status":"verified"}`), nil
default:
return testResponse(http.StatusNotFound, "application/json", `{"error":{"code":"not_found"}}`), nil
}
})}
sim := client{httpClient: httpClient, apiBase: "http://api.example"}
var output bytes.Buffer

err := authenticateSimulatorSession(context.Background(), &output, &sim, config{
username: "admin",
password: "password",
totpCode: code,
})
if err != nil {
t.Fatalf("authenticateSimulatorSession returned error: %v", err)
}
if sim.sessionToken != "session-token" {
t.Fatal("session token was not retained after login")
}
if got := strings.Join(gotPaths, "\n"); got != "POST /v1/auth/login\nPOST /v1/account/second-factor/totp/verify" {
t.Fatalf("paths = %q", got)
}
if strings.Contains(output.String(), code) || strings.Contains(output.String(), "session-token") {
t.Fatalf("authentication output exposed a verification credential: %s", output.String())
}
}

func TestAuthenticateSimulatorSessionRequiresTOTPCode(t *testing.T) {
httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return testResponse(http.StatusCreated, "application/json", `{"token":"session-token","second_factor_verification_required":true}`), nil
})}
sim := client{httpClient: httpClient, apiBase: "http://api.example"}
var output bytes.Buffer

err := authenticateSimulatorSession(context.Background(), &output, &sim, config{username: "admin", password: "password"})
if err == nil || !strings.Contains(err.Error(), "PROOFLINE_SIM_TOTP_CODE") {
t.Fatalf("error = %v, want TOTP environment guidance", err)
}
if strings.Contains(err.Error(), "session-token") || strings.Contains(output.String(), "session-token") {
t.Fatal("missing-code error exposed the session token")
}
}

func TestPostJSONUnexpectedStatusOmitsTokenBearingBody(t *testing.T) {
httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return testResponse(http.StatusOK, "application/json", `{"token":"raw-session-token","other":"raw-viewer-token"}`), nil
Expand Down
Loading