From 7303f3df9d26a1c9ed532fb7748057e09717071b Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Thu, 16 Jul 2026 16:53:53 +0000 Subject: [PATCH 1/9] feat(api): add atomic recipient drop opens --- ashdrop/api/main.go | 124 ++++++++++++++++++++++++++++++++----------- ashdrop/api/store.go | 116 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 203 insertions(+), 37 deletions(-) diff --git a/ashdrop/api/main.go b/ashdrop/api/main.go index b936c00..5ba952f 100644 --- a/ashdrop/api/main.go +++ b/ashdrop/api/main.go @@ -4,6 +4,7 @@ package main import ( "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "log" @@ -15,12 +16,14 @@ import ( ) const ( - maxBody = 96 * 1024 // ciphertext is capped ~64KB; leave room for JSON - maxTTL = 30 * 24 * 3600 // 30 days - minTTL = 60 // 1 minute floor + maxBody = 96 * 1024 // ciphertext is capped ~64KB; leave room for JSON + maxTTL = 30 * 24 * 3600 // 30 days + minTTL = 60 // 1 minute floor ) -var store *Store +type API struct { + store *Store +} func main() { dbPath := getenv("ASHDROP_DB", "ashdrop.db") @@ -28,35 +31,41 @@ func main() { if err != nil { log.Fatalf("open store: %v", err) } - store = st defer st.Close() go cleanupLoop(st) - mux := http.NewServeMux() - mux.HandleFunc("POST /api/secrets", handleCreate) - mux.HandleFunc("GET /api/secrets/{id}", handleGet) - mux.HandleFunc("POST /api/secrets/{id}/burn", handleBurn) - mux.HandleFunc("GET /api/secrets/{id}/status", handleStatus) - mux.HandleFunc("DELETE /api/secrets/{id}", handleDelete) - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - addr := ":" + getenv("PORT", "8080") log.Printf("ashdrop api listening on %s (db: %s)", addr, dbPath) - log.Fatal(http.ListenAndServe(addr, cors(rateLimit(mux)))) + log.Fatal(http.ListenAndServe(addr, newHandler(st))) } // ---- handlers ---- -func handleCreate(w http.ResponseWriter, r *http.Request) { +func newHandler(store *Store) http.Handler { + api := &API{store: store} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/secrets", api.handleCreate) + mux.HandleFunc("GET /api/secrets/{id}", api.handleGet) + mux.HandleFunc("GET /api/secrets/{id}/metadata", api.handleMetadata) + mux.HandleFunc("POST /api/secrets/{id}/open", api.handleOpen) + mux.HandleFunc("POST /api/secrets/{id}/burn", api.handleBurn) + mux.HandleFunc("GET /api/secrets/{id}/status", api.handleStatus) + mux.HandleFunc("DELETE /api/secrets/{id}", api.handleDelete) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + return cors(rateLimit(mux)) +} + +func (api *API) handleCreate(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxBody) var req struct { Ciphertext string `json:"ciphertext"` IV string `json:"iv"` TTL int `json:"ttl"` MaxViews int `json:"maxViews"` + RecipientPub string `json:"recipientPub"` EphemeralPub string `json:"ephemeralPub"` // non-empty = recipient-keyed ECDH drop } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -71,6 +80,12 @@ func handleCreate(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusRequestEntityTooLarge, "secret too large") return } + if req.RecipientPub != "" { + if !validPublicKey(req.RecipientPub) || !validPublicKey(req.EphemeralPub) { + httpError(w, http.StatusBadRequest, "invalid recipient public key") + return + } + } ttl := req.TTL if ttl < minTTL { @@ -91,9 +106,10 @@ func handleCreate(w http.ResponseWriter, r *http.Request) { MaxViews: maxViews, ExpiresAt: time.Now().Unix() + int64(ttl), NotifyTok: notify, + RecipientPub: req.RecipientPub, EphemeralPub: req.EphemeralPub, } - if err := store.Put(id, sec); err != nil { + if err := api.store.Put(id, sec); err != nil { httpError(w, http.StatusInternalServerError, "could not store secret") return } @@ -104,8 +120,8 @@ func handleCreate(w http.ResponseWriter, r *http.Request) { }) } -func handleGet(w http.ResponseWriter, r *http.Request) { - sec, err := store.Fetch(r.PathValue("id")) +func (api *API) handleGet(w http.ResponseWriter, r *http.Request) { + sec, err := api.store.Fetch(r.PathValue("id")) if err != nil { httpError(w, http.StatusInternalServerError, "lookup failed") return @@ -114,31 +130,63 @@ func handleGet(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusNotFound, "this secret no longer exists") return } - viewsLeft := -1 // unlimited - if sec.MaxViews > 0 { - viewsLeft = sec.MaxViews - sec.Views + writeJSON(w, http.StatusOK, map[string]any{ + "ciphertext": sec.Ciphertext, + "iv": sec.IV, + "viewsLeft": viewsLeft(sec), + "ephemeralPub": sec.EphemeralPub, + "recipientKeyed": sec.EphemeralPub != "", + }) +} + +func (api *API) handleMetadata(w http.ResponseWriter, r *http.Request) { + sec, err := api.store.Metadata(r.PathValue("id")) + if err != nil { + httpError(w, http.StatusInternalServerError, "lookup failed") + return + } + if sec == nil { + httpError(w, http.StatusNotFound, "this secret no longer exists") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "recipientKeyed": sec.EphemeralPub != "", + "recipientPub": sec.RecipientPub, + "expiresAt": sec.ExpiresAt, + "viewsLeft": viewsLeft(sec), + }) +} + +func (api *API) handleOpen(w http.ResponseWriter, r *http.Request) { + sec, err := api.store.Open(r.PathValue("id")) + if err != nil { + httpError(w, http.StatusInternalServerError, "lookup failed") + return + } + if sec == nil { + httpError(w, http.StatusNotFound, "this secret no longer exists") + return } writeJSON(w, http.StatusOK, map[string]any{ "ciphertext": sec.Ciphertext, "iv": sec.IV, - "viewsLeft": viewsLeft, "ephemeralPub": sec.EphemeralPub, "recipientKeyed": sec.EphemeralPub != "", }) } -func handleBurn(w http.ResponseWriter, r *http.Request) { - if err := store.Burn(r.PathValue("id")); err != nil { +func (api *API) handleBurn(w http.ResponseWriter, r *http.Request) { + if err := api.store.Burn(r.PathValue("id")); err != nil { httpError(w, http.StatusInternalServerError, "burn failed") return } w.WriteHeader(http.StatusNoContent) } -func handleStatus(w http.ResponseWriter, r *http.Request) { +func (api *API) handleStatus(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") tok := r.URL.Query().Get("notifyToken") - notifyTok, opened, openedAt, found, err := store.Status(id) + notifyTok, opened, openedAt, found, err := api.store.Status(id) if err != nil { httpError(w, http.StatusInternalServerError, "status failed") return @@ -157,8 +205,8 @@ func handleStatus(w http.ResponseWriter, r *http.Request) { }) } -func handleDelete(w http.ResponseWriter, r *http.Request) { - if err := store.Delete(r.PathValue("id")); err != nil { +func (api *API) handleDelete(w http.ResponseWriter, r *http.Request) { + if err := api.store.Delete(r.PathValue("id")); err != nil { httpError(w, http.StatusInternalServerError, "delete failed") return } @@ -180,7 +228,7 @@ func cors(next http.Handler) http.Handler { }) } -// rateLimit is a small fixed-window per-IP limiter on writes (POST). Reads pass. +// rateLimit is a small fixed-window per-IP limiter on creates. Other requests pass. func rateLimit(next http.Handler) http.Handler { type win struct { start time.Time @@ -192,7 +240,7 @@ func rateLimit(next http.Handler) http.Handler { limit = 30 // creates per minute per IP ) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/api/secrets") || strings.HasSuffix(r.URL.Path, "/burn") { + if r.Method != http.MethodPost || r.URL.Path != "/api/secrets" { next.ServeHTTP(w, r) return } @@ -217,6 +265,18 @@ func rateLimit(next http.Handler) http.Handler { // ---- helpers ---- +func viewsLeft(sec *Secret) int { + if sec.MaxViews > 0 { + return sec.MaxViews - sec.Views + } + return -1 // unlimited +} + +func validPublicKey(encoded string) bool { + pub, err := base64.RawURLEncoding.DecodeString(encoded) + return err == nil && len(pub) == 65 && pub[0] == 0x04 && base64.RawURLEncoding.EncodeToString(pub) == encoded +} + func cleanupLoop(s *Store) { t := time.NewTicker(5 * time.Minute) defer t.Stop() diff --git a/ashdrop/api/store.go b/ashdrop/api/store.go index 5b2fe9c..720c1a0 100644 --- a/ashdrop/api/store.go +++ b/ashdrop/api/store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "strings" "time" _ "modernc.org/sqlite" @@ -19,6 +20,7 @@ type Secret struct { ExpiresAt int64 NotifyTok string OpenedAt *int64 + RecipientPub string // non-empty = recipient-keyed drop (ECDH); recipient's public key EphemeralPub string // non-empty = recipient-keyed drop (ECDH); sender's ephemeral public key } @@ -37,6 +39,7 @@ CREATE TABLE IF NOT EXISTS secrets ( expires_at INTEGER NOT NULL, notify_token TEXT NOT NULL, opened_at INTEGER, + recipient_pub TEXT NOT NULL DEFAULT '', ephemeral_pub TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_secrets_expires ON secrets(expires_at); @@ -55,6 +58,11 @@ func OpenStore(path string) (*Store, error) { // Migrate existing databases: safe to ignore errors when columns already exist. _, _ = db.Exec(`ALTER TABLE secrets ADD COLUMN pin_protected INTEGER NOT NULL DEFAULT 0`) _, _ = db.Exec(`ALTER TABLE secrets ADD COLUMN ephemeral_pub TEXT NOT NULL DEFAULT ''`) + _, err = db.Exec(`ALTER TABLE secrets ADD COLUMN recipient_pub TEXT NOT NULL DEFAULT ''`) + if err != nil && !strings.Contains(err.Error(), "duplicate column name: recipient_pub") { + _ = db.Close() + return nil, err + } return &Store{db: db}, nil } @@ -64,13 +72,108 @@ func now() int64 { return time.Now().Unix() } func (s *Store) Put(id string, sec Secret) error { _, err := s.db.Exec( - `INSERT INTO secrets (id, ciphertext, iv, max_views, views, burned, expires_at, notify_token, ephemeral_pub) - VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)`, - id, sec.Ciphertext, sec.IV, sec.MaxViews, sec.ExpiresAt, sec.NotifyTok, sec.EphemeralPub, + `INSERT INTO secrets (id, ciphertext, iv, max_views, views, burned, expires_at, notify_token, recipient_pub, ephemeral_pub) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`, + id, sec.Ciphertext, sec.IV, sec.MaxViews, sec.ExpiresAt, sec.NotifyTok, sec.RecipientPub, sec.EphemeralPub, ) return err } +// Metadata returns public drop state without reading the encrypted payload. +// Expired rows are reaped lazily. Returns (nil, nil) for a missing or burned drop. +func (s *Store) Metadata(id string) (*Secret, error) { + var sec Secret + var openedAt sql.NullInt64 + var burned int + row := s.db.QueryRow( + `SELECT max_views, views, burned, expires_at, opened_at, recipient_pub, ephemeral_pub + FROM secrets WHERE id = ?`, id) + err := row.Scan(&sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &openedAt, &sec.RecipientPub, &sec.EphemeralPub) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if sec.ExpiresAt <= now() { + _, _ = s.db.Exec(`DELETE FROM secrets WHERE id = ?`, id) + return nil, nil + } + if burned == 1 { + return nil, nil + } + if openedAt.Valid { + v := openedAt.Int64 + sec.OpenedAt = &v + } + return &sec, nil +} + +// Open atomically retrieves and records a view. A final permitted view returns +// its captured payload while burning and wiping the stored payload before commit. +func (s *Store) Open(id string) (*Secret, error) { + tx, err := s.db.BeginTx(context.Background(), nil) + if err != nil { + return nil, err + } + defer tx.Rollback() //nolint:errcheck + + var sec Secret + var burned int + var openedAt sql.NullInt64 + row := tx.QueryRow( + `SELECT ciphertext, iv, max_views, views, burned, expires_at, opened_at, ephemeral_pub + FROM secrets WHERE id = ?`, id) + err = row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &openedAt, &sec.EphemeralPub) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + current := now() + if sec.ExpiresAt <= current { + if _, err := tx.Exec(`DELETE FROM secrets WHERE id = ?`, id); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return nil, nil + } + if burned == 1 || (sec.MaxViews > 0 && sec.Views >= sec.MaxViews) { + return nil, nil + } + + sec.Views++ + if openedAt.Valid { + v := openedAt.Int64 + sec.OpenedAt = &v + } else { + sec.OpenedAt = ¤t + } + + if sec.MaxViews > 0 && sec.Views >= sec.MaxViews { + _, err = tx.Exec( + `UPDATE secrets SET views = ?, burned = 1, ciphertext = '', iv = '', opened_at = ? WHERE id = ?`, + sec.Views, *sec.OpenedAt, id, + ) + } else { + _, err = tx.Exec( + `UPDATE secrets SET views = ?, opened_at = ? WHERE id = ?`, + sec.Views, *sec.OpenedAt, id, + ) + } + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return &sec, nil +} + // Fetch returns the secret only if it's still retrievable (exists, unexpired, // not yet burned). Expired rows are reaped lazily. Returns (nil, nil) for "gone". func (s *Store) Fetch(id string) (*Secret, error) { @@ -78,9 +181,9 @@ func (s *Store) Fetch(id string) (*Secret, error) { var openedAt sql.NullInt64 var burned int row := s.db.QueryRow( - `SELECT ciphertext, iv, max_views, views, burned, expires_at, notify_token, opened_at, ephemeral_pub + `SELECT ciphertext, iv, max_views, views, burned, expires_at, notify_token, opened_at, recipient_pub, ephemeral_pub FROM secrets WHERE id = ?`, id) - err := row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.NotifyTok, &openedAt, &sec.EphemeralPub) + err := row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.NotifyTok, &openedAt, &sec.RecipientPub, &sec.EphemeralPub) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -94,6 +197,9 @@ func (s *Store) Fetch(id string) (*Secret, error) { if burned == 1 { return nil, nil } + if sec.RecipientPub != "" { + return nil, nil + } if openedAt.Valid { v := openedAt.Int64 sec.OpenedAt = &v From 37525f5c397b2f99feb53ba8bd24b8c74762744d Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Thu, 16 Jul 2026 16:54:02 +0000 Subject: [PATCH 2/9] feat(web): atomically open recipient drops --- ashdrop/web/src/lib/api.ts | 35 +++++++++++- .../src/routes/drop-for/[pubkey]/+page.svelte | 3 +- ashdrop/web/src/routes/s/[id]/+page.svelte | 56 ++++++++++++++++--- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/ashdrop/web/src/lib/api.ts b/ashdrop/web/src/lib/api.ts index 87ed2ee..a2920a1 100644 --- a/ashdrop/web/src/lib/api.ts +++ b/ashdrop/web/src/lib/api.ts @@ -2,14 +2,17 @@ const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'; -export interface CreateInput { +interface CreateInputBase { ciphertext: string; iv: string; ttl: number; // seconds maxViews: number; // 0 = unlimited - ephemeralPub?: string; // non-empty = recipient-keyed ECDH drop } +export type CreateInput = + | (CreateInputBase & { ephemeralPub: string; recipientPub: string }) + | (CreateInputBase & { ephemeralPub?: never; recipientPub?: never }); + export interface CreateResult { id: string; notifyToken: string; @@ -24,6 +27,20 @@ export interface FetchedSecret { recipientKeyed: boolean; } +export interface OpenedSecret { + ciphertext: string; + iv: string; + ephemeralPub: string; + recipientKeyed: boolean; +} + +export interface DropMetadata { + recipientKeyed: boolean; + recipientPub: string; + expiresAt: number; + viewsLeft: number; +} + export async function createSecret(input: CreateInput): Promise { const r = await fetch(`${BASE}/api/secrets`, { method: 'POST', @@ -43,6 +60,20 @@ export async function fetchSecret(id: string): Promise { return r.json(); } +export async function fetchMetadata(id: string): Promise { + const r = await fetch(`${BASE}/api/secrets/${id}/metadata`); + if (r.status === 404) return null; + if (!r.ok) throw new Error('Could not load the drop.'); + return r.json(); +} + +export async function openSecret(id: string): Promise { + const r = await fetch(`${BASE}/api/secrets/${id}/open`, { method: 'POST' }); + if (r.status === 404) return null; + if (!r.ok) throw new Error('Could not open the drop.'); + return r.json(); +} + export async function burnSecret(id: string): Promise { await fetch(`${BASE}/api/secrets/${id}/burn`, { method: 'POST' }); } diff --git a/ashdrop/web/src/routes/drop-for/[pubkey]/+page.svelte b/ashdrop/web/src/routes/drop-for/[pubkey]/+page.svelte index eab0d58..f0f6fd9 100644 --- a/ashdrop/web/src/routes/drop-for/[pubkey]/+page.svelte +++ b/ashdrop/web/src/routes/drop-for/[pubkey]/+page.svelte @@ -60,7 +60,8 @@ iv: sealed.iv, ttl, maxViews, - ephemeralPub: sealed.ephemeralPub + ephemeralPub: sealed.ephemeralPub, + recipientPub: recipientPubB64 }); result = res; dropLink = `${location.origin}/s/${res.id}`; diff --git a/ashdrop/web/src/routes/s/[id]/+page.svelte b/ashdrop/web/src/routes/s/[id]/+page.svelte index b0e722d..0de6fb2 100644 --- a/ashdrop/web/src/routes/s/[id]/+page.svelte +++ b/ashdrop/web/src/routes/s/[id]/+page.svelte @@ -2,8 +2,8 @@ import { onMount } from 'svelte'; import { page } from '$app/state'; import { parseEnv, maskValue, type EnvPair } from '$lib/env'; - import { decryptSecret, decryptWithMyKey, hasMyKeyPair } from '$lib/crypto'; - import { fetchSecret, burnSecret } from '$lib/api'; + import { decryptSecret, decryptWithMyKey, hasMyKeyPair, myPublicKeyB64 } from '$lib/crypto'; + import { fetchMetadata, fetchSecret, openSecret, burnSecret } from '$lib/api'; type Phase = 'loading' | 'sealed' | 'recipient-sealed' | 'no-local-key' | 'burning' | 'revealing' | 'revealed' | 'gone' | 'nokey' | 'error'; let phase = $state('loading'); @@ -12,6 +12,9 @@ const id = page.params.id ?? ''; let keyB64 = ''; let cipher: { ciphertext: string; iv: string; ephemeralPub: string; recipientKeyed: boolean } | null = null; + let opensOnReveal = false; + let expectedRecipientPub = ''; + let recipientKeyMismatch = $state(false); let plaintext = $state(''); let pairs = $state([]); @@ -32,6 +35,22 @@ onMount(async () => { try { + const metadata = await fetchMetadata(id); + if (!metadata) { phase = 'gone'; return; } + if (metadata.recipientKeyed && metadata.recipientPub) { + const hasKeyPair = hasMyKeyPair(); + const matchesRecipient = myPublicKeyB64() === metadata.recipientPub; + if (!matchesRecipient || !hasKeyPair) { + recipientKeyMismatch = hasKeyPair && !matchesRecipient; + phase = 'no-local-key'; + return; + } + expectedRecipientPub = metadata.recipientPub; + opensOnReveal = true; + phase = 'recipient-sealed'; + return; + } + const s = await fetchSecret(id); if (!s) { phase = 'gone'; return; } cipher = { ciphertext: s.ciphertext, iv: s.iv, ephemeralPub: s.ephemeralPub, recipientKeyed: s.recipientKeyed }; @@ -48,22 +67,37 @@ }); async function reveal() { - if (!cipher) return; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; phase = reduce ? 'revealing' : 'burning'; + let secret = cipher; try { - if (cipher.recipientKeyed) { - plaintext = await decryptWithMyKey(cipher.ciphertext, cipher.iv, cipher.ephemeralPub); + if (opensOnReveal) { + const hasKeyPair = hasMyKeyPair(); + const currentRecipientPub = myPublicKeyB64(); + if (!hasKeyPair || currentRecipientPub !== expectedRecipientPub) { + recipientKeyMismatch = hasKeyPair && currentRecipientPub !== expectedRecipientPub; + phase = 'no-local-key'; + return; + } + const opened = await openSecret(id); + if (!opened) { phase = 'gone'; return; } + secret = opened; + cipher = opened; + } + if (!secret) return; + + if (secret.recipientKeyed) { + plaintext = await decryptWithMyKey(secret.ciphertext, secret.iv, secret.ephemeralPub); } else { - plaintext = await decryptSecret(cipher.ciphertext, cipher.iv, keyB64); + plaintext = await decryptSecret(secret.ciphertext, secret.iv, keyB64); } pairs = parseEnv(plaintext); - await burnSecret(id); + if (!opensOnReveal) await burnSecret(id); if (!reduce) await sleep(1000); phase = 'revealed'; } catch { phase = 'error'; - errMsg = cipher.recipientKeyed + errMsg = secret?.recipientKeyed || opensOnReveal ? 'Could not decrypt. Make sure you are on the same browser where you set up your receive address.' : 'This link is invalid or was tampered with.'; } @@ -103,7 +137,11 @@ {:else if phase === 'no-local-key'}

Wrong browser or device.

-

This drop was encrypted for a specific receive address. Open it on the browser where you set up your key.

+ {#if recipientKeyMismatch} +

This drop targets a different local receive address. Open it on the browser where that address was set up.

+ {:else} +

This drop was encrypted for a specific receive address. Open it on the browser where you set up your key.

+ {/if} Check your receive address →
From 6a9578194c6eab5b1aabf399823d19f0dafb8876 Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Thu, 16 Jul 2026 16:54:20 +0000 Subject: [PATCH 3/9] feat(cli): add encrypted env sharing --- .gitignore | 5 + ashdrop/cli/README.md | 133 ++++ ashdrop/cli/build.zig | 34 + ashdrop/cli/build.zig.zon | 12 + ashdrop/cli/src/api.zig | 438 +++++++++++ ashdrop/cli/src/commands.zig | 713 ++++++++++++++++++ ashdrop/cli/src/config.zig | 83 ++ ashdrop/cli/src/crypto.zig | 269 +++++++ ashdrop/cli/src/crypto_test.zig | 265 +++++++ ashdrop/cli/src/files.zig | 228 ++++++ ashdrop/cli/src/identity.zig | 338 +++++++++ ashdrop/cli/src/links.zig | 374 +++++++++ ashdrop/cli/src/main.zig | 574 ++++++++++++++ ashdrop/cli/src/test_server.zig | 99 +++ ashdrop/cli/test.zig | 6 + ashdrop/cli/testdata/generate-protocol-v1.mjs | 79 ++ ashdrop/cli/testdata/protocol-v1.json | 23 + 17 files changed, 3673 insertions(+) create mode 100644 ashdrop/cli/README.md create mode 100644 ashdrop/cli/build.zig create mode 100644 ashdrop/cli/build.zig.zon create mode 100644 ashdrop/cli/src/api.zig create mode 100644 ashdrop/cli/src/commands.zig create mode 100644 ashdrop/cli/src/config.zig create mode 100644 ashdrop/cli/src/crypto.zig create mode 100644 ashdrop/cli/src/crypto_test.zig create mode 100644 ashdrop/cli/src/files.zig create mode 100644 ashdrop/cli/src/identity.zig create mode 100644 ashdrop/cli/src/links.zig create mode 100644 ashdrop/cli/src/main.zig create mode 100644 ashdrop/cli/src/test_server.zig create mode 100644 ashdrop/cli/test.zig create mode 100644 ashdrop/cli/testdata/generate-protocol-v1.mjs create mode 100644 ashdrop/cli/testdata/protocol-v1.json diff --git a/.gitignore b/.gitignore index a80f7d0..c22e78a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ ashdrop/api/ashdrop *.db-shm *.db-wal +# zig CLI +ashdrop/cli/.zig-cache/ +ashdrop/cli/zig-out/ +docs/* + # env / local .env .env.local diff --git a/ashdrop/cli/README.md b/ashdrop/cli/README.md new file mode 100644 index 0000000..d67318a --- /dev/null +++ b/ashdrop/cli/README.md @@ -0,0 +1,133 @@ +# Ashdrop CLI + +`ashdrop` encrypts an environment file for a recipient's local receive +identity, stores the encrypted drop through the Ashdrop API, and writes a +received file locally. It does not print environment-file plaintext. + +## Build + +Run these commands from `ashdrop/cli`. `build.zig.zon` declares Zig +`0.17.0-dev.1398+cb5635714` as the minimum and verified version; it does not +enforce an exact toolchain pin. + +```sh +# Development build; installs ./zig-out/bin/ashdrop +zig build + +# Run without separately invoking the installed executable +zig build run -- address create + +# Unit tests +zig build test + +# Release-safe build; installs ./zig-out/bin/ashdrop +zig build -Doptimize=ReleaseSafe +``` + +## Receive Identity and Address + +Create a local recipient identity once: + +```sh +./zig-out/bin/ashdrop address create +``` + +This creates `~/.config/ashdrop/identity.json`, with its directory set to mode +`0700` and the identity file set to mode `0600`. The command will not replace +an existing identity. It prints a shareable receive address. + +```sh +# Print the existing shareable receive address +./zig-out/bin/ashdrop address + +# Print only the base64url P-256 public key +./zig-out/bin/ashdrop address --raw +``` + +The identity file contains the private key needed to decrypt drops. The CLI has +no private-key export command. Browser and CLI identities are separate; there +is no automatic identity transfer between them. + +## Share + +```sh +./zig-out/bin/ashdrop share --to --file .env \ + [--ttl 1h|24h|7d] [--views ] [--api ] [--web ] +``` + +`--to` accepts a receive URL, the raw public key from `address --raw`, or an +Ashdrop receive URI. `--file` must name a UTF-8 file no larger than 64 KiB. +The default TTL is `24h`; valid TTL values are `1h`, `24h`, and `7d`. The +default view limit is `1`. `--views` accepts a non-negative decimal value; +`0` is unlimited in the current API. + +The command encrypts the file for the recipient and prints only the resulting +drop reference. It never prints the source plaintext. + +## Pull + +```sh +./zig-out/bin/ashdrop pull \ + [-o |--output ] [--force] [--api ] +``` + +Standalone smoke example: + +```sh +./zig-out/bin/ashdrop pull -o .env.ashdrop +``` + +`` may be a drop URL, a 32-character drop ID, or an Ashdrop +drop URI. The default output is `.env.ashdrop`. Existing files are refused +unless `--force` is supplied. The output path and its parent directories must +not be symlinks, a directory cannot be used as the output, and the plaintext is +atomically written with mode `0600`. The destination is reserved before any +metadata or open request, so unsafe or existing output paths do not consume a +drop view. + +Pull requires the local `~/.config/ashdrop/identity.json`. Before opening a +drop, the CLI checks its recipient public key against that identity. A drop for +a different identity is not opened. The metadata request does not consume a +view; an API `open` request does. Consequently, with the default one-view +limit, the first permitted `open` burns the server-side ciphertext even if a +later local decrypt or file write fails. Plaintext is written only to the +output file and is never printed to stdout or stderr. + +## Managed and Self-Hosted Endpoints + +The managed defaults are: + +```text +API: https://ashdrop.onrender.com +Web: https://ashdrop.vercel.app +``` + +`address` and `share` accept `--api` and `--web`. `pull` accepts `--api`. +`ASHDROP_API_URL` provides the API endpoint for all network commands, +including `share` and `pull` (and is also used when `address` formats a +self-hosted receive reference). `ASHDROP_WEB_URL` affects only the human URLs +emitted by `address` and `share`; `pull` does not use it. An explicit `--api` +takes precedence over an API embedded in an Ashdrop URI, which takes precedence +over `ASHDROP_API_URL`; otherwise the managed API is used. `--web` takes +precedence over `ASHDROP_WEB_URL`; otherwise links using the managed API use +the managed web URL. + +For a custom API without a configured web URL, `address` and `share` print +self-contained Ashdrop URIs instead of web URLs: + +```text +ashdrop://receive/?api= +ashdrop://drop/?api= +``` + +These URIs preserve the API endpoint when passed to `share --to` or `pull`. +To use a local API and web application directly: + +```sh +ASHDROP_API_URL=http://127.0.0.1:8080 \ +ASHDROP_WEB_URL=http://127.0.0.1:5173 \ +./zig-out/bin/ashdrop address create +``` + +Use `--api` for a per-command API override. `--web` is available only on +`address` and `share`. diff --git a/ashdrop/cli/build.zig b/ashdrop/cli/build.zig new file mode 100644 index 0000000..6e2939b --- /dev/null +++ b/ashdrop/cli/build.zig @@ -0,0 +1,34 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "ashdrop", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + run_cmd.addPassthruArgs(); + const run_step = b.step("run", "Run the Ashdrop CLI"); + run_step.dependOn(&run_cmd.step); + + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("test.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + const run_tests = b.addRunArtifact(tests); + const test_step = b.step("test", "Run CLI unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/ashdrop/cli/build.zig.zon b/ashdrop/cli/build.zig.zon new file mode 100644 index 0000000..d74c431 --- /dev/null +++ b/ashdrop/cli/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .ashdrop, + .version = "0.1.0", + .minimum_zig_version = "0.17.0-dev.1398+cb5635714", + .fingerprint = 0xc50801f2eac2c15d, + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/ashdrop/cli/src/api.zig b/ashdrop/cli/src/api.zig new file mode 100644 index 0000000..841bc3c --- /dev/null +++ b/ashdrop/cli/src/api.zig @@ -0,0 +1,438 @@ +const std = @import("std"); + +pub const max_response_size = 96 * 1024; + +pub const CreateInput = struct { + ciphertext: []const u8, + iv: []const u8, + ttl: u64, + maxViews: u32, + ephemeralPub: []const u8, + recipientPub: []const u8, +}; + +pub const CreateResult = struct { + id: []u8, + notifyToken: []u8, + expiresAt: i64, + + pub fn deinit(self: *CreateResult, allocator: std.mem.Allocator) void { + allocator.free(self.id); + allocator.free(self.notifyToken); + self.* = undefined; + } +}; + +pub const Metadata = struct { + recipientKeyed: bool, + recipientPub: []u8, + expiresAt: i64, + viewsLeft: i64, + + pub fn deinit(self: *Metadata, allocator: std.mem.Allocator) void { + allocator.free(self.recipientPub); + self.* = undefined; + } +}; + +pub const OpenedSecret = struct { + ciphertext: []u8, + iv: []u8, + ephemeralPub: []u8, + recipientKeyed: bool, + + pub fn deinit(self: *OpenedSecret, allocator: std.mem.Allocator) void { + allocator.free(self.ciphertext); + allocator.free(self.iv); + allocator.free(self.ephemeralPub); + self.* = undefined; + } +}; + +pub const Client = struct { + allocator: std.mem.Allocator, + io: std.Io, + base_url: []const u8, + + pub fn create(self: *Client, input: CreateInput) !CreateResult { + const body = try encodeCreate(self.allocator, input); + defer self.allocator.free(body); + const response = try self.request(.POST, "/api/secrets", body); + defer self.allocator.free(response.body); + if (response.status < 200 or response.status >= 300) { + try checkErrorResponse(response.status, response.body); + return error.RemoteFailure; + } + return parseCreateResult(self.allocator, response.body); + } + + pub fn metadata(self: *Client, id: []const u8) !?Metadata { + const path = try std.fmt.allocPrint(self.allocator, "/api/secrets/{s}/metadata", .{id}); + defer self.allocator.free(path); + const response = try self.request(.GET, path, null); + defer self.allocator.free(response.body); + if (response.status == 404) return null; + if (response.status < 200 or response.status >= 300) { + try checkErrorResponse(response.status, response.body); + return error.RemoteFailure; + } + return try parseMetadata(self.allocator, response.body); + } + + pub fn open(self: *Client, id: []const u8) !?OpenedSecret { + const path = try std.fmt.allocPrint(self.allocator, "/api/secrets/{s}/open", .{id}); + defer self.allocator.free(path); + const response = try self.request(.POST, path, ""); + defer self.allocator.free(response.body); + if (response.status == 404) return null; + if (response.status < 200 or response.status >= 300) { + try checkErrorResponse(response.status, response.body); + return error.RemoteFailure; + } + return try parseOpenedSecret(self.allocator, response.body); + } + + const Response = struct { + status: u16, + body: []u8, + }; + + fn request(self: *Client, method: std.http.Method, path: []const u8, payload: ?[]const u8) !Response { + const url = try endpointUrl(self.allocator, self.base_url, path); + defer self.allocator.free(url); + + var response_buffer: [max_response_size]u8 = undefined; + var response_writer = std.Io.Writer.fixed(&response_buffer); + var http_client: std.http.Client = .{ + .allocator = self.allocator, + .io = self.io, + }; + defer http_client.deinit(); + const headers = [_]std.http.Header{ + .{ .name = "content-type", .value = "application/json" }, + }; + const result = http_client.fetch(.{ + .location = .{ .url = url }, + .method = method, + .payload = payload, + .redirect_behavior = .not_allowed, + .extra_headers = &headers, + .response_writer = &response_writer, + }) catch |err| switch (err) { + error.WriteFailed => return error.ResponseTooLarge, + error.TooManyHttpRedirects => return error.RemoteFailure, + else => return err, + }; + return .{ + .status = @intFromEnum(result.status), + .body = try self.allocator.dupe(u8, response_writer.buffered()), + }; + } +}; + +pub fn encodeCreate(allocator: std.mem.Allocator, input: CreateInput) ![]u8 { + return jsonBody(allocator, input); +} + +fn jsonBody(allocator: std.mem.Allocator, value: anytype) ![]u8 { + var buffer = std.Io.Writer.Allocating.init(allocator); + defer buffer.deinit(); + var stringify = std.json.Stringify{ .writer = &buffer.writer }; + try stringify.write(value); + return allocator.dupe(u8, buffer.written()); +} + +pub fn endpointUrl(allocator: std.mem.Allocator, base_url: []const u8, path: []const u8) ![]u8 { + var base_end = base_url.len; + while (base_end > 0 and base_url[base_end - 1] == '/') : (base_end -= 1) {} + if (base_end == 0 or path.len == 0 or path[0] != '/') return error.InvalidEndpoint; + + var escaped_len: usize = 0; + for (path) |byte| { + escaped_len += if (byte == '/' or isUnreserved(byte)) 1 else 3; + } + const out = try allocator.alloc(u8, base_end + escaped_len); + @memcpy(out[0..base_end], base_url[0..base_end]); + var index = base_end; + for (path) |byte| { + if (byte == '/' or isUnreserved(byte)) { + out[index] = byte; + index += 1; + } else { + out[index] = '%'; + out[index + 1] = hexDigit(byte >> 4); + out[index + 2] = hexDigit(byte & 0x0f); + index += 3; + } + } + return out; +} + +pub fn checkErrorResponse(status: u16, body: []const u8) !void { + if (status == 429) return error.RateLimited; + const ErrorResponse = struct { @"error": []const u8 }; + var parsed = std.json.parseFromSlice(ErrorResponse, std.heap.page_allocator, body, .{}) catch return error.RemoteFailure; + defer parsed.deinit(); + if (parsed.value.@"error".len == 0) return error.RemoteFailure; + return error.RemoteFailure; +} + +fn parseCreateResult(allocator: std.mem.Allocator, body: []const u8) !CreateResult { + const Wire = struct { + id: []const u8, + notifyToken: []const u8, + expiresAt: i64, + }; + var parsed = std.json.parseFromSlice(Wire, allocator, body, .{}) catch return error.InvalidResponse; + defer parsed.deinit(); + if (!isCanonicalDropId(parsed.value.id)) return error.InvalidResponse; + const id = try allocator.dupe(u8, parsed.value.id); + errdefer allocator.free(id); + const notify_token = try allocator.dupe(u8, parsed.value.notifyToken); + return .{ + .id = id, + .notifyToken = notify_token, + .expiresAt = parsed.value.expiresAt, + }; +} + +fn parseMetadata(allocator: std.mem.Allocator, body: []const u8) !Metadata { + const Wire = struct { + recipientKeyed: bool, + recipientPub: []const u8, + expiresAt: i64, + viewsLeft: i64, + }; + var parsed = std.json.parseFromSlice(Wire, allocator, body, .{}) catch return error.InvalidResponse; + defer parsed.deinit(); + const recipient_pub = try allocator.dupe(u8, parsed.value.recipientPub); + return .{ + .recipientKeyed = parsed.value.recipientKeyed, + .recipientPub = recipient_pub, + .expiresAt = parsed.value.expiresAt, + .viewsLeft = parsed.value.viewsLeft, + }; +} + +fn parseOpenedSecret(allocator: std.mem.Allocator, body: []const u8) !OpenedSecret { + const Wire = struct { + ciphertext: []const u8, + iv: []const u8, + ephemeralPub: []const u8, + recipientKeyed: bool, + }; + var parsed = std.json.parseFromSlice(Wire, allocator, body, .{}) catch return error.InvalidResponse; + defer parsed.deinit(); + const ciphertext = try allocator.dupe(u8, parsed.value.ciphertext); + errdefer allocator.free(ciphertext); + const iv = try allocator.dupe(u8, parsed.value.iv); + errdefer allocator.free(iv); + const ephemeral_pub = try allocator.dupe(u8, parsed.value.ephemeralPub); + return .{ + .ciphertext = ciphertext, + .iv = iv, + .ephemeralPub = ephemeral_pub, + .recipientKeyed = parsed.value.recipientKeyed, + }; +} + +fn isUnreserved(byte: u8) bool { + return (byte >= 'a' and byte <= 'z') or + (byte >= 'A' and byte <= 'Z') or + (byte >= '0' and byte <= '9') or + byte == '-' or byte == '.' or byte == '_' or byte == '~'; +} + +fn isCanonicalDropId(id: []const u8) bool { + if (id.len != 32) return false; + for (id) |byte| { + if (!(byte >= '0' and byte <= '9') and !(byte >= 'a' and byte <= 'f')) return false; + } + return true; +} + +fn hexDigit(value: u8) u8 { + return if (value < 10) '0' + value else 'A' + value - 10; +} + +test "create JSON retains recipient public key and encrypted fields" { + const body = try encodeCreate(std.testing.allocator, .{ + .ciphertext = "ciphertext-value", + .iv = "nonce-value", + .ttl = 86400, + .maxViews = 1, + .ephemeralPub = "ephemeral-key", + .recipientPub = "recipient-key", + }); + defer std.testing.allocator.free(body); + + try std.testing.expect(std.mem.indexOf(u8, body, "\"recipientPub\":\"recipient-key\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"ciphertext\":\"ciphertext-value\"") != null); +} + +test "URL joining escapes secret identifiers and avoids duplicate slashes" { + const url = try endpointUrl(std.testing.allocator, "http://127.0.0.1:18080/", "/api/secrets/a b/metadata"); + defer std.testing.allocator.free(url); + try std.testing.expectEqualStrings("http://127.0.0.1:18080/api/secrets/a%20b/metadata", url); +} + +test "API error JSON maps to a safe generic failure" { + try std.testing.expectError(error.RemoteFailure, checkErrorResponse(400, "{\"error\":\"ciphertext leaked\"}")); + try std.testing.expectError(error.RateLimited, checkErrorResponse(429, "{\"error\":\"slow down\"}")); + try std.testing.expectError(error.RateLimited, checkErrorResponse(429, "not JSON")); +} + +test "HTTP client uses the create metadata and open API contract" { + const test_server = @import("test_server.zig"); + const id = "0123456789abcdef0123456789abcdef"; + const expected = [_]test_server.ExpectedRequest{ + .{ + .method = .POST, + .target = "/api/secrets", + .json_body = true, + .response_status = @enumFromInt(201), + .response_body = "{\"id\":\"0123456789abcdef0123456789abcdef\",\"notifyToken\":\"notify\",\"expiresAt\":1}", + }, + .{ + .method = .GET, + .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", + .response_status = .ok, + .response_body = "{\"recipientKeyed\":true,\"recipientPub\":\"recipient\",\"expiresAt\":1,\"viewsLeft\":1}", + }, + .{ + .method = .POST, + .target = "/api/secrets/0123456789abcdef0123456789abcdef/open", + .response_status = .ok, + .response_body = "{\"ciphertext\":\"ciphertext\",\"iv\":\"iv\",\"ephemeralPub\":\"ephemeral\",\"recipientKeyed\":true}", + }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + + var created = try client.create(.{ + .ciphertext = "ciphertext", + .iv = "iv", + .ttl = 86400, + .maxViews = 1, + .ephemeralPub = "ephemeral", + .recipientPub = "recipient", + }); + defer created.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(id, created.id); + var metadata = (try client.metadata(id)).?; + defer metadata.deinit(std.testing.allocator); + try std.testing.expect(metadata.recipientKeyed); + var opened = (try client.open(id)).?; + defer opened.deinit(std.testing.allocator); + try std.testing.expect(opened.recipientKeyed); + try server.deinit(); + server_live = false; +} + +test "HTTP client maps missing rate-limited error and capped responses safely" { + const test_server = @import("test_server.zig"); + const id = "0123456789abcdef0123456789abcdef"; + { + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", .response_status = .not_found, .response_body = "{\"error\":\"gone\"}" }, + .{ .method = .POST, .target = "/api/secrets/0123456789abcdef0123456789abcdef/open", .response_status = .not_found, .response_body = "{\"error\":\"gone\"}" }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + try std.testing.expect((try client.metadata(id)) == null); + try std.testing.expect((try client.open(id)) == null); + try server.deinit(); + server_live = false; + } + { + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", .response_status = .too_many_requests, .response_body = "{\"error\":\"slow down\"}" }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + try std.testing.expectError(error.RateLimited, client.metadata(id)); + try server.deinit(); + server_live = false; + } + { + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", .response_status = .bad_request, .response_body = "{\"error\":\"TOKEN=never-print-this\"}" }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + try std.testing.expectError(error.RemoteFailure, client.metadata(id)); + try server.deinit(); + server_live = false; + } + { + var oversized: [max_response_size + 1]u8 = undefined; + @memset(&oversized, 'x'); + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", .response_status = .ok, .response_body = &oversized }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + try std.testing.expectError(error.ResponseTooLarge, client.metadata(id)); + try server.deinit(); + server_live = false; + } +} + +test "HTTP client rejects redirects without following their location" { + const test_server = @import("test_server.zig"); + const id = "0123456789abcdef0123456789abcdef"; + var location_header = [_]std.http.Header{.{ .name = "location", .value = undefined }}; + var expected = [_]test_server.ExpectedRequest{ + .{ + .method = .GET, + .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", + .response_status = @enumFromInt(302), + .response_body = "", + .response_headers = &location_header, + }, + .{ + .method = .GET, + .target = "/follow-up", + .response_status = .ok, + .response_body = "{\"recipientKeyed\":true,\"recipientPub\":\"recipient\",\"expiresAt\":1,\"viewsLeft\":1}", + }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var location_buffer: [128]u8 = undefined; + location_header[0].value = try std.fmt.bufPrint(&location_buffer, "{s}/follow-up", .{base_url}); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + + try std.testing.expectError(error.RemoteFailure, client.metadata(id)); + try std.testing.expectError(error.TestServerMissedRequest, server.deinit()); + server_live = false; +} diff --git a/ashdrop/cli/src/commands.zig b/ashdrop/cli/src/commands.zig new file mode 100644 index 0000000..ccb9b68 --- /dev/null +++ b/ashdrop/cli/src/commands.zig @@ -0,0 +1,713 @@ +const std = @import("std"); +const api = @import("api.zig"); +const config = @import("config.zig"); +const crypto = @import("crypto.zig"); +const files = @import("files.zig"); +const identity = @import("identity.zig"); +const links = @import("links.zig"); + +pub const Runtime = struct { + allocator: std.mem.Allocator, + io: std.Io, + cwd: std.Io.Dir, + home_base: std.Io.Dir, + home: []const u8, + api_env: ?[]const u8 = null, + web_env: ?[]const u8 = null, + stdout: *std.Io.Writer, + stderr: *std.Io.Writer, +}; + +pub const ShareOptions = struct { + to: []const u8 = "", + file: []const u8 = "", + ttl: u64 = 24 * 60 * 60, + views: u32 = 1, + api: ?[]const u8 = null, + web: ?[]const u8 = null, +}; + +pub const PullOptions = struct { + drop: []const u8, + output: []const u8 = ".env.ashdrop", + force: bool = false, + api: ?[]const u8 = null, +}; + +pub const ResolvedPull = struct { + options: PullOptions, + endpoint: []const u8, + drop: links.DropRef, + + pub fn deinit(self: *ResolvedPull, allocator: std.mem.Allocator) void { + self.drop.deinit(allocator); + self.* = undefined; + } +}; + +pub const EncodedCreate = struct { + body: []u8, +}; + +pub const PullRemote = struct { + context: ?*anyopaque, + metadata: *const fn (context: ?*anyopaque, id: []const u8) anyerror!?api.Metadata, + open: *const fn (context: ?*anyopaque, id: []const u8) anyerror!?api.OpenedSecret, +}; + +pub fn parseShareArgs(args: []const []const u8) error{Usage}!ShareOptions { + if (args.len == 0 or !std.mem.eql(u8, args[0], "share")) return error.Usage; + + var to: ?[]const u8 = null; + var file: ?[]const u8 = null; + var options = ShareOptions{}; + var ttl_set = false; + var views_set = false; + var index: usize = 1; + while (index < args.len) : (index += 1) { + const arg = args[index]; + if (std.mem.eql(u8, arg, "--to") or std.mem.eql(u8, arg, "--file") or std.mem.eql(u8, arg, "--ttl") or std.mem.eql(u8, arg, "--views") or std.mem.eql(u8, arg, "--api") or std.mem.eql(u8, arg, "--web")) { + index += 1; + if (index == args.len or args[index].len == 0) return error.Usage; + const value = args[index]; + if (std.mem.eql(u8, arg, "--to")) { + if (to != null) return error.Usage; + to = value; + } else if (std.mem.eql(u8, arg, "--file")) { + if (file != null) return error.Usage; + file = value; + } else if (std.mem.eql(u8, arg, "--ttl")) { + if (ttl_set) return error.Usage; + options.ttl = parseTtl(value) catch return error.Usage; + ttl_set = true; + } else if (std.mem.eql(u8, arg, "--views")) { + if (views_set) return error.Usage; + options.views = parseViews(value) catch return error.Usage; + views_set = true; + } else if (std.mem.eql(u8, arg, "--api")) { + if (options.api != null) return error.Usage; + options.api = value; + } else { + if (options.web != null) return error.Usage; + options.web = value; + } + continue; + } + return error.Usage; + } + return .{ + .to = to orelse return error.Usage, + .file = file orelse return error.Usage, + .ttl = options.ttl, + .views = options.views, + .api = options.api, + .web = options.web, + }; +} + +pub fn parsePullArgs(args: []const []const u8) error{Usage}!PullOptions { + if (args.len == 0 or !std.mem.eql(u8, args[0], "pull")) return error.Usage; + + var drop: ?[]const u8 = null; + var options = PullOptions{ .drop = undefined }; + var output_set = false; + var index: usize = 1; + while (index < args.len) : (index += 1) { + const arg = args[index]; + if (std.mem.eql(u8, arg, "--force")) { + if (options.force) return error.Usage; + options.force = true; + continue; + } + if (std.mem.eql(u8, arg, "-o") or std.mem.eql(u8, arg, "--output") or std.mem.eql(u8, arg, "--api")) { + index += 1; + if (index == args.len or args[index].len == 0) return error.Usage; + const value = args[index]; + if (std.mem.eql(u8, arg, "--api")) { + if (options.api != null) return error.Usage; + options.api = value; + } else { + if (output_set) return error.Usage; + options.output = value; + output_set = true; + } + continue; + } + if (drop != null or std.mem.startsWith(u8, arg, "-")) return error.Usage; + drop = arg; + } + options.drop = drop orelse return error.Usage; + return options; +} + +pub fn resolvePull(allocator: std.mem.Allocator, args: []const []const u8, api_env: ?[]const u8) !ResolvedPull { + var options = try parsePullArgs(args); + var drop = try links.parseDrop(allocator, options.drop); + errdefer drop.deinit(allocator); + const endpoint = try config.resolveApi(options.api, api_env, drop.api); + options.drop = drop.id; + return .{ + .options = options, + .endpoint = endpoint, + .drop = drop, + }; +} + +pub fn buildCreateRequest( + allocator: std.mem.Allocator, + io: std.Io, + plaintext: []const u8, + recipient_sec1: [65]u8, + options: ShareOptions, +) !EncodedCreate { + var sealed = try crypto.sealForRecipient(allocator, io, plaintext, &recipient_sec1); + defer sealed.deinit(allocator); + const body = try api.encodeCreate(allocator, .{ + .ciphertext = sealed.ciphertext, + .iv = sealed.iv, + .ttl = options.ttl, + .maxViews = options.views, + .ephemeralPub = sealed.ephemeral_pub, + .recipientPub = &links.formatRawReceive(recipient_sec1), + }); + errdefer allocator.free(body); + try files.requireApiBodySize(body); + return .{ .body = body }; +} + +pub fn share(args: []const []const u8, runtime: Runtime) !void { + const options = try parseShareArgs(args); + var recipient = try links.parseReceive(runtime.allocator, options.to); + defer recipient.deinit(runtime.allocator); + const endpoint = try config.resolveApi(options.api, runtime.api_env, recipient.api); + _ = try config.resolveWeb(options.web, runtime.web_env); + + const plaintext = files.readEnv(runtime.allocator, runtime.io, runtime.cwd, options.file) catch |err| switch (err) { + error.FileNotFound => return error.InputFileMissing, + else => return err, + }; + defer { + std.crypto.secureZero(u8, plaintext); + runtime.allocator.free(plaintext); + } + const encoded = try buildCreateRequest(runtime.allocator, runtime.io, plaintext, recipient.key, options); + defer runtime.allocator.free(encoded.body); + var request = try std.json.parseFromSlice(api.CreateInput, runtime.allocator, encoded.body, .{}); + defer request.deinit(); + + var client = api.Client{ + .allocator = runtime.allocator, + .io = runtime.io, + .base_url = endpoint, + }; + var result = try client.create(request.value); + defer result.deinit(runtime.allocator); + const drop = try links.formatDrop( + runtime.allocator, + result.id, + endpoint, + config.configuredWeb(options.web, runtime.web_env), + ); + defer runtime.allocator.free(drop); + try runtime.stdout.print("{s}\n", .{drop}); +} + +pub fn pull(args: []const []const u8, runtime: Runtime) !void { + var target = try resolvePull(runtime.allocator, args, runtime.api_env); + defer target.deinit(runtime.allocator); + var client = api.Client{ + .allocator = runtime.allocator, + .io = runtime.io, + .base_url = target.endpoint, + }; + try pullWithRemote(target.options, runtime, .{ + .context = &client, + .metadata = metadataFromClient, + .open = openFromClient, + }); +} + +pub fn pullWithRemote(options: PullOptions, runtime: Runtime, remote: PullRemote) !void { + var output = files.prepareOutput(runtime.allocator, runtime.io, runtime.cwd, options.output, options.force) catch |err| switch (err) { + error.OutputExists, + error.OutputIsDirectory, + error.UnsafeOutputPath, + => return err, + else => return error.OutputPreparationFailed, + }; + defer output.deinit(runtime.allocator, runtime.io); + + var config_dir = try identity.openConfigDirAt(runtime.io, runtime.home_base, runtime.home); + defer config_dir.close(runtime.io); + const local_identity = try identity.load(runtime.allocator, runtime.io, config_dir, "identity.json"); + const local_public = links.formatRawReceive(local_identity.publicSec1()); + + var metadata = (try remote.metadata(remote.context, options.drop)) orelse return error.DropUnavailable; + defer metadata.deinit(runtime.allocator); + if (!metadata.recipientKeyed or !std.mem.eql(u8, metadata.recipientPub, &local_public)) return error.RecipientMismatch; + + var opened = (try remote.open(remote.context, options.drop)) orelse return error.DropUnavailable; + defer opened.deinit(runtime.allocator); + if (!opened.recipientKeyed) return error.InvalidResponse; + const plaintext = try crypto.openForRecipient( + runtime.allocator, + &local_identity.d, + opened.ciphertext, + opened.iv, + opened.ephemeralPub, + ); + defer { + std.crypto.secureZero(u8, plaintext); + runtime.allocator.free(plaintext); + } + if (!std.unicode.utf8ValidateSlice(plaintext)) return error.InvalidUtf8; + files.writeAtomically(runtime.io, &output, plaintext) catch |err| switch (err) { + error.OutputExists => return err, + else => return error.OutputWriteFailed, + }; + try runtime.stderr.print("{s}\n", .{options.output}); +} + +fn metadataFromClient(context: ?*anyopaque, id: []const u8) anyerror!?api.Metadata { + const client: *api.Client = @ptrCast(@alignCast(context.?)); + return client.metadata(id); +} + +fn openFromClient(context: ?*anyopaque, id: []const u8) anyerror!?api.OpenedSecret { + const client: *api.Client = @ptrCast(@alignCast(context.?)); + return client.open(id); +} + +fn parseTtl(value: []const u8) error{InvalidTtl}!u64 { + if (std.mem.eql(u8, value, "1h")) return 60 * 60; + if (std.mem.eql(u8, value, "24h")) return 24 * 60 * 60; + if (std.mem.eql(u8, value, "7d")) return 7 * 24 * 60 * 60; + return error.InvalidTtl; +} + +fn parseViews(value: []const u8) error{InvalidViews}!u32 { + if (value.len == 0) return error.InvalidViews; + for (value) |byte| { + if (byte < '0' or byte > '9') return error.InvalidViews; + } + return std.fmt.parseInt(u32, value, 10) catch error.InvalidViews; +} + +test "share parser accepts defaults and endpoint flags" { + const args = [_][]const u8{ + "share", + "--to", + "recipient", + "--file", + ".env", + "--api", + "http://127.0.0.1:18080", + "--web", + "https://web.example", + }; + const options = try parseShareArgs(&args); + try std.testing.expectEqual(@as(u64, 24 * 60 * 60), options.ttl); + try std.testing.expectEqual(@as(u32, 1), options.views); + try std.testing.expectEqualStrings("recipient", options.to); + try std.testing.expectEqualStrings(".env", options.file); +} + +test "pull parser accepts default output and force flag" { + const defaults = [_][]const u8{ "pull", "0123456789abcdef0123456789abcdef" }; + const default_options = try parsePullArgs(&defaults); + try std.testing.expectEqualStrings(".env.ashdrop", default_options.output); + try std.testing.expect(!default_options.force); + + const forced = [_][]const u8{ "pull", "0123456789abcdef0123456789abcdef", "-o", "received.env", "--force" }; + const forced_options = try parsePullArgs(&forced); + try std.testing.expectEqualStrings("received.env", forced_options.output); + try std.testing.expect(forced_options.force); +} + +test "command parsers reject repeated flags even at default values" { + const repeated_share = [_][]const u8{ + "share", + "--to", + "recipient", + "--file", + ".env", + "--ttl", + "24h", + "--ttl", + "24h", + }; + try std.testing.expectError(error.Usage, parseShareArgs(&repeated_share)); + + const repeated_pull = [_][]const u8{ + "pull", + "0123456789abcdef0123456789abcdef", + "--output", + ".env.ashdrop", + "-o", + ".env.ashdrop", + }; + try std.testing.expectError(error.Usage, parsePullArgs(&repeated_pull)); +} + +test "share serializes recipientPub and never includes plaintext" { + const recipient = @import("identity.zig").generate(std.testing.io); + const request = try buildCreateRequest( + std.testing.allocator, + std.testing.io, + "TOKEN=top-secret\n", + recipient.publicSec1(), + .{}, + ); + defer std.testing.allocator.free(request.body); + + try std.testing.expect(std.mem.indexOf(u8, request.body, "recipientPub") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "TOKEN=top-secret") == null); +} + +const PullSpy = struct { + metadata_calls: usize = 0, + open_calls: usize = 0, + metadata_value: ?api.Metadata = null, + open_value: ?api.OpenedSecret = null, + metadata_id: ?[]const u8 = null, + open_id: ?[]const u8 = null, + + fn metadata(context: ?*anyopaque, id: []const u8) anyerror!?api.Metadata { + const self: *PullSpy = @ptrCast(@alignCast(context.?)); + self.metadata_calls += 1; + self.metadata_id = id; + if (self.metadata_value) |value| { + self.metadata_value = null; + return value; + } + return null; + } + + fn open(context: ?*anyopaque, id: []const u8) anyerror!?api.OpenedSecret { + const self: *PullSpy = @ptrCast(@alignCast(context.?)); + self.open_calls += 1; + self.open_id = id; + if (self.open_value) |value| { + self.open_value = null; + return value; + } + return null; + } +}; + +test "existing output fails before metadata or open" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = ".env.ashdrop", .data = "KEEP=yes\n" }); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + var spy = PullSpy{}; + const options = PullOptions{ .drop = "0123456789abcdef0123456789abcdef" }; + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + try std.testing.expectError(error.OutputExists, pullWithRemote(options, runtime, .{ + .context = &spy, + .metadata = PullSpy.metadata, + .open = PullSpy.open, + })); + try std.testing.expectEqual(@as(usize, 0), spy.metadata_calls); + try std.testing.expectEqual(@as(usize, 0), spy.open_calls); +} + +test "recipient mismatch never calls open" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var config_dir = try @import("identity.zig").openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + _ = try @import("identity.zig").create(std.testing.io, config_dir, "identity.json"); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + var spy = PullSpy{ .metadata_value = .{ + .recipientKeyed = true, + .recipientPub = try std.testing.allocator.dupe(u8, "different-recipient"), + .expiresAt = 0, + .viewsLeft = 1, + } }; + defer if (spy.metadata_value) |*value| value.deinit(std.testing.allocator); + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + try std.testing.expectError(error.RecipientMismatch, pullWithRemote(.{ + .drop = "0123456789abcdef0123456789abcdef", + }, runtime, .{ + .context = &spy, + .metadata = PullSpy.metadata, + .open = PullSpy.open, + })); + try std.testing.expectEqual(@as(usize, 1), spy.metadata_calls); + try std.testing.expectEqual(@as(usize, 0), spy.open_calls); +} + +test "pull reference resolution uses the parsed drop ID and endpoint" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + _ = try identity.create(std.testing.io, config_dir, "identity.json"); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const id = "0123456789abcdef0123456789abcdef"; + const references = [_]struct { + input: []const u8, + expected_endpoint: []const u8, + }{ + .{ + .input = "https://web.example/s/0123456789abcdef0123456789abcdef", + .expected_endpoint = "http://env.example", + }, + .{ + .input = "ashdrop://drop/0123456789abcdef0123456789abcdef?api=http%3A%2F%2Furi.example%3A18080", + .expected_endpoint = "http://uri.example:18080", + }, + }; + + for (references) |reference| { + const args = [_][]const u8{ "pull", reference.input }; + var target = try resolvePull(std.testing.allocator, &args, "http://env.example"); + defer target.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(id, target.options.drop); + try std.testing.expectEqualStrings(reference.expected_endpoint, target.endpoint); + var spy = PullSpy{}; + try std.testing.expectError(error.DropUnavailable, pullWithRemote(target.options, runtime, .{ + .context = &spy, + .metadata = PullSpy.metadata, + .open = PullSpy.open, + })); + try std.testing.expectEqualStrings(id, spy.metadata_id.?); + try std.testing.expectEqual(@as(usize, 0), spy.open_calls); + } +} + +test "invalid decrypted plaintext is never written" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + const local_identity = try identity.create(std.testing.io, config_dir, "identity.json"); + const public = links.formatRawReceive(local_identity.publicSec1()); + var sealed = try crypto.sealForRecipient(std.testing.allocator, std.testing.io, &[_]u8{ 0xff, 0xfe }, &local_identity.publicSec1()); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + var spy = PullSpy{ + .metadata_value = .{ + .recipientKeyed = true, + .recipientPub = try std.testing.allocator.dupe(u8, &public), + .expiresAt = 0, + .viewsLeft = 1, + }, + .open_value = .{ + .ciphertext = sealed.ciphertext, + .iv = sealed.iv, + .ephemeralPub = sealed.ephemeral_pub, + .recipientKeyed = true, + }, + }; + sealed = undefined; + defer if (spy.metadata_value) |*value| value.deinit(std.testing.allocator); + defer if (spy.open_value) |*value| value.deinit(std.testing.allocator); + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + try std.testing.expectError(error.InvalidUtf8, pullWithRemote(.{ + .drop = "0123456789abcdef0123456789abcdef", + }, runtime, .{ + .context = &spy, + .metadata = PullSpy.metadata, + .open = PullSpy.open, + })); + try std.testing.expectEqual(@as(usize, 1), spy.open_calls); + try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(std.testing.io, ".env.ashdrop", .{})); +} + +test "forced directory output fails before metadata" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDir(std.testing.io, ".env.ashdrop", @enumFromInt(0o700)); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + _ = try identity.create(std.testing.io, config_dir, "identity.json"); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + var spy = PullSpy{}; + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + try std.testing.expectError(error.OutputIsDirectory, pullWithRemote(.{ + .drop = "0123456789abcdef0123456789abcdef", + .force = true, + }, runtime, .{ + .context = &spy, + .metadata = PullSpy.metadata, + .open = PullSpy.open, + })); + try std.testing.expectEqual(@as(usize, 0), spy.metadata_calls); + try std.testing.expectEqual(@as(usize, 0), spy.open_calls); +} + +test "intermediate output symlink fails before metadata and leaves target unchanged" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDir(std.testing.io, "safe", @enumFromInt(0o700)); + try tmp.dir.createDir(std.testing.io, "target", @enumFromInt(0o700)); + try tmp.dir.createDir(std.testing.io, "target/subdir", @enumFromInt(0o700)); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "target/subdir/.env.ashdrop", .data = "KEEP=yes\n" }); + try tmp.dir.symLink(std.testing.io, "../target", "safe/link", .{ .is_directory = true }); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + _ = try identity.create(std.testing.io, config_dir, "identity.json"); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + var spy = PullSpy{}; + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + try std.testing.expectError(error.UnsafeOutputPath, pullWithRemote(.{ + .drop = "0123456789abcdef0123456789abcdef", + .output = "safe/link/subdir/.env.ashdrop", + }, runtime, .{ + .context = &spy, + .metadata = PullSpy.metadata, + .open = PullSpy.open, + })); + try std.testing.expectEqual(@as(usize, 0), spy.metadata_calls); + try std.testing.expectEqual(@as(usize, 0), spy.open_calls); + var content: [64]u8 = undefined; + try std.testing.expectEqualStrings("KEEP=yes\n", try tmp.dir.readFile(std.testing.io, "target/subdir/.env.ashdrop", &content)); +} + +test "share and pull keep plaintext out of command streams" { + const test_server = @import("test_server.zig"); + const source = "TOKEN=stream-secret\n"; + const id = "0123456789abcdef0123456789abcdef"; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + const local_identity = try identity.create(std.testing.io, config_dir, "identity.json"); + const local_public = links.formatRawReceive(local_identity.publicSec1()); + var sealed = try crypto.sealForRecipient(std.testing.allocator, std.testing.io, source, &local_identity.publicSec1()); + defer sealed.deinit(std.testing.allocator); + const metadata_body = try std.fmt.allocPrint( + std.testing.allocator, + "{{\"recipientKeyed\":true,\"recipientPub\":\"{s}\",\"expiresAt\":1,\"viewsLeft\":1}}", + .{&local_public}, + ); + defer std.testing.allocator.free(metadata_body); + const opened_body = try std.fmt.allocPrint( + std.testing.allocator, + "{{\"ciphertext\":\"{s}\",\"iv\":\"{s}\",\"ephemeralPub\":\"{s}\",\"recipientKeyed\":true}}", + .{ sealed.ciphertext, sealed.iv, sealed.ephemeral_pub }, + ); + defer std.testing.allocator.free(opened_body); + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .POST, .target = "/api/secrets", .json_body = true, .response_status = @enumFromInt(201), .response_body = "{\"id\":\"0123456789abcdef0123456789abcdef\",\"notifyToken\":\"notify\",\"expiresAt\":1}" }, + .{ .method = .GET, .target = "/api/secrets/0123456789abcdef0123456789abcdef/metadata", .response_status = .ok, .response_body = metadata_body }, + .{ .method = .POST, .target = "/api/secrets/0123456789abcdef0123456789abcdef/open", .response_status = .ok, .response_body = opened_body }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const api_env = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(api_env); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "source.env", .data = source }); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = Runtime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .cwd = tmp.dir, + .home_base = tmp.dir, + .home = "home", + .api_env = api_env, + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const share_args = [_][]const u8{ "share", "--to", &local_public, "--file", "source.env" }; + try share(&share_args, runtime); + const drop = std.mem.trimEnd(u8, stdout.written(), "\n"); + var parsed_drop = try links.parseDrop(std.testing.allocator, drop); + defer parsed_drop.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(id, parsed_drop.id); + try std.testing.expect(std.mem.indexOf(u8, stdout.written(), source) == null); + try std.testing.expectEqual(@as(usize, 0), stderr.written().len); + + const pull_args = [_][]const u8{ "pull", drop, "--output", "received.env" }; + try pull(&pull_args, runtime); + try std.testing.expect(std.mem.indexOf(u8, stdout.written(), source) == null); + try std.testing.expect(std.mem.indexOf(u8, stderr.written(), source) == null); + try std.testing.expectEqualStrings("received.env\n", stderr.written()); + var received: [128]u8 = undefined; + try std.testing.expectEqualStrings(source, try tmp.dir.readFile(std.testing.io, "received.env", &received)); + try server.deinit(); + server_live = false; +} diff --git a/ashdrop/cli/src/config.zig b/ashdrop/cli/src/config.zig new file mode 100644 index 0000000..a51a2b2 --- /dev/null +++ b/ashdrop/cli/src/config.zig @@ -0,0 +1,83 @@ +const std = @import("std"); + +// pub const managed_api = "https://ashdrop.onrender.com"; +// pub const managed_web = "https://ashdrop.vercel.app"; +// +pub const managed_api = "http://localhost:8080"; +pub const managed_web = "http://localhost:5173"; + +pub fn resolveApi(flag: ?[]const u8, env: ?[]const u8, embedded: ?[]const u8) error{InvalidEndpoint}![]const u8 { + return validateEndpoint(flag orelse embedded orelse env orelse managed_api); +} + +pub fn resolveWeb(flag: ?[]const u8, env: ?[]const u8) error{InvalidEndpoint}![]const u8 { + return validateEndpoint(flag orelse env orelse managed_web); +} + +pub fn configuredWeb(flag: ?[]const u8, env: ?[]const u8) ?[]const u8 { + return flag orelse env; +} + +fn validateEndpoint(endpoint: []const u8) error{InvalidEndpoint}![]const u8 { + const uri = std.Uri.parse(endpoint) catch return error.InvalidEndpoint; + if (!std.mem.eql(u8, uri.scheme, "http") and !std.mem.eql(u8, uri.scheme, "https")) return error.InvalidEndpoint; + if (uri.user != null or uri.password != null or uri.query != null or uri.fragment != null) return error.InvalidEndpoint; + + var hostname_buffer: [std.Io.net.HostName.max_len]u8 = undefined; + if (std.Io.net.HostName.fromUri(uri, &hostname_buffer)) |_| { + return endpoint; + } else |_| { + const host = uri.host orelse return error.InvalidEndpoint; + var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; + const raw_host = host.toRaw(&host_buffer) catch return error.InvalidEndpoint; + _ = std.Io.net.IpAddress.parseLiteral(raw_host) catch return error.InvalidEndpoint; + } + return endpoint; +} + +test "ordinary API references prefer explicit flag then environment" { + try std.testing.expectEqualStrings( + "https://flag.example", + try resolveApi("https://flag.example", "https://env.example", null), + ); + try std.testing.expectEqualStrings( + "https://env.example", + try resolveApi(null, "https://env.example", null), + ); + try std.testing.expectEqualStrings(managed_api, try resolveApi(null, null, null)); +} + +test "web endpoint resolves independently" { + try std.testing.expectEqualStrings( + "https://flag-web.example", + try resolveWeb("https://flag-web.example", "https://env-web.example"), + ); + try std.testing.expectEqualStrings( + "https://env-web.example", + try resolveWeb(null, "https://env-web.example"), + ); + try std.testing.expectEqualStrings(managed_web, try resolveWeb(null, null)); +} + +test "endpoint configuration rejects a URL without a host" { + try std.testing.expectError(error.InvalidEndpoint, resolveApi("https:///missing-host", null, null)); + try std.testing.expectError(error.InvalidEndpoint, resolveWeb("http:///missing-host", null)); +} + +test "endpoint configuration rejects malformed authorities and unsupported schemes" { + const invalid = [_][]const u8{ + "https://:443", + "https://[::1", + "https://example.com:bad", + "ftp://example.com", + }; + for (invalid) |endpoint| { + try std.testing.expectError(error.InvalidEndpoint, resolveApi(endpoint, null, null)); + try std.testing.expectError(error.InvalidEndpoint, resolveWeb(endpoint, null)); + } +} + +test "endpoint configuration accepts http and https localhost bases" { + try std.testing.expectEqualStrings("https://localhost", try resolveApi("https://localhost", null, null)); + try std.testing.expectEqualStrings("http://localhost:8080", try resolveWeb("http://localhost:8080", null)); +} diff --git a/ashdrop/cli/src/crypto.zig b/ashdrop/cli/src/crypto.zig new file mode 100644 index 0000000..aef6fb8 --- /dev/null +++ b/ashdrop/cli/src/crypto.zig @@ -0,0 +1,269 @@ +const std = @import("std"); + +const Aes256Gcm = std.crypto.aead.aes_gcm.Aes256Gcm; +const HkdfSha256 = std.crypto.kdf.hkdf.HkdfSha256; +const P256 = std.crypto.ecc.P256; + +const hkdf_info = "ashdrop-ecdh-v1"; + +const RandomSource = struct { + context: ?*anyopaque, + fill: *const fn (context: ?*anyopaque, output: []u8) void, +}; + +pub const Sealed = struct { + ciphertext: []u8, + iv: []u8, + ephemeral_pub: []u8, + + pub fn deinit(self: *Sealed, allocator: std.mem.Allocator) void { + allocator.free(self.ciphertext); + allocator.free(self.iv); + allocator.free(self.ephemeral_pub); + self.* = undefined; + } +}; + +/// Encrypts plaintext using an ephemeral P-256 private key and a recipient SEC1 point. +pub fn sealForRecipient( + allocator: std.mem.Allocator, + io: std.Io, + plaintext: []const u8, + recipient_sec1: []const u8, +) !Sealed { + var source_io = io; + return sealForRecipientWithRandomness(allocator, plaintext, recipient_sec1, .{ + .context = &source_io, + .fill = fillFromIo, + }); +} + +fn sealForRecipientWithRandomness( + allocator: std.mem.Allocator, + plaintext: []const u8, + recipient_sec1: []const u8, + random: RandomSource, +) !Sealed { + const recipient = parseRecipientPoint(recipient_sec1) catch return error.InvalidRecipient; + var ephemeral_private: [32]u8 = undefined; + defer std.crypto.secureZero(u8, &ephemeral_private); + randomPrivateScalar(random, &ephemeral_private); + var ephemeral_public = P256.basePoint.mul(ephemeral_private, .big) catch unreachable; + defer secureZeroValue(P256, &ephemeral_public); + const ephemeral_sec1 = ephemeral_public.toUncompressedSec1(); + var key: [Aes256Gcm.key_length]u8 = undefined; + defer std.crypto.secureZero(u8, &key); + deriveKey(&ephemeral_private, recipient, &key) catch return error.InvalidRecipient; + + var iv: [Aes256Gcm.nonce_length]u8 = undefined; + random.fill(random.context, &iv); + + var encrypted = try allocator.alloc(u8, plaintext.len + Aes256Gcm.tag_length); + defer allocator.free(encrypted); + var tag: [Aes256Gcm.tag_length]u8 = undefined; + Aes256Gcm.encrypt( + encrypted[0..plaintext.len], + &tag, + plaintext, + "", + iv, + key, + ); + @memcpy(encrypted[plaintext.len..], &tag); + + const ciphertext = try encodeB64url(allocator, encrypted); + errdefer allocator.free(ciphertext); + const encoded_iv = try encodeB64url(allocator, &iv); + errdefer allocator.free(encoded_iv); + const encoded_ephemeral = try encodeB64url(allocator, &ephemeral_sec1); + return .{ + .ciphertext = ciphertext, + .iv = encoded_iv, + .ephemeral_pub = encoded_ephemeral, + }; +} + +/// Decrypts a protocol v1 recipient payload using a big-endian P-256 scalar. +pub fn openForRecipient( + allocator: std.mem.Allocator, + private_scalar: *const [32]u8, + ciphertext_b64: []const u8, + iv_b64: []const u8, + ephemeral_b64: []const u8, +) ![]u8 { + try validatePrivateScalar(private_scalar); + + const ciphertext = decodeB64url(allocator, ciphertext_b64) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return error.InvalidInput, + }; + defer allocator.free(ciphertext); + if (ciphertext.len < Aes256Gcm.tag_length) return error.InvalidInput; + + const encoded_iv = decodeB64url(allocator, iv_b64) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return error.InvalidInput, + }; + defer allocator.free(encoded_iv); + if (encoded_iv.len != Aes256Gcm.nonce_length) return error.InvalidInput; + const iv: [Aes256Gcm.nonce_length]u8 = encoded_iv[0..Aes256Gcm.nonce_length].*; + + const ephemeral_sec1 = decodeB64url(allocator, ephemeral_b64) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return error.InvalidInput, + }; + defer allocator.free(ephemeral_sec1); + const ephemeral = parseEphemeralPoint(ephemeral_sec1) catch return error.InvalidInput; + var key: [Aes256Gcm.key_length]u8 = undefined; + defer std.crypto.secureZero(u8, &key); + deriveKey(private_scalar, ephemeral, &key) catch return error.InvalidInput; + + const encrypted_len = ciphertext.len - Aes256Gcm.tag_length; + const plaintext = try allocator.alloc(u8, encrypted_len); + errdefer allocator.free(plaintext); + const tag: [Aes256Gcm.tag_length]u8 = ciphertext[encrypted_len..][0..Aes256Gcm.tag_length].*; + Aes256Gcm.decrypt(plaintext, ciphertext[0..encrypted_len], tag, "", iv, key) catch return error.AuthenticationFailed; + return plaintext; +} + +fn validatePrivateScalar(private: *const [32]u8) error{InvalidPrivateKey}!void { + P256.scalar.rejectNonCanonical(private.*, .big) catch return error.InvalidPrivateKey; + if (std.mem.allEqual(u8, private, 0)) return error.InvalidPrivateKey; +} + +fn parseRecipientPoint(sec1: []const u8) error{InvalidRecipient}!P256 { + if (sec1.len != 65 or sec1[0] != 0x04) return error.InvalidRecipient; + return P256.fromSec1(sec1) catch error.InvalidRecipient; +} + +fn parseEphemeralPoint(sec1: []const u8) error{InvalidInput}!P256 { + if (sec1.len != 65 or sec1[0] != 0x04) return error.InvalidInput; + return P256.fromSec1(sec1) catch error.InvalidInput; +} + +fn deriveKey(private: *const [32]u8, peer_public: P256, key: *[Aes256Gcm.key_length]u8) error{InvalidInput}!void { + var shared_point = peer_public.mul(private.*, .big) catch return error.InvalidInput; + defer secureZeroValue(P256, &shared_point); + var shared_coordinates = shared_point.affineCoordinates(); + defer secureZeroValue(@TypeOf(shared_coordinates), &shared_coordinates); + var shared_x = shared_coordinates.x.toBytes(.big); + defer std.crypto.secureZero(u8, &shared_x); + const salt: [32]u8 = @splat(0); + var prk = HkdfSha256.extract(&salt, &shared_x); + defer std.crypto.secureZero(u8, &prk); + HkdfSha256.expand(key, hkdf_info, prk); +} + +fn fillFromIo(context: ?*anyopaque, output: []u8) void { + const io: *const std.Io = @ptrCast(@alignCast(context.?)); + io.random(output); +} + +fn randomPrivateScalar(random: RandomSource, private: *[32]u8) void { + var entropy: [48]u8 = undefined; + defer std.crypto.secureZero(u8, &entropy); + while (true) { + random.fill(random.context, &entropy); + var scalar = P256.scalar.Scalar.fromBytes48(entropy, .little); + const is_zero = scalar.isZero(); + if (!is_zero) private.* = scalar.toBytes(.big); + secureZeroValue(@TypeOf(scalar), &scalar); + if (!is_zero) return; + } +} + +fn secureZeroValue(comptime T: type, value: *T) void { + std.crypto.secureZero(T, @as([*]volatile T, @ptrCast(value))[0..1]); +} + +fn encodeB64url(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { + const encoded = try allocator.alloc(u8, std.base64.url_safe_no_pad.Encoder.calcSize(bytes.len)); + _ = std.base64.url_safe_no_pad.Encoder.encode(encoded, bytes); + return encoded; +} + +fn decodeB64url(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { + const decoded_len = std.base64.url_safe_no_pad.Decoder.calcSizeForSlice(encoded) catch return error.InvalidInput; + if (!hasCanonicalTrailingBits(encoded)) return error.InvalidInput; + + const decoded = try allocator.alloc(u8, decoded_len); + errdefer allocator.free(decoded); + std.base64.url_safe_no_pad.Decoder.decode(decoded, encoded) catch return error.InvalidInput; + return decoded; +} + +fn hasCanonicalTrailingBits(encoded: []const u8) bool { + if (encoded.len == 0 or encoded.len % 4 == 0) return true; + const index = std.mem.indexOfScalar(u8, &std.base64.url_safe_alphabet_chars, encoded[encoded.len - 1]) orelse return false; + return switch (encoded.len % 4) { + 2 => index & 0x0f == 0, + 3 => index & 0x03 == 0, + else => false, + }; +} + +const ProtocolFixture = struct { + recipient_private_jwk: struct { + d: []const u8, + }, + recipient_public_sec1: []const u8, + ephemeral_private_jwk: struct { + d: []const u8, + }, + ephemeral_public_sec1: []const u8, + iv: []const u8, + ciphertext: []const u8, + plaintext: []const u8, +}; + +const FixedRandom = struct { + bytes: [48 + Aes256Gcm.nonce_length]u8, + offset: usize = 0, + + fn init(ephemeral_private: [32]u8, iv: [Aes256Gcm.nonce_length]u8) FixedRandom { + var bytes: [48 + Aes256Gcm.nonce_length]u8 = undefined; + for (ephemeral_private, 0..) |byte, index| { + bytes[31 - index] = byte; + } + @memset(bytes[32..48], 0); + @memcpy(bytes[48..], &iv); + return .{ .bytes = bytes }; + } + + fn fill(context: ?*anyopaque, output: []u8) void { + const self: *FixedRandom = @ptrCast(@alignCast(context.?)); + std.debug.assert(self.offset + output.len <= self.bytes.len); + @memcpy(output, self.bytes[self.offset..][0..output.len]); + self.offset += output.len; + } +}; + +test "recipient sealing matches the fixed Node Web Crypto fixture" { + var fixture = try std.json.parseFromSlice(ProtocolFixture, std.testing.allocator, @embedFile("../testdata/protocol-v1.json"), .{ + .ignore_unknown_fields = true, + }); + defer fixture.deinit(); + + var recipient_public: [65]u8 = undefined; + try std.base64.url_safe_no_pad.Decoder.decode(&recipient_public, fixture.value.recipient_public_sec1); + var ephemeral_private: [32]u8 = undefined; + defer std.crypto.secureZero(u8, &ephemeral_private); + try std.base64.url_safe_no_pad.Decoder.decode(&ephemeral_private, fixture.value.ephemeral_private_jwk.d); + var iv: [Aes256Gcm.nonce_length]u8 = undefined; + try std.base64.url_safe_no_pad.Decoder.decode(&iv, fixture.value.iv); + var fixed_random = FixedRandom.init(ephemeral_private, iv); + defer std.crypto.secureZero(u8, &fixed_random.bytes); + + var sealed = try sealForRecipientWithRandomness( + std.testing.allocator, + fixture.value.plaintext, + &recipient_public, + .{ .context = &fixed_random, .fill = FixedRandom.fill }, + ); + defer sealed.deinit(std.testing.allocator); + + try std.testing.expectEqualStrings(fixture.value.ciphertext, sealed.ciphertext); + try std.testing.expectEqualStrings(fixture.value.iv, sealed.iv); + try std.testing.expectEqualStrings(fixture.value.ephemeral_public_sec1, sealed.ephemeral_pub); + try std.testing.expectEqual(fixed_random.bytes.len, fixed_random.offset); +} diff --git a/ashdrop/cli/src/crypto_test.zig b/ashdrop/cli/src/crypto_test.zig new file mode 100644 index 0000000..e7b96e9 --- /dev/null +++ b/ashdrop/cli/src/crypto_test.zig @@ -0,0 +1,265 @@ +const std = @import("std"); +const crypto = @import("crypto.zig"); + +const Fixture = struct { + version: u8, + recipient_private_jwk: struct { + d: []const u8, + }, + recipient_public_sec1: []const u8, + ephemeral_public_sec1: []const u8, + iv: []const u8, + ciphertext: []const u8, +}; + +fn loadFixture() !std.json.Parsed(Fixture) { + return std.json.parseFromSlice(Fixture, std.testing.allocator, @embedFile("../testdata/protocol-v1.json"), .{ + .ignore_unknown_fields = true, + }); +} + +fn decodeFixed(comptime len: usize, encoded: []const u8) ![len]u8 { + var decoded: [len]u8 = undefined; + try std.base64.url_safe_no_pad.Decoder.decode(&decoded, encoded); + return decoded; +} + +fn sealAllocationFailure(allocator: std.mem.Allocator, recipient_public: [65]u8) !void { + var sealed = try crypto.sealForRecipient(allocator, std.testing.io, "allocation cleanup", &recipient_public); + defer sealed.deinit(allocator); +} + +fn openAllocationFailure( + allocator: std.mem.Allocator, + private_scalar: [32]u8, + ciphertext: []const u8, + iv: []const u8, + ephemeral_public: []const u8, +) !void { + const plaintext = try crypto.openForRecipient(allocator, &private_scalar, ciphertext, iv, ephemeral_public); + defer allocator.free(plaintext); +} + +test "recipient protocol decrypts the Node Web Crypto fixture" { + var fixture = try loadFixture(); + defer fixture.deinit(); + try std.testing.expectEqual(@as(u8, 1), fixture.value.version); + + const private_scalar = try decodeFixed(32, fixture.value.recipient_private_jwk.d); + const plaintext = try crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + fixture.value.ciphertext, + fixture.value.iv, + fixture.value.ephemeral_public_sec1, + ); + defer std.testing.allocator.free(plaintext); + + try std.testing.expectEqualStrings("DATABASE_URL=postgres://ashdrop\nTOKEN=top-secret\n", plaintext); +} + +test "recipient protocol seals and opens a plaintext" { + var fixture = try loadFixture(); + defer fixture.deinit(); + + const recipient_public = try decodeFixed(65, fixture.value.recipient_public_sec1); + const private_scalar = try decodeFixed(32, fixture.value.recipient_private_jwk.d); + var sealed = try crypto.sealForRecipient( + std.testing.allocator, + std.testing.io, + "DATABASE_URL=postgres://ashdrop\nTOKEN=top-secret\n", + &recipient_public, + ); + defer sealed.deinit(std.testing.allocator); + + try std.testing.expectEqual(@as(usize, 16), sealed.iv.len); + try std.testing.expectEqual(@as(usize, 87), sealed.ephemeral_pub.len); + const plaintext = try crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + sealed.ciphertext, + sealed.iv, + sealed.ephemeral_pub, + ); + defer std.testing.allocator.free(plaintext); + + try std.testing.expectEqualStrings("DATABASE_URL=postgres://ashdrop\nTOKEN=top-secret\n", plaintext); +} + +test "recipient protocol rejects malformed recipient and payload inputs" { + var fixture = try loadFixture(); + defer fixture.deinit(); + + const recipient_public = try decodeFixed(65, fixture.value.recipient_public_sec1); + const private_scalar = try decodeFixed(32, fixture.value.recipient_private_jwk.d); + var malformed_point: [65]u8 = @splat(0); + malformed_point[0] = 0x04; + try std.testing.expectError( + error.InvalidRecipient, + crypto.sealForRecipient(std.testing.allocator, std.testing.io, "test", &malformed_point), + ); + const short_point = [_]u8{0x04}; + try std.testing.expectError( + error.InvalidRecipient, + crypto.sealForRecipient(std.testing.allocator, std.testing.io, "test", &short_point), + ); + + try std.testing.expectError( + error.InvalidInput, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + "AA==", + fixture.value.iv, + fixture.value.ephemeral_public_sec1, + ), + ); + try std.testing.expectError( + error.InvalidInput, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + fixture.value.ciphertext, + "AA", + fixture.value.ephemeral_public_sec1, + ), + ); + try std.testing.expectError( + error.InvalidInput, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + "AA", + fixture.value.iv, + fixture.value.ephemeral_public_sec1, + ), + ); + try std.testing.expectError( + error.InvalidInput, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + fixture.value.ciphertext, + fixture.value.iv, + "AA", + ), + ); + const ephemeral = try decodeFixed(65, fixture.value.ephemeral_public_sec1); + var compressed: [33]u8 = undefined; + compressed[0] = 0x02 + (ephemeral[64] & 1); + @memcpy(compressed[1..], ephemeral[1..33]); + var compressed_b64: [44]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&compressed_b64, &compressed); + try std.testing.expectError( + error.InvalidInput, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + fixture.value.ciphertext, + fixture.value.iv, + &compressed_b64, + ), + ); + + var sealed = try crypto.sealForRecipient(std.testing.allocator, std.testing.io, "roundtrip", &recipient_public); + defer sealed.deinit(std.testing.allocator); + const alphabet = std.base64.url_safe_alphabet_chars; + var noncanonical = try std.testing.allocator.dupe(u8, sealed.ciphertext); + defer std.testing.allocator.free(noncanonical); + const index = std.mem.indexOfScalar(u8, &alphabet, noncanonical[noncanonical.len - 1]).?; + noncanonical[noncanonical.len - 1] = alphabet[(index & 0b110000) | ((index + 1) & 0b001111)]; + try std.testing.expectError( + error.InvalidInput, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + noncanonical, + sealed.iv, + sealed.ephemeral_pub, + ), + ); +} + +test "recipient protocol rejects zero and noncanonical P-256 private scalars" { + var fixture = try loadFixture(); + defer fixture.deinit(); + + const order = [32]u8{ + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, + 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, + }; + const invalid_scalars = [_][32]u8{ + @splat(0), + order, + @splat(0xff), + }; + for (invalid_scalars) |private_scalar| { + try std.testing.expectError( + error.InvalidPrivateKey, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + fixture.value.ciphertext, + fixture.value.iv, + fixture.value.ephemeral_public_sec1, + ), + ); + } +} + +test "recipient protocol reports AES authentication failures" { + var fixture = try loadFixture(); + defer fixture.deinit(); + + const recipient_public = try decodeFixed(65, fixture.value.recipient_public_sec1); + const private_scalar = try decodeFixed(32, fixture.value.recipient_private_jwk.d); + var sealed = try crypto.sealForRecipient(std.testing.allocator, std.testing.io, "authenticated", &recipient_public); + defer sealed.deinit(std.testing.allocator); + + const alphabet = std.base64.url_safe_alphabet_chars; + var tampered = try std.testing.allocator.dupe(u8, sealed.ciphertext); + defer std.testing.allocator.free(tampered); + const index = std.mem.indexOfScalar(u8, &alphabet, tampered[0]).?; + tampered[0] = alphabet[(index + 1) % alphabet.len]; + try std.testing.expectError( + error.AuthenticationFailed, + crypto.openForRecipient( + std.testing.allocator, + &private_scalar, + tampered, + sealed.iv, + sealed.ephemeral_pub, + ), + ); +} + +test "recipient protocol cleans up every sealing allocation failure" { + var fixture = try loadFixture(); + defer fixture.deinit(); + + const recipient_public = try decodeFixed(65, fixture.value.recipient_public_sec1); + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + sealAllocationFailure, + .{recipient_public}, + ); +} + +test "recipient protocol cleans up every opening allocation failure" { + var fixture = try loadFixture(); + defer fixture.deinit(); + + const private_scalar = try decodeFixed(32, fixture.value.recipient_private_jwk.d); + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + openAllocationFailure, + .{ + private_scalar, + fixture.value.ciphertext, + fixture.value.iv, + fixture.value.ephemeral_public_sec1, + }, + ); +} diff --git a/ashdrop/cli/src/files.zig b/ashdrop/cli/src/files.zig new file mode 100644 index 0000000..74f98bb --- /dev/null +++ b/ashdrop/cli/src/files.zig @@ -0,0 +1,228 @@ +const std = @import("std"); + +pub const max_file_size = 64 * 1024; +pub const max_api_body_size = 96 * 1024; + +pub const PreparedOutput = struct { + dir: std.Io.Dir, + basename: []u8, + owns_dir: bool, + atomic_file: std.Io.File.Atomic, + force: bool, + + pub fn deinit(self: *PreparedOutput, allocator: std.mem.Allocator, io: std.Io) void { + self.atomic_file.deinit(io); + if (self.owns_dir) self.dir.close(io); + allocator.free(self.basename); + self.* = undefined; + } +}; + +pub fn readEnv(allocator: std.mem.Allocator, io: std.Io, base_dir: std.Io.Dir, path: []const u8) ![]u8 { + const content = base_dir.readFileAlloc(io, path, allocator, .limited(max_file_size + 1)) catch |err| switch (err) { + error.StreamTooLong => return error.FileTooLarge, + else => return err, + }; + errdefer allocator.free(content); + if (content.len > max_file_size) return error.FileTooLarge; + if (!std.unicode.utf8ValidateSlice(content)) return error.InvalidUtf8; + return content; +} + +pub fn requireApiBodySize(body: []const u8) error{RequestTooLarge}!void { + if (body.len >= max_api_body_size) return error.RequestTooLarge; +} + +const OutputParent = struct { + dir: std.Io.Dir, + owns_dir: bool, +}; + +fn openOutputParent(io: std.Io, base_dir: std.Io.Dir, path: []const u8) !OutputParent { + const parent = std.Io.Dir.path.dirname(path) orelse ""; + var current = base_dir; + var owns_current = false; + var start: usize = 0; + if (std.Io.Dir.path.isAbsolute(parent)) { + current = try std.Io.Dir.openDirAbsolute(io, "/", .{ .iterate = true }); + owns_current = true; + while (start < parent.len and parent[start] == '/') : (start += 1) {} + } + errdefer if (owns_current) current.close(io); + + while (start < parent.len) { + const end = std.mem.indexOfScalarPos(u8, parent, start, '/') orelse parent.len; + const component = parent[start..end]; + start = end + @intFromBool(end < parent.len); + if (component.len == 0 or std.mem.eql(u8, component, ".")) continue; + + const stat = try current.statFile(io, component, .{ .follow_symlinks = false }); + if (stat.kind == .sym_link) return error.UnsafeOutputPath; + if (stat.kind != .directory) return error.NotDir; + const next = try current.openDir(io, component, .{ + .iterate = true, + .follow_symlinks = false, + }); + if (owns_current) current.close(io); + current = next; + owns_current = true; + } + return .{ .dir = current, .owns_dir = owns_current }; +} + +pub fn prepareOutput( + allocator: std.mem.Allocator, + io: std.Io, + base_dir: std.Io.Dir, + path: []const u8, + force: bool, +) !PreparedOutput { + const basename = std.Io.Dir.path.basename(path); + if (basename.len == 0 or std.mem.eql(u8, basename, ".") or std.mem.eql(u8, basename, "..")) return error.InvalidOutputPath; + + const output_parent = try openOutputParent(io, base_dir, path); + const output_dir = output_parent.dir; + const owns_dir = output_parent.owns_dir; + errdefer if (owns_dir) output_dir.close(io); + + const existing = output_dir.statFile(io, basename, .{ .follow_symlinks = false }) catch |err| switch (err) { + error.FileNotFound => null, + else => |cause| return cause, + }; + if (existing) |stat| { + if (stat.kind == .sym_link) return error.UnsafeOutputPath; + if (stat.kind == .directory) return error.OutputIsDirectory; + if (!force) return error.OutputExists; + } + + const owned_basename = try allocator.dupe(u8, basename); + errdefer allocator.free(owned_basename); + var atomic_file = try output_dir.createFileAtomic(io, owned_basename, .{ + .permissions = @enumFromInt(0o600), + .replace = force, + }); + errdefer atomic_file.deinit(io); + try atomic_file.file.setPermissions(io, @enumFromInt(0o600)); + return .{ + .dir = output_dir, + .basename = owned_basename, + .owns_dir = owns_dir, + .atomic_file = atomic_file, + .force = force, + }; +} + +pub fn writeAtomically(io: std.Io, output: *PreparedOutput, content: []const u8) !void { + try output.atomic_file.file.writeStreamingAll(io, content); + try output.atomic_file.file.sync(io); + if (output.force) { + try output.atomic_file.replace(io); + } else { + output.atomic_file.link(io) catch |err| switch (err) { + error.PathAlreadyExists => return error.OutputExists, + else => return err, + }; + } +} + +test "readEnv rejects files larger than 64 KiB" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const content = try std.testing.allocator.alloc(u8, 64 * 1024 + 1); + defer std.testing.allocator.free(content); + @memset(content, 'a'); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "large.env", .data = content }); + + try std.testing.expectError( + error.FileTooLarge, + readEnv(std.testing.allocator, std.testing.io, tmp.dir, "large.env"), + ); +} + +test "readEnv rejects non-UTF-8 content" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "invalid.env", .data = "KEY=\xff\n" }); + + try std.testing.expectError( + error.InvalidUtf8, + readEnv(std.testing.allocator, std.testing.io, tmp.dir, "invalid.env"), + ); +} + +test "create request bodies must remain below the API cap" { + const body = try std.testing.allocator.alloc(u8, 96 * 1024); + defer std.testing.allocator.free(body); + try std.testing.expectError(error.RequestTooLarge, requireApiBodySize(body)); +} + +test "safe output preparation rejects an existing destination" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = ".env.ashdrop", .data = "EXISTING=yes\n" }); + + try std.testing.expectError( + error.OutputExists, + prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", false), + ); +} + +test "safe output preparation rejects a directory even when forced" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDir(std.testing.io, ".env.ashdrop", @enumFromInt(0o700)); + + try std.testing.expectError( + error.OutputIsDirectory, + prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", true), + ); +} + +test "output preparation reserves a secure atomic file before content is available" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var output = try prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", false); + defer output.deinit(std.testing.allocator, std.testing.io); + + try std.testing.expectEqual( + @as(std.posix.mode_t, 0o600), + (try output.atomic_file.file.stat(std.testing.io)).permissions.toMode() & 0o777, + ); + try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(std.testing.io, ".env.ashdrop", .{})); +} + +test "late destination creation is not replaced by atomic write" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var output = try prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", false); + defer output.deinit(std.testing.allocator, std.testing.io); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = ".env.ashdrop", .data = "LATE=yes\n" }); + + try std.testing.expectError(error.OutputExists, writeAtomically(std.testing.io, &output, "TOKEN=secret\n")); + var content: [64]u8 = undefined; + try std.testing.expectEqualStrings("LATE=yes\n", try tmp.dir.readFile(std.testing.io, ".env.ashdrop", &content)); +} + +test "atomic writes set mode and replace only when forced" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var output = try prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", false); + try writeAtomically(std.testing.io, &output, "TOKEN=first\n"); + output.deinit(std.testing.allocator, std.testing.io); + + var content: [64]u8 = undefined; + try std.testing.expectEqualStrings("TOKEN=first\n", try tmp.dir.readFile(std.testing.io, ".env.ashdrop", &content)); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), (try tmp.dir.statFile(std.testing.io, ".env.ashdrop", .{})).permissions.toMode() & 0o777); + + try std.testing.expectError( + error.OutputExists, + prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", false), + ); + var forced = try prepareOutput(std.testing.allocator, std.testing.io, tmp.dir, ".env.ashdrop", true); + defer forced.deinit(std.testing.allocator, std.testing.io); + try writeAtomically(std.testing.io, &forced, "TOKEN=second\n"); + try std.testing.expectEqualStrings("TOKEN=second\n", try tmp.dir.readFile(std.testing.io, ".env.ashdrop", &content)); +} diff --git a/ashdrop/cli/src/identity.zig b/ashdrop/cli/src/identity.zig new file mode 100644 index 0000000..bff5588 --- /dev/null +++ b/ashdrop/cli/src/identity.zig @@ -0,0 +1,338 @@ +const std = @import("std"); + +pub const Identity = struct { + d: [32]u8, + x: [32]u8, + y: [32]u8, + + pub fn publicSec1(self: Identity) [65]u8 { + var out: [65]u8 = undefined; + out[0] = 0x04; + @memcpy(out[1..33], &self.x); + @memcpy(out[33..65], &self.y); + return out; + } +}; + +const StoredIdentity = struct { + version: u8, + kty: []const u8, + crv: []const u8, + x: []const u8, + y: []const u8, + d: []const u8, +}; + +pub fn generate(io: std.Io) Identity { + const d = std.crypto.ecc.P256.scalar.random(io, .big); + return derive(d) catch unreachable; +} + +pub fn create(io: std.Io, dir: std.Io.Dir, filename: []const u8) !Identity { + const identity = generate(io); + try save(io, dir, filename, identity); + return identity; +} + +pub fn save(io: std.Io, dir: std.Io.Dir, filename: []const u8, identity: Identity) !void { + try validate(identity); + + const d = encodeCoordinate(identity.d); + const x = encodeCoordinate(identity.x); + const y = encodeCoordinate(identity.y); + var json_buffer: [256]u8 = undefined; + const json = try std.fmt.bufPrint( + &json_buffer, + "{{\"version\":1,\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"{s}\",\"y\":\"{s}\",\"d\":\"{s}\"}}\n", + .{ &x, &y, &d }, + ); + + var file = dir.createFile(io, filename, .{ + .exclusive = true, + .permissions = filePermissions(), + }) catch |err| switch (err) { + error.PathAlreadyExists => return error.IdentityAlreadyExists, + else => |cause| return cause, + }; + errdefer { + file.close(io); + dir.deleteFile(io, filename) catch {}; + } + try file.setPermissions(io, filePermissions()); + try file.writeStreamingAll(io, json); + try file.sync(io); + file.close(io); +} + +pub fn load(allocator: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, filename: []const u8) !Identity { + var file = try dir.openFile(io, filename, .{ + .allow_directory = false, + .follow_symlinks = false, + }); + defer file.close(io); + try file.setPermissions(io, filePermissions()); + var buffer: [512]u8 = undefined; + var reader = file.reader(io, &.{}); + const n = reader.interface.readSliceShort(&buffer) catch |err| switch (err) { + error.ReadFailed => return reader.err.?, + }; + const json = buffer[0..n]; + if (json.len == buffer.len) return error.InvalidIdentity; + + var parsed = std.json.parseFromSlice(StoredIdentity, allocator, json, .{}) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return error.InvalidIdentity, + }; + defer parsed.deinit(); + const stored = parsed.value; + if (stored.version != 1 or !std.mem.eql(u8, stored.kty, "EC") or !std.mem.eql(u8, stored.crv, "P-256")) { + return error.InvalidIdentity; + } + + const identity = Identity{ + .d = decodeCoordinate(stored.d) catch return error.InvalidIdentity, + .x = decodeCoordinate(stored.x) catch return error.InvalidIdentity, + .y = decodeCoordinate(stored.y) catch return error.InvalidIdentity, + }; + try validate(identity); + return identity; +} + +pub fn openConfigDir(io: std.Io, home: []const u8) !std.Io.Dir { + return openConfigDirAt(io, std.Io.Dir.cwd(), home); +} + +pub fn openConfigDirAt(io: std.Io, home_base: std.Io.Dir, home_path: []const u8) !std.Io.Dir { + var home_dir = try openSecurePath(io, home_base, home_path, false); + defer home_dir.close(io); + var config_dir = try openSecurePath(io, home_dir, ".config", false); + defer config_dir.close(io); + return openSecurePath(io, config_dir, "ashdrop", true); +} + +fn openSecurePath(io: std.Io, base: std.Io.Dir, path: []const u8, enforce_final_mode: bool) !std.Io.Dir { + if (path.len == 0) return error.InvalidHomePath; + + var current = base; + var owns_current = false; + var start: usize = 0; + if (std.Io.Dir.path.isAbsolute(path)) { + current = try std.Io.Dir.openDirAbsolute(io, "/", .{ .iterate = true }); + owns_current = true; + while (start < path.len and path[start] == '/') : (start += 1) {} + } + errdefer if (owns_current) current.close(io); + + var saw_component = false; + while (start < path.len) { + const end = std.mem.indexOfScalarPos(u8, path, start, '/') orelse path.len; + const component = path[start..end]; + start = end + @intFromBool(end < path.len); + if (component.len == 0) continue; + if (std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidHomePath; + saw_component = true; + + var next_start = start; + while (next_start < path.len and path[next_start] == '/') : (next_start += 1) {} + const is_final = next_start == path.len; + const created = blk: { + current.createDir(io, component, directoryPermissions()) catch |err| switch (err) { + error.PathAlreadyExists => break :blk false, + else => |cause| return cause, + }; + break :blk true; + }; + if (created) { + // Repair a restrictive umask before opening the new directory descriptor. + try current.setFilePermissions(io, component, directoryPermissions(), .{ .follow_symlinks = false }); + } + + var next = try current.openDir(io, component, .{ + .iterate = true, + .follow_symlinks = false, + }); + errdefer next.close(io); + if (created or (enforce_final_mode and is_final)) { + try next.setPermissions(io, directoryPermissions()); + } + if (owns_current) current.close(io); + current = next; + owns_current = true; + } + + if (!saw_component) return error.InvalidHomePath; + return current; +} + +fn directoryPermissions() std.Io.Dir.Permissions { + return @enumFromInt(0o700); +} + +fn filePermissions() std.Io.File.Permissions { + return @enumFromInt(0o600); +} + +fn derive(d: [32]u8) error{InvalidIdentity}!Identity { + const scalar = std.crypto.ecc.P256.scalar.Scalar.fromBytes(d, .big) catch return error.InvalidIdentity; + if (scalar.isZero()) return error.InvalidIdentity; + const point = std.crypto.ecc.P256.basePoint.mul(d, .big) catch return error.InvalidIdentity; + const public = point.toUncompressedSec1(); + return .{ + .d = d, + .x = public[1..33].*, + .y = public[33..65].*, + }; +} + +fn validate(identity: Identity) error{InvalidIdentity}!void { + const expected = try derive(identity.d); + if (!std.mem.eql(u8, &expected.x, &identity.x) or !std.mem.eql(u8, &expected.y, &identity.y)) { + return error.InvalidIdentity; + } +} + +fn encodeCoordinate(value: [32]u8) [43]u8 { + var encoded: [43]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&encoded, &value); + return encoded; +} + +fn decodeCoordinate(encoded: []const u8) error{InvalidIdentity}![32]u8 { + if (encoded.len != 43) return error.InvalidIdentity; + var value: [32]u8 = undefined; + std.base64.url_safe_no_pad.Decoder.decode(&value, encoded) catch return error.InvalidIdentity; + const canonical = encodeCoordinate(value); + if (!std.mem.eql(u8, encoded, &canonical)) return error.InvalidIdentity; + return value; +} + +fn writeTestJwk(io: std.Io, dir: std.Io.Dir, d: []const u8, x: []const u8, y: []const u8) !void { + var buffer: [256]u8 = undefined; + const json = try std.fmt.bufPrint( + &buffer, + "{{\"version\":1,\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"{s}\",\"y\":\"{s}\",\"d\":\"{s}\"}}\n", + .{ x, y, d }, + ); + try dir.writeFile(io, .{ .sub_path = "identity.json", .data = json }); +} + +test "identity save and load preserve the SEC1 public key" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const original = generate(std.testing.io); + try save(std.testing.io, tmp.dir, "identity.json", original); + const loaded = try load(std.testing.allocator, std.testing.io, tmp.dir, "identity.json"); + const original_public = original.publicSec1(); + const loaded_public = loaded.publicSec1(); + try std.testing.expectEqualSlices(u8, &original_public, &loaded_public); +} + +test "identity save never overwrites an existing identity" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try save(std.testing.io, tmp.dir, "identity.json", generate(std.testing.io)); + try std.testing.expectError( + error.IdentityAlreadyExists, + save(std.testing.io, tmp.dir, "identity.json", generate(std.testing.io)), + ); +} + +test "identity load rejects invalid and noncanonical JWK coordinates" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const original = generate(std.testing.io); + const d = encodeCoordinate(original.d); + const x = encodeCoordinate(original.x); + const y = encodeCoordinate(original.y); + try writeTestJwk(std.testing.io, tmp.dir, &d, "A", &y); + try std.testing.expectError( + error.InvalidIdentity, + load(std.testing.allocator, std.testing.io, tmp.dir, "identity.json"), + ); + + var invalid_x = x; + invalid_x[0] = '+'; + try writeTestJwk(std.testing.io, tmp.dir, &d, &invalid_x, &y); + try std.testing.expectError( + error.InvalidIdentity, + load(std.testing.allocator, std.testing.io, tmp.dir, "identity.json"), + ); + + var noncanonical_x = x; + const alphabet = std.base64.url_safe_alphabet_chars; + const index = std.mem.indexOfScalar(u8, &alphabet, noncanonical_x[noncanonical_x.len - 1]).?; + noncanonical_x[noncanonical_x.len - 1] = alphabet[index | 1]; + try writeTestJwk(std.testing.io, tmp.dir, &d, &noncanonical_x, &y); + try std.testing.expectError( + error.InvalidIdentity, + load(std.testing.allocator, std.testing.io, tmp.dir, "identity.json"), + ); +} + +test "identity load rejects coordinates that do not match the private scalar" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const first = generate(std.testing.io); + const second = generate(std.testing.io); + const d = encodeCoordinate(second.d); + const x = encodeCoordinate(first.x); + const y = encodeCoordinate(first.y); + try writeTestJwk(std.testing.io, tmp.dir, &d, &x, &y); + try std.testing.expectError( + error.InvalidIdentity, + load(std.testing.allocator, std.testing.io, tmp.dir, "identity.json"), + ); +} + +test "identity load preserves allocator failures" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try save(std.testing.io, tmp.dir, "identity.json", generate(std.testing.io)); + try std.testing.expectError( + error.OutOfMemory, + load(std.testing.failing_allocator, std.testing.io, tmp.dir, "identity.json"), + ); +} + +test "identity config directory rejects symlinks without changing the target" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var target = try tmp.dir.createDirPathOpen(std.testing.io, "target", .{ .open_options = .{ .iterate = true } }); + defer target.close(std.testing.io); + try target.setPermissions(std.testing.io, @enumFromInt(0o755)); + var parent = try tmp.dir.createDirPathOpen(std.testing.io, "home/.config", .{}); + defer parent.close(std.testing.io); + try tmp.dir.symLink(std.testing.io, "../../target", "home/.config/ashdrop", .{ .is_directory = true }); + + if (openConfigDirAt(std.testing.io, tmp.dir, "home")) |dir| { + dir.close(std.testing.io); + return error.TestExpectedError; + } else |_| {} + try std.testing.expectEqual(@as(std.posix.mode_t, 0o755), (try target.stat(std.testing.io)).permissions.toMode() & 0o777); +} + +test "identity load rejects symlinked files without changing the target" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + var config_dir = try openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + var parent = try tmp.dir.openDir(std.testing.io, "home/.config", .{ .iterate = true }); + defer parent.close(std.testing.io); + try parent.writeFile(std.testing.io, .{ .sub_path = "target.json", .data = "TARGET\n" }); + try parent.setFilePermissions(std.testing.io, "target.json", @enumFromInt(0o644), .{}); + try config_dir.symLink(std.testing.io, "../target.json", "identity.json", .{}); + + if (load(std.testing.allocator, std.testing.io, config_dir, "identity.json")) |_| { + return error.TestExpectedError; + } else |_| {} + try std.testing.expectEqual(@as(std.posix.mode_t, 0o644), (try parent.statFile(std.testing.io, "target.json", .{})).permissions.toMode() & 0o777); + var content: [16]u8 = undefined; + try std.testing.expectEqualStrings("TARGET\n", try parent.readFile(std.testing.io, "target.json", &content)); +} diff --git a/ashdrop/cli/src/links.zig b/ashdrop/cli/src/links.zig new file mode 100644 index 0000000..e6f3ddb --- /dev/null +++ b/ashdrop/cli/src/links.zig @@ -0,0 +1,374 @@ +const std = @import("std"); +const config = @import("config.zig"); + +pub const ReceiveRef = struct { + key: [65]u8, + api: ?[]u8 = null, + + pub fn deinit(self: *ReceiveRef, allocator: std.mem.Allocator) void { + if (self.api) |api| allocator.free(api); + self.* = undefined; + } +}; + +pub const DropRef = struct { + id: []const u8, + api: ?[]const u8 = null, + api_owned: ?[]u8 = null, + + pub fn deinit(self: *DropRef, allocator: std.mem.Allocator) void { + if (self.api_owned) |api| allocator.free(api); + self.* = undefined; + } +}; + +pub fn formatRawReceive(key: [65]u8) [87]u8 { + var encoded: [87]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&encoded, &key); + return encoded; +} + +pub fn parseReceive(allocator: std.mem.Allocator, input: []const u8) !ReceiveRef { + if (std.mem.startsWith(u8, input, "ashdrop://receive/")) { + const parts = try parseAshdropUri(allocator, input, "ashdrop://receive/", error.InvalidReceiveReference); + errdefer allocator.free(parts.api); + return .{ + .key = try decodeReceiveKey(parts.value), + .api = parts.api, + }; + } + + if (webPath(input)) |path| { + const prefix = "/drop-for/"; + const route_index = std.mem.lastIndexOf(u8, path, prefix) orelse return error.InvalidReceiveReference; + return .{ .key = try decodeReceiveKey(path[route_index + prefix.len ..]) }; + } + + return .{ .key = try decodeReceiveKey(input) }; +} + +pub fn parseDrop(allocator: std.mem.Allocator, input: []const u8) !DropRef { + if (std.mem.startsWith(u8, input, "ashdrop://drop/")) { + const parts = try parseAshdropUri(allocator, input, "ashdrop://drop/", error.InvalidDropReference); + errdefer allocator.free(parts.api); + if (!isDropId(parts.value)) return error.InvalidDropReference; + return .{ .id = parts.value, .api = parts.api, .api_owned = parts.api }; + } + + if (webPath(input)) |path| { + const prefix = "/s/"; + const route_index = std.mem.lastIndexOf(u8, path, prefix) orelse return error.InvalidDropReference; + const id = path[route_index + prefix.len ..]; + if (!isDropId(id)) return error.InvalidDropReference; + return .{ .id = id }; + } + + if (!isDropId(input)) return error.InvalidDropReference; + return .{ .id = input }; +} + +pub fn formatReceive( + allocator: std.mem.Allocator, + key: [65]u8, + api: []const u8, + web: ?[]const u8, +) ![]u8 { + _ = try config.resolveApi(null, null, api); + try validateReceiveKey(key); + const raw = formatRawReceive(key); + return formatLink(allocator, "drop-for", &raw, "receive", api, web); +} + +pub fn formatDrop( + allocator: std.mem.Allocator, + id: []const u8, + api: []const u8, + web: ?[]const u8, +) ![]u8 { + _ = try config.resolveApi(null, null, api); + if (!isDropId(id)) return error.InvalidDropReference; + return formatLink(allocator, "s", id, "drop", api, web); +} + +fn formatLink( + allocator: std.mem.Allocator, + web_path: []const u8, + value: []const u8, + uri_kind: []const u8, + api: []const u8, + web: ?[]const u8, +) ![]u8 { + const selected_web: ?[]const u8 = web orelse if (std.mem.eql(u8, api, config.managed_api)) config.managed_web else null; + if (selected_web) |base| { + const valid_base = try config.resolveWeb(base, null); + return std.fmt.allocPrint(allocator, "{s}/{s}/{s}", .{ trimTrailingSlashes(valid_base), web_path, value }); + } + + const prefix = try std.fmt.allocPrint(allocator, "ashdrop://{s}/{s}?api=", .{ uri_kind, value }); + defer allocator.free(prefix); + const encoded_api_len = percentEncodedLen(api); + const output = try allocator.alloc(u8, prefix.len + encoded_api_len); + @memcpy(output[0..prefix.len], prefix); + var out_index = prefix.len; + for (api) |byte| { + if (isUnreserved(byte)) { + output[out_index] = byte; + out_index += 1; + } else { + output[out_index] = '%'; + output[out_index + 1] = hexDigit(byte >> 4); + output[out_index + 2] = hexDigit(byte & 0x0f); + out_index += 3; + } + } + return output; +} + +fn parseAshdropUri( + allocator: std.mem.Allocator, + input: []const u8, + prefix: []const u8, + comptime invalid_error: anyerror, +) !struct { value: []const u8, api: []u8 } { + const remaining = input[prefix.len..]; + const query_index = std.mem.indexOfScalar(u8, remaining, '?') orelse return invalid_error; + const value = remaining[0..query_index]; + const query = remaining[query_index + 1 ..]; + if (value.len == 0 or !std.mem.startsWith(u8, query, "api=") or std.mem.indexOfScalar(u8, query[4..], '&') != null) { + return invalid_error; + } + const api = decodeEmbeddedApi(allocator, query[4..]) catch |err| switch (err) { + error.OutOfMemory => return err, + error.InvalidEmbeddedApi => return invalid_error, + }; + return .{ .value = value, .api = api }; +} + +fn decodeReceiveKey(input: []const u8) error{InvalidReceiveReference}![65]u8 { + if (input.len != 87) return error.InvalidReceiveReference; + var key: [65]u8 = undefined; + std.base64.url_safe_no_pad.Decoder.decode(&key, input) catch return error.InvalidReceiveReference; + if (key[0] != 0x04) return error.InvalidReceiveReference; + const canonical = formatRawReceive(key); + if (!std.mem.eql(u8, input, &canonical)) return error.InvalidReceiveReference; + _ = std.crypto.ecc.P256.fromSec1(&key) catch return error.InvalidReceiveReference; + return key; +} + +fn validateReceiveKey(key: [65]u8) error{InvalidReceiveReference}!void { + if (key[0] != 0x04) return error.InvalidReceiveReference; + _ = std.crypto.ecc.P256.fromSec1(&key) catch return error.InvalidReceiveReference; +} + +fn decodeEmbeddedApi(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { + if (encoded.len == 0) return error.InvalidEmbeddedApi; + var decoded = try allocator.alloc(u8, encoded.len); + errdefer allocator.free(decoded); + + var input_index: usize = 0; + var output_index: usize = 0; + while (input_index < encoded.len) { + const byte = encoded[input_index]; + if (isUnreserved(byte)) { + decoded[output_index] = byte; + input_index += 1; + output_index += 1; + continue; + } + if (byte != '%' or input_index + 2 >= encoded.len) return error.InvalidEmbeddedApi; + const high = hexValue(encoded[input_index + 1]) orelse return error.InvalidEmbeddedApi; + const low = hexValue(encoded[input_index + 2]) orelse return error.InvalidEmbeddedApi; + decoded[output_index] = (high << 4) | low; + input_index += 3; + output_index += 1; + } + decoded = try allocator.realloc(decoded, output_index); + _ = config.resolveApi(null, null, decoded) catch return error.InvalidEmbeddedApi; + return decoded; +} + +fn webPath(input: []const u8) ?[]const u8 { + const scheme_len = if (std.mem.startsWith(u8, input, "https://")) + "https://".len + else if (std.mem.startsWith(u8, input, "http://")) + "http://".len + else + return null; + _ = config.resolveWeb(input, null) catch return null; + const host_and_path = input[scheme_len..]; + const path_index = std.mem.indexOfScalar(u8, host_and_path, '/') orelse return null; + if (path_index == 0) return null; + return host_and_path[path_index..]; +} + +fn trimTrailingSlashes(url: []const u8) []const u8 { + var end = url.len; + while (end > "https://".len and url[end - 1] == '/') : (end -= 1) {} + return url[0..end]; +} + +fn isDropId(id: []const u8) bool { + if (id.len != 32) return false; + for (id) |byte| { + if (!(byte >= '0' and byte <= '9') and !(byte >= 'a' and byte <= 'f')) return false; + } + return true; +} + +fn isUnreserved(byte: u8) bool { + return (byte >= 'a' and byte <= 'z') or + (byte >= 'A' and byte <= 'Z') or + (byte >= '0' and byte <= '9') or + byte == '-' or byte == '.' or byte == '_' or byte == '~'; +} + +fn percentEncodedLen(input: []const u8) usize { + var length = input.len; + for (input) |byte| { + if (!isUnreserved(byte)) length += 2; + } + return length; +} + +fn hexDigit(value: u8) u8 { + return if (value < 10) '0' + value else 'A' + value - 10; +} + +fn hexValue(byte: u8) ?u8 { + if (byte >= '0' and byte <= '9') return byte - '0'; + if (byte >= 'A' and byte <= 'F') return byte - 'A' + 10; + if (byte >= 'a' and byte <= 'f') return byte - 'a' + 10; + return null; +} + +fn basePoint() [65]u8 { + return std.crypto.ecc.P256.basePoint.toUncompressedSec1(); +} + +test "raw receive key parses and formats canonically" { + const key = basePoint(); + const raw = formatRawReceive(key); + var parsed = try parseReceive(std.testing.allocator, &raw); + defer parsed.deinit(std.testing.allocator); + try std.testing.expectEqualSlices(u8, &key, &parsed.key); +} + +test "web receive and drop links parse and format" { + const key = basePoint(); + const receive = try formatReceive(std.testing.allocator, key, config.managed_api, null); + defer std.testing.allocator.free(receive); + var parsed_receive = try parseReceive(std.testing.allocator, receive); + defer parsed_receive.deinit(std.testing.allocator); + try std.testing.expectEqualSlices(u8, &key, &parsed_receive.key); + + const id = "0123456789abcdef0123456789abcdef"; + const drop = try formatDrop(std.testing.allocator, id, config.managed_api, null); + defer std.testing.allocator.free(drop); + var parsed_drop = try parseDrop(std.testing.allocator, drop); + defer parsed_drop.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(id, parsed_drop.id); + try std.testing.expect(parsed_drop.api == null); +} + +test "managed, localhost, and path-prefixed web links round trip" { + const key = basePoint(); + const id = "0123456789abcdef0123456789abcdef"; + const web_bases = [_][]const u8{ + "https://ashdrop.dev", + "http://localhost:8080", + "http://localhost:8080/app", + }; + + for (web_bases) |web| { + const receive = try formatReceive(std.testing.allocator, key, config.managed_api, web); + defer std.testing.allocator.free(receive); + var parsed_receive = try parseReceive(std.testing.allocator, receive); + defer parsed_receive.deinit(std.testing.allocator); + try std.testing.expectEqualSlices(u8, &key, &parsed_receive.key); + + const drop = try formatDrop(std.testing.allocator, id, config.managed_api, web); + defer std.testing.allocator.free(drop); + var parsed_drop = try parseDrop(std.testing.allocator, drop); + defer parsed_drop.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(id, parsed_drop.id); + } + + const prefixed = try formatReceive(std.testing.allocator, key, config.managed_api, "http://localhost:8080/app"); + defer std.testing.allocator.free(prefixed); + try std.testing.expect(std.mem.startsWith(u8, prefixed, "http://localhost:8080/app/drop-for/")); +} + +test "Ashdrop receive and drop URIs preserve embedded APIs" { + const key = basePoint(); + const receive = try formatReceive(std.testing.allocator, key, "https://self.example/api", null); + defer std.testing.allocator.free(receive); + var parsed_receive = try parseReceive(std.testing.allocator, receive); + defer parsed_receive.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("https://self.example/api", parsed_receive.api.?); + + const drop = try formatDrop( + std.testing.allocator, + "0123456789abcdef0123456789abcdef", + "https://self.example/api", + null, + ); + defer std.testing.allocator.free(drop); + var parsed_drop = try parseDrop(std.testing.allocator, drop); + defer parsed_drop.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("https://self.example/api", parsed_drop.api.?); +} + +test "parsed Ashdrop URI API takes precedence over environment" { + var drop = try parseDrop( + std.testing.allocator, + "ashdrop://drop/0123456789abcdef0123456789abcdef?api=https%3A%2F%2Furi.example", + ); + defer drop.deinit(std.testing.allocator); + try std.testing.expect(@TypeOf(drop.api) == ?[]const u8); + + try std.testing.expectEqualStrings( + "https://uri.example", + try config.resolveApi(null, "https://env.example", drop.api), + ); + try std.testing.expectEqualStrings( + "https://flag.example", + try config.resolveApi("https://flag.example", "https://env.example", drop.api), + ); + try std.testing.expectEqualStrings(config.managed_api, try config.resolveApi(null, null, null)); +} + +test "custom web endpoint formats web links" { + const key = basePoint(); + const receive = try formatReceive( + std.testing.allocator, + key, + "https://self.example/api", + "https://web.example", + ); + defer std.testing.allocator.free(receive); + try std.testing.expectEqualStrings("https://web.example/drop-for/", receive[0..29]); +} + +test "link parser rejects malformed and noncanonical references" { + const key = basePoint(); + var raw = formatRawReceive(key); + const alphabet = std.base64.url_safe_alphabet_chars; + const index = std.mem.indexOfScalar(u8, &alphabet, raw[raw.len - 1]).?; + raw[raw.len - 1] = alphabet[index | 1]; + try std.testing.expectError(error.InvalidReceiveReference, parseReceive(std.testing.allocator, &raw)); + try std.testing.expectError(error.InvalidReceiveReference, parseReceive(std.testing.allocator, "https://ashdrop.dev/drop-for/nope/extra")); + try std.testing.expectError(error.InvalidReceiveReference, parseReceive(std.testing.allocator, "ashdrop://receive/nope?api=https%3A%2F%2Fself.example&x=1")); + try std.testing.expectError(error.InvalidDropReference, parseDrop(std.testing.allocator, "https://ashdrop.dev/s/0123456789ABCDEF0123456789ABCDEF")); + try std.testing.expectError(error.InvalidDropReference, parseDrop(std.testing.allocator, "ashdrop://drop/0123456789abcdef0123456789abcdef?x=1")); + try std.testing.expectError(error.InvalidDropReference, parseDrop(std.testing.allocator, "ashdrop://drop/0123456789abcdef0123456789abcdef?api=%ZZ")); +} + +test "Ashdrop URI parsing preserves allocator failures" { + try std.testing.expectError( + error.OutOfMemory, + parseDrop( + std.testing.failing_allocator, + "ashdrop://drop/0123456789abcdef0123456789abcdef?api=https%3A%2F%2Fself.example", + ), + ); +} diff --git a/ashdrop/cli/src/main.zig b/ashdrop/cli/src/main.zig new file mode 100644 index 0000000..5d9279f --- /dev/null +++ b/ashdrop/cli/src/main.zig @@ -0,0 +1,574 @@ +const std = @import("std"); +const config = @import("config.zig"); +const commands = @import("commands.zig"); +const identity = @import("identity.zig"); +const links = @import("links.zig"); + +const AddressOptions = struct { + create: bool = false, + raw: bool = false, + api: ?[]const u8 = null, + web: ?[]const u8 = null, +}; + +const AddressRuntime = struct { + allocator: std.mem.Allocator, + io: std.Io, + home_base: std.Io.Dir, + home: []const u8, + api_env: ?[]const u8 = null, + web_env: ?[]const u8 = null, + stdout: *std.Io.Writer, + stderr: *std.Io.Writer, +}; + +pub fn main(init: std.process.Init) u8 { + var stdout_buffer: [1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(init.io, &stdout_buffer); + var stderr_buffer: [512]u8 = undefined; + var stderr = std.Io.File.stderr().writer(init.io, &stderr_buffer); + const args = init.minimal.args.toSlice(init.arena.allocator()) catch { + const status = writeFailure(&stderr.interface, error.CommandFailed); + return finishCommand(status, &stdout.interface, &stderr.interface); + }; + const home = init.environ_map.get("HOME") orelse { + const status = writeFailure(&stderr.interface, error.HomeMissing); + return finishCommand(status, &stdout.interface, &stderr.interface); + }; + const status = runCommand(args[1..], .{ + .allocator = init.gpa, + .io = init.io, + .home_base = std.Io.Dir.cwd(), + .home = home, + .api_env = init.environ_map.get("ASHDROP_API_URL"), + .web_env = init.environ_map.get("ASHDROP_WEB_URL"), + .stdout = &stdout.interface, + .stderr = &stderr.interface, + }); + return finishCommand(status, &stdout.interface, &stderr.interface); +} + +fn runCommand(args: anytype, runtime: AddressRuntime) u8 { + if (args.len == 0) return writeFailure(runtime.stderr, error.Usage); + const command: []const u8 = args[0]; + if (std.mem.eql(u8, command, "address")) return runAddress(args, runtime); + if (std.mem.eql(u8, command, "share")) return runShare(args, runtime); + if (std.mem.eql(u8, command, "pull")) return runPull(args, runtime); + return writeFailure(runtime.stderr, error.Usage); +} + +fn runShare(args: []const []const u8, runtime: AddressRuntime) u8 { + commands.share(args, commandRuntime(runtime)) catch |err| return writeFailure(runtime.stderr, err); + return 0; +} + +fn runPull(args: []const []const u8, runtime: AddressRuntime) u8 { + commands.pull(args, commandRuntime(runtime)) catch |err| return writeFailure(runtime.stderr, err); + return 0; +} + +fn commandRuntime(runtime: AddressRuntime) commands.Runtime { + return .{ + .allocator = runtime.allocator, + .io = runtime.io, + .cwd = std.Io.Dir.cwd(), + .home_base = runtime.home_base, + .home = runtime.home, + .api_env = runtime.api_env, + .web_env = runtime.web_env, + .stdout = runtime.stdout, + .stderr = runtime.stderr, + }; +} + +fn runAddress(args: anytype, runtime: AddressRuntime) u8 { + const options = parseAddressArgs(args) catch |err| return writeFailure(runtime.stderr, err); + const api = config.resolveApi(options.api, runtime.api_env, null) catch |err| return writeFailure(runtime.stderr, err); + _ = config.resolveWeb(options.web, runtime.web_env) catch |err| return writeFailure(runtime.stderr, err); + const configured_web = config.configuredWeb(options.web, runtime.web_env); + + var config_dir = identity.openConfigDirAt(runtime.io, runtime.home_base, runtime.home) catch |err| return writeFailure(runtime.stderr, err); + defer config_dir.close(runtime.io); + const local_identity = if (options.create) + identity.create(runtime.io, config_dir, "identity.json") + else + identity.load(runtime.allocator, runtime.io, config_dir, "identity.json"); + const loaded = local_identity catch |err| return writeFailure(runtime.stderr, err); + const public = loaded.publicSec1(); + + if (options.raw) { + const raw = links.formatRawReceive(public); + runtime.stdout.print("{s}\n", .{&raw}) catch |err| return writeFailure(runtime.stderr, err); + return 0; + } + + const receive_url = links.formatReceive(runtime.allocator, public, api, configured_web) catch |err| return writeFailure(runtime.stderr, err); + defer runtime.allocator.free(receive_url); + runtime.stdout.print("{s}\n", .{receive_url}) catch |err| return writeFailure(runtime.stderr, err); + return 0; +} + +fn writeFailure(stderr: *std.Io.Writer, err: anyerror) u8 { + const status: u8 = switch (err) { + error.Usage, + error.InvalidEndpoint, + error.HomeMissing, + error.InvalidHomePath, + error.IdentityAlreadyExists, + error.FileNotFound, + error.InvalidIdentity, + error.IdentityMissing, + error.InvalidReceiveReference, + error.InvalidDropReference, + error.InvalidOutputPath, + => 2, + else => 1, + }; + const message = switch (err) { + error.Usage => "usage: ashdrop [options]\n", + error.InvalidEndpoint => "ashdrop: invalid API or web endpoint\n", + error.HomeMissing => "ashdrop: HOME is not set\n", + error.InvalidHomePath => "ashdrop: HOME is invalid\n", + error.IdentityAlreadyExists => "ashdrop: receive identity already exists\n", + error.FileNotFound, error.IdentityMissing => "ashdrop: receive identity does not exist; run `ashdrop address create`\n", + error.InvalidIdentity => "ashdrop: stored receive identity is invalid\n", + error.InvalidReceiveReference => "ashdrop: invalid receive address\n", + error.InvalidDropReference => "ashdrop: invalid drop reference\n", + error.InvalidOutputPath => "ashdrop: invalid output path\n", + error.OutputIsDirectory => "ashdrop: output path is a directory\n", + error.OutputExists => "ashdrop: output file already exists; use --force to replace it\n", + error.UnsafeOutputPath => "ashdrop: output path must not be a symlink\n", + error.OutputPreparationFailed => "ashdrop: could not prepare output path\n", + error.OutputWriteFailed => "ashdrop: could not write output file\n", + error.FileTooLarge => "ashdrop: input file is too large\n", + error.InputFileMissing => "ashdrop: input file does not exist\n", + error.InvalidUtf8 => "ashdrop: input file is not valid UTF-8\n", + error.RequestTooLarge => "ashdrop: encrypted request is too large\n", + error.RateLimited => "ashdrop: request was rate limited\n", + error.DropUnavailable => "ashdrop: drop no longer exists\n", + error.RecipientMismatch => "ashdrop: drop is for a different receive identity\n", + error.RemoteFailure, error.ResponseTooLarge, error.InvalidResponse => "ashdrop: API request failed\n", + else => "ashdrop: command failed\n", + }; + stderr.writeAll(message) catch {}; + return status; +} + +fn finishCommand(status: u8, stdout: *std.Io.Writer, stderr: *std.Io.Writer) u8 { + const stdout_failed = blk: { + stdout.flush() catch break :blk true; + break :blk false; + }; + const stderr_failed = blk: { + stderr.flush() catch break :blk true; + break :blk false; + }; + if (status == 0 and (stdout_failed or stderr_failed)) return 1; + return status; +} + +fn parseAddressArgs(args: anytype) error{Usage}!AddressOptions { + if (args.len == 0 or !std.mem.eql(u8, args[0], "address")) return error.Usage; + + var options = AddressOptions{}; + var index: usize = 1; + while (index < args.len) : (index += 1) { + const arg: []const u8 = args[index]; + if (std.mem.eql(u8, arg, "create")) { + if (options.create) return error.Usage; + options.create = true; + continue; + } + if (std.mem.eql(u8, arg, "--raw")) { + if (options.raw) return error.Usage; + options.raw = true; + continue; + } + if (std.mem.eql(u8, arg, "--api") or std.mem.eql(u8, arg, "--web")) { + index += 1; + if (index == args.len) return error.Usage; + const value: []const u8 = args[index]; + if (std.mem.eql(u8, arg, "--api")) { + if (options.api != null) return error.Usage; + options.api = value; + } else { + if (options.web != null) return error.Usage; + options.web = value; + } + continue; + } + return error.Usage; + } + + if (options.create and options.raw) return error.Usage; + return options; +} + +test { + _ = @import("config.zig"); + _ = @import("crypto_test.zig"); + _ = @import("links.zig"); + _ = @import("identity.zig"); +} + +test "address parser accepts create with endpoint overrides" { + const args = [_][]const u8{ + "address", + "create", + "--api", + "https://api.example", + "--web", + "https://web.example", + }; + const options = try parseAddressArgs(&args); + try std.testing.expect(options.create); + try std.testing.expectEqualStrings("https://api.example", options.api.?); + try std.testing.expectEqualStrings("https://web.example", options.web.?); +} + +test "address parser accepts raw output only for an existing address" { + const args = [_][]const u8{ "address", "--raw" }; + const options = try parseAddressArgs(&args); + try std.testing.expect(options.raw); + try std.testing.expect(!options.create); +} + +test "address parser rejects unsupported commands and conflicting modes" { + const unsupported = [_][]const u8{"share"}; + const conflicting = [_][]const u8{ "address", "create", "--raw" }; + try std.testing.expectError(error.Usage, parseAddressArgs(&unsupported)); + try std.testing.expectError(error.Usage, parseAddressArgs(&conflicting)); +} + +test "address commands keep output streams separate and repair modes under umask" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const previous_umask = std.c.umask(0o777); + defer _ = std.c.umask(previous_umask); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + const create = [_][]const u8{ "address", "create" }; + try std.testing.expectEqual(@as(u8, 0), runAddress(&create, runtime)); + try std.testing.expect(std.mem.startsWith(u8, stdout.written(), config.managed_web ++ "/drop-for/")); + try std.testing.expectEqual(@as(usize, 0), stderr.written().len); + + const created = try std.testing.allocator.dupe(u8, stdout.written()); + defer std.testing.allocator.free(created); + stdout.clearRetainingCapacity(); + var config_dir = try tmp.dir.openDir(std.testing.io, "home/.config/ashdrop", .{ .iterate = true }); + try config_dir.setPermissions(std.testing.io, @enumFromInt(0o755)); + try config_dir.setFilePermissions(std.testing.io, "identity.json", @enumFromInt(0o644), .{}); + config_dir.close(std.testing.io); + const show = [_][]const u8{"address"}; + try std.testing.expectEqual(@as(u8, 0), runAddress(&show, runtime)); + try std.testing.expectEqualSlices(u8, created, stdout.written()); + try std.testing.expectEqual(@as(usize, 0), stderr.written().len); + + stdout.clearRetainingCapacity(); + const raw = [_][]const u8{ "address", "--raw" }; + try std.testing.expectEqual(@as(u8, 0), runAddress(&raw, runtime)); + try std.testing.expectEqual(@as(usize, 88), stdout.written().len); + try std.testing.expectEqual(@as(u8, '\n'), stdout.written()[87]); + try std.testing.expectEqual(@as(usize, 0), stderr.written().len); + const before_duplicate = try std.testing.allocator.dupe(u8, stdout.written()); + defer std.testing.allocator.free(before_duplicate); + + stdout.clearRetainingCapacity(); + stderr.clearRetainingCapacity(); + try std.testing.expectEqual(@as(u8, 2), runAddress(&create, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expect(std.mem.indexOf(u8, stderr.written(), "already exists") != null); + + stdout.clearRetainingCapacity(); + stderr.clearRetainingCapacity(); + try std.testing.expectEqual(@as(u8, 0), runAddress(&raw, runtime)); + try std.testing.expectEqualSlices(u8, before_duplicate, stdout.written()); + try std.testing.expectEqual(@as(usize, 0), stderr.written().len); + + config_dir = try tmp.dir.openDir(std.testing.io, "home/.config/ashdrop", .{ .iterate = true }); + defer config_dir.close(std.testing.io); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o700), (try config_dir.stat(std.testing.io)).permissions.toMode() & 0o777); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), (try config_dir.statFile(std.testing.io, "identity.json", .{})).permissions.toMode() & 0o777); +} + +test "share and pull malformed commands report standard usage on stderr" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + const share = [_][]const u8{"share"}; + try std.testing.expectEqual(@as(u8, 2), runCommand(&share, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("usage: ashdrop [options]\n", stderr.written()); + + stdout.clearRetainingCapacity(); + stderr.clearRetainingCapacity(); + const pull = [_][]const u8{"pull"}; + try std.testing.expectEqual(@as(u8, 2), runCommand(&pull, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("usage: ashdrop [options]\n", stderr.written()); +} + +test "address missing identity reports status 2 on stderr" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + const address = [_][]const u8{"address"}; + try std.testing.expectEqual(@as(u8, 2), runCommand(&address, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: receive identity does not exist; run `ashdrop address create`\n", stderr.written()); + + stdout.clearRetainingCapacity(); + stderr.clearRetainingCapacity(); + const raw = [_][]const u8{ "address", "--raw" }; + try std.testing.expectEqual(@as(u8, 2), runCommand(&raw, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: receive identity does not exist; run `ashdrop address create`\n", stderr.written()); +} + +test "share source-file failures are operational errors" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const raw = links.formatRawReceive(std.crypto.ecc.P256.basePoint.toUncompressedSec1()); + const share = [_][]const u8{ "share", "--to", &raw, "--file", "missing.env" }; + + try std.testing.expectEqual(@as(u8, 1), runCommand(&share, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: input file does not exist\n", stderr.written()); +} + +test "invalid create response ID is an operational API failure" { + const test_server = @import("test_server.zig"); + const expected = [_]test_server.ExpectedRequest{ + .{ + .method = .POST, + .target = "/api/secrets", + .json_body = true, + .response_status = @enumFromInt(201), + .response_body = "{\"id\":\"INVALID\",\"notifyToken\":\"notify\",\"expiresAt\":1}", + }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const api_env = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(api_env); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "source.env", .data = "TOKEN=never-print\n" }); + var path_buffer: [4096]u8 = undefined; + const path_len = try tmp.dir.realPath(std.testing.io, &path_buffer); + var source_path_buffer: [4096]u8 = undefined; + const source_path = try std.fmt.bufPrint(&source_path_buffer, "{s}/source.env", .{path_buffer[0..path_len]}); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .api_env = api_env, + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const recipient = links.formatRawReceive(std.crypto.ecc.P256.basePoint.toUncompressedSec1()); + const args = [_][]const u8{ "share", "--to", &recipient, "--file", source_path }; + + try std.testing.expectEqual(@as(u8, 1), runCommand(&args, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: API request failed\n", stderr.written()); + try server.deinit(); + server_live = false; +} + +test "directory output failures are operational errors" { + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + + try std.testing.expectEqual(@as(u8, 1), writeFailure(&stderr.writer, error.OutputIsDirectory)); + try std.testing.expectEqualStrings("ashdrop: output path is a directory\n", stderr.written()); +} + +test "output preparation and write failures use output diagnostics" { + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + + try std.testing.expectEqual(@as(u8, 1), writeFailure(&stderr.writer, error.OutputPreparationFailed)); + try std.testing.expectEqualStrings("ashdrop: could not prepare output path\n", stderr.written()); + stderr.clearRetainingCapacity(); + try std.testing.expectEqual(@as(u8, 1), writeFailure(&stderr.writer, error.OutputWriteFailed)); + try std.testing.expectEqualStrings("ashdrop: could not write output file\n", stderr.written()); +} + +test "missing pull output parent is an output error, not an identity error" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + var dir_path: [4096]u8 = undefined; + const dir_len = try tmp.dir.realPath(std.testing.io, &dir_path); + var output_path: [4096]u8 = undefined; + const output = try std.fmt.bufPrint(&output_path, "{s}/missing/.env.ashdrop", .{dir_path[0..dir_len]}); + const args = [_][]const u8{ "pull", "0123456789abcdef0123456789abcdef", "--output", output }; + + try std.testing.expectEqual(@as(u8, 1), runCommand(&args, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: could not prepare output path\n", stderr.written()); +} + +test "address invalid identity and configuration report status 2 on stderr" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + try config_dir.writeFile(std.testing.io, .{ .sub_path = "identity.json", .data = "{}" }); + const address = [_][]const u8{"address"}; + try std.testing.expectEqual(@as(u8, 2), runCommand(&address, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: stored receive identity is invalid\n", stderr.written()); + + stdout.clearRetainingCapacity(); + stderr.clearRetainingCapacity(); + const invalid_api = [_][]const u8{ "address", "--api", "invalid" }; + try std.testing.expectEqual(@as(u8, 2), runCommand(&invalid_api, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: invalid API or web endpoint\n", stderr.written()); +} + +const FlushFailingWriter = struct { + const vtable: std.Io.Writer.VTable = .{ + .drain = drain, + .flush = flush, + }; + + fn drain(_: *std.Io.Writer, _: []const []const u8, _: usize) std.Io.Writer.Error!usize { + return error.WriteFailed; + } + + fn flush(_: *std.Io.Writer) std.Io.Writer.Error!void { + return error.WriteFailed; + } +}; + +test "invalid endpoint prevents address creation before identity persistence" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + + const create = [_][]const u8{ "address", "create", "--api", "https://:443" }; + try std.testing.expectEqual(@as(u8, 2), runCommand(&create, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: invalid API or web endpoint\n", stderr.written()); + try std.testing.expectError( + error.FileNotFound, + tmp.dir.statFile(std.testing.io, "home/.config/ashdrop/identity.json", .{}), + ); +} + +test "flush failure changes successful command status to 1" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var output_buffer: [256]u8 = undefined; + var stdout = std.Io.Writer{ .vtable = &FlushFailingWriter.vtable, .buffer = &output_buffer }; + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout, + .stderr = &stderr.writer, + }; + + const create = [_][]const u8{ "address", "create" }; + try std.testing.expectEqual(@as(u8, 0), runCommand(&create, runtime)); + try std.testing.expectEqual(@as(u8, 1), finishCommand(0, &stdout, &stderr.writer)); + try std.testing.expectEqual(@as(u8, 2), finishCommand(2, &stdout, &stderr.writer)); +} diff --git a/ashdrop/cli/src/test_server.zig b/ashdrop/cli/src/test_server.zig new file mode 100644 index 0000000..28e03fd --- /dev/null +++ b/ashdrop/cli/src/test_server.zig @@ -0,0 +1,99 @@ +const std = @import("std"); + +pub const ExpectedRequest = struct { + method: std.http.Method, + target: []const u8, + json_body: bool = false, + response_status: std.http.Status, + response_body: []const u8, + response_headers: []const std.http.Header = &.{}, +}; + +pub const Server = struct { + io: std.Io, + listener: std.Io.net.Server = undefined, + thread: std.Thread = undefined, + expected: []const ExpectedRequest, + next: usize = 0, + failure: ?anyerror = null, + shutting_down: std.atomic.Value(bool) = .init(false), + + pub fn init(self: *Server, io: std.Io, expected: []const ExpectedRequest) !void { + self.* = .{ + .io = io, + .expected = expected, + .shutting_down = .init(false), + }; + const address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + self.listener = try address.listen(io, .{}); + self.thread = try std.Thread.spawn(.{}, serve, .{self}); + } + + pub fn deinit(self: *Server) !void { + self.shutting_down.store(true, .release); + const address = self.listener.socket.address; + if (std.Io.net.IpAddress.connect(&address, self.io, .{ .mode = .stream })) |stream| { + stream.close(self.io); + } else |_| {} + self.listener.socket.close(self.io); + self.thread.join(); + self.listener = undefined; + if (self.failure) |err| return err; + if (self.next != self.expected.len) return error.TestServerMissedRequest; + } + + pub fn port(self: *const Server) u16 { + return self.listener.socket.address.getPort(); + } + + fn serve(self: *Server) void { + while (self.next < self.expected.len) { + serveOne(self) catch |err| { + if (self.shutting_down.load(.acquire)) return; + self.failure = err; + return; + }; + } + } + + fn serveOne(self: *Server) !void { + var stream = try self.listener.accept(self.io); + defer stream.close(self.io); + var read_buffer: [8192]u8 = undefined; + var write_buffer: [8192]u8 = undefined; + var reader = stream.reader(self.io, &read_buffer); + var writer = stream.writer(self.io, &write_buffer); + var server = std.http.Server.init(&reader.interface, &writer.interface); + var request = try server.receiveHead(); + const expected = self.expected[self.next]; + if (request.head.method != expected.method or !std.mem.eql(u8, request.head.target, expected.target)) { + return error.TestServerUnexpectedRequest; + } + if (expected.json_body and !std.mem.eql(u8, request.head.content_type orelse "", "application/json")) { + return error.TestServerUnexpectedContentType; + } + + var body_buffer: [8192]u8 = undefined; + const body_reader = request.readerExpectNone(&.{}); + const body_len = body_reader.readSliceShort(&body_buffer) catch return error.TestServerReadFailed; + const body = body_buffer[0..body_len]; + if (expected.json_body) { + var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, body, .{}) catch return error.TestServerInvalidJson; + parsed.deinit(); + } + try request.respond(expected.response_body, .{ + .status = expected.response_status, + .extra_headers = expected.response_headers, + }); + self.next += 1; + } +}; + +test "server teardown before expected requests returns without waiting for a client" { + const expected = [_]ExpectedRequest{ + .{ .method = .GET, .target = "/never", .response_status = .ok, .response_body = "{}" }, + }; + var server: Server = undefined; + try server.init(std.testing.io, &expected); + try std.testing.expectError(error.TestServerMissedRequest, server.deinit()); +} diff --git a/ashdrop/cli/test.zig b/ashdrop/cli/test.zig new file mode 100644 index 0000000..bdaf85c --- /dev/null +++ b/ashdrop/cli/test.zig @@ -0,0 +1,6 @@ +test { + _ = @import("src/main.zig"); + _ = @import("src/api.zig"); + _ = @import("src/files.zig"); + _ = @import("src/commands.zig"); +} diff --git a/ashdrop/cli/testdata/generate-protocol-v1.mjs b/ashdrop/cli/testdata/generate-protocol-v1.mjs new file mode 100644 index 0000000..0f07064 --- /dev/null +++ b/ashdrop/cli/testdata/generate-protocol-v1.mjs @@ -0,0 +1,79 @@ +// Generate with: node ashdrop/cli/testdata/generate-protocol-v1.mjs > ashdrop/cli/testdata/protocol-v1.json +// This uses Node.js Web Crypto via node:crypto webcrypto.subtle. +import { webcrypto } from "node:crypto"; + +const { subtle } = webcrypto; +const text = new TextEncoder(); +const b64url = (value) => Buffer.from(value).toString("base64url"); + +const recipientPrivateJwk = { + kty: "EC", + crv: "P-256", + d: "UZtCPXFfi11UmhpTs-AbIEzS-ahHhsyUVfPGME1HV4M", + x: "UwM5XZkcVodU5uKW10zVjhczheXnu9BkXEeU55pnvqk", + y: "IOBYm6ne2_y4SFHPCjx5F7apZez_KB3UfW6nwKOMD2w" +}; +const ephemeralPrivateJwk = { + kty: "EC", + crv: "P-256", + d: "lKG7sUuQamGigPJF-ek8fztOI6gvay2wqPTwxtL057k", + x: "XZHx0BUzMi6fuqyaJU3xCAlrXqA5h4QT6k1k8FJPhp0", + y: "uGn_3ZqlZcLWTmvZ1I-3QXST3HAd29HmdCtRESAXqM4" +}; +const iv = Uint8Array.from([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb]); +const plaintext = "DATABASE_URL=postgres://ashdrop\nTOKEN=top-secret\n"; + +const recipientPublicJwk = { + kty: "EC", + crv: "P-256", + x: recipientPrivateJwk.x, + y: recipientPrivateJwk.y +}; +const ephemeralPublicJwk = { + kty: "EC", + crv: "P-256", + x: ephemeralPrivateJwk.x, + y: ephemeralPrivateJwk.y +}; +const recipientPublicSec1 = Buffer.concat([ + Buffer.from([0x04]), + Buffer.from(recipientPrivateJwk.x, "base64url"), + Buffer.from(recipientPrivateJwk.y, "base64url") +]); +const ephemeralPublicSec1 = Buffer.concat([ + Buffer.from([0x04]), + Buffer.from(ephemeralPrivateJwk.x, "base64url"), + Buffer.from(ephemeralPrivateJwk.y, "base64url") +]); + +const recipientPublic = await subtle.importKey("jwk", recipientPublicJwk, { name: "ECDH", namedCurve: "P-256" }, false, []); +const ephemeralPrivate = await subtle.importKey("jwk", ephemeralPrivateJwk, { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); +const shared = await subtle.deriveBits({ name: "ECDH", public: recipientPublic }, ephemeralPrivate, 256); +const hkdf = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); +const aes = await subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(32), + info: text.encode("ashdrop-ecdh-v1") + }, + hkdf, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"] +); +const ciphertext = await subtle.encrypt({ name: "AES-GCM", iv }, aes, text.encode(plaintext)); + +const fixture = { + version: 1, + generated_by: "Node.js Web Crypto", + recipient_private_jwk: recipientPrivateJwk, + recipient_public_sec1: b64url(recipientPublicSec1), + ephemeral_private_jwk: ephemeralPrivateJwk, + ephemeral_public_sec1: b64url(ephemeralPublicSec1), + iv: b64url(iv), + ciphertext: b64url(ciphertext), + plaintext +}; + +process.stdout.write(`${JSON.stringify(fixture, null, 2)}\n`); diff --git a/ashdrop/cli/testdata/protocol-v1.json b/ashdrop/cli/testdata/protocol-v1.json new file mode 100644 index 0000000..7b92ec5 --- /dev/null +++ b/ashdrop/cli/testdata/protocol-v1.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "generated_by": "Node.js Web Crypto", + "recipient_private_jwk": { + "kty": "EC", + "crv": "P-256", + "d": "UZtCPXFfi11UmhpTs-AbIEzS-ahHhsyUVfPGME1HV4M", + "x": "UwM5XZkcVodU5uKW10zVjhczheXnu9BkXEeU55pnvqk", + "y": "IOBYm6ne2_y4SFHPCjx5F7apZez_KB3UfW6nwKOMD2w" + }, + "recipient_public_sec1": "BFMDOV2ZHFaHVObiltdM1Y4XM4Xl57vQZFxHlOeaZ76pIOBYm6ne2_y4SFHPCjx5F7apZez_KB3UfW6nwKOMD2w", + "ephemeral_private_jwk": { + "kty": "EC", + "crv": "P-256", + "d": "lKG7sUuQamGigPJF-ek8fztOI6gvay2wqPTwxtL057k", + "x": "XZHx0BUzMi6fuqyaJU3xCAlrXqA5h4QT6k1k8FJPhp0", + "y": "uGn_3ZqlZcLWTmvZ1I-3QXST3HAd29HmdCtRESAXqM4" + }, + "ephemeral_public_sec1": "BF2R8dAVMzIun7qsmiVN8QgJa16gOYeEE-pNZPBST4aduGn_3ZqlZcLWTmvZ1I-3QXST3HAd29HmdCtRESAXqM4", + "iv": "ABEiM0RVZneImaq7", + "ciphertext": "rYQfKJ7NGzgRSSMCVIAA76fmAriClbRWtr_mAdGgpA3-H2XWeCYMGVbdWzOAPCyLewCgPBRIadBEtMuNgcN6bJA", + "plaintext": "DATABASE_URL=postgres://ashdrop\nTOKEN=top-secret\n" +} From a5f7c46b2ba3fe8e8ed80ca5d074c7bd608f3cdf Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Thu, 16 Jul 2026 17:00:06 +0000 Subject: [PATCH 4/9] docs(cli): explain critical flow invariants --- ashdrop/cli/build.zig | 2 ++ ashdrop/cli/build.zig.zon | 1 + ashdrop/cli/src/api.zig | 5 +++++ ashdrop/cli/src/commands.zig | 5 +++++ ashdrop/cli/src/config.zig | 3 +++ ashdrop/cli/src/crypto.zig | 7 +++++++ ashdrop/cli/src/crypto_test.zig | 2 ++ ashdrop/cli/src/files.zig | 5 +++++ ashdrop/cli/src/identity.zig | 5 ++++- ashdrop/cli/src/links.zig | 4 ++++ ashdrop/cli/src/main.zig | 2 ++ ashdrop/cli/src/test_server.zig | 2 ++ ashdrop/cli/test.zig | 2 ++ ashdrop/cli/testdata/generate-protocol-v1.mjs | 1 + 14 files changed, 45 insertions(+), 1 deletion(-) diff --git a/ashdrop/cli/build.zig b/ashdrop/cli/build.zig index 6e2939b..90c0b66 100644 --- a/ashdrop/cli/build.zig +++ b/ashdrop/cli/build.zig @@ -1,3 +1,5 @@ +//! Builds the standalone Ashdrop CLI executable and its unit-test runner. + const std = @import("std"); pub fn build(b: *std.Build) void { diff --git a/ashdrop/cli/build.zig.zon b/ashdrop/cli/build.zig.zon index d74c431..c9a3805 100644 --- a/ashdrop/cli/build.zig.zon +++ b/ashdrop/cli/build.zig.zon @@ -1,3 +1,4 @@ +// Declares the standalone Ashdrop CLI package and its source paths. .{ .name = .ashdrop, .version = "0.1.0", diff --git a/ashdrop/cli/src/api.zig b/ashdrop/cli/src/api.zig index 841bc3c..3d026d0 100644 --- a/ashdrop/cli/src/api.zig +++ b/ashdrop/cli/src/api.zig @@ -1,3 +1,5 @@ +//! Provides the Ashdrop HTTP client, wire types, response bounds, and error mapping. + const std = @import("std"); pub const max_response_size = 96 * 1024; @@ -101,6 +103,7 @@ pub const Client = struct { const url = try endpointUrl(self.allocator, self.base_url, path); defer self.allocator.free(url); + // Bound untrusted API responses before copying them into allocator-owned memory. var response_buffer: [max_response_size]u8 = undefined; var response_writer = std.Io.Writer.fixed(&response_buffer); var http_client: std.http.Client = .{ @@ -115,6 +118,7 @@ pub const Client = struct { .location = .{ .url = url }, .method = method, .payload = payload, + // A configured API must not redirect ciphertext or metadata to another origin. .redirect_behavior = .not_allowed, .extra_headers = &headers, .response_writer = &response_writer, @@ -170,6 +174,7 @@ pub fn endpointUrl(allocator: std.mem.Allocator, base_url: []const u8, path: []c pub fn checkErrorResponse(status: u16, body: []const u8) !void { if (status == 429) return error.RateLimited; + // Remote error text is untrusted and may contain sensitive data, so never surface it to users. const ErrorResponse = struct { @"error": []const u8 }; var parsed = std.json.parseFromSlice(ErrorResponse, std.heap.page_allocator, body, .{}) catch return error.RemoteFailure; defer parsed.deinit(); diff --git a/ashdrop/cli/src/commands.zig b/ashdrop/cli/src/commands.zig index ccb9b68..46afaf3 100644 --- a/ashdrop/cli/src/commands.zig +++ b/ashdrop/cli/src/commands.zig @@ -1,3 +1,5 @@ +//! Parses share and pull commands and coordinates local files, crypto, links, and the API. + const std = @import("std"); const api = @import("api.zig"); const config = @import("config.zig"); @@ -228,6 +230,7 @@ pub fn pull(args: []const []const u8, runtime: Runtime) !void { } pub fn pullWithRemote(options: PullOptions, runtime: Runtime, remote: PullRemote) !void { + // Validate the destination before opening because a consumed one-view drop cannot be restored. var output = files.prepareOutput(runtime.allocator, runtime.io, runtime.cwd, options.output, options.force) catch |err| switch (err) { error.OutputExists, error.OutputIsDirectory, @@ -242,6 +245,7 @@ pub fn pullWithRemote(options: PullOptions, runtime: Runtime, remote: PullRemote const local_identity = try identity.load(runtime.allocator, runtime.io, config_dir, "identity.json"); const local_public = links.formatRawReceive(local_identity.publicSec1()); + // Metadata is non-consuming, so reject a wrong recipient before `open` spends a view. var metadata = (try remote.metadata(remote.context, options.drop)) orelse return error.DropUnavailable; defer metadata.deinit(runtime.allocator); if (!metadata.recipientKeyed or !std.mem.eql(u8, metadata.recipientPub, &local_public)) return error.RecipientMismatch; @@ -261,6 +265,7 @@ pub fn pullWithRemote(options: PullOptions, runtime: Runtime, remote: PullRemote runtime.allocator.free(plaintext); } if (!std.unicode.utf8ValidateSlice(plaintext)) return error.InvalidUtf8; + // Publish plaintext only after recipient matching, authentication, and content validation succeed. files.writeAtomically(runtime.io, &output, plaintext) catch |err| switch (err) { error.OutputExists => return err, else => return error.OutputWriteFailed, diff --git a/ashdrop/cli/src/config.zig b/ashdrop/cli/src/config.zig index a51a2b2..b5eb85f 100644 --- a/ashdrop/cli/src/config.zig +++ b/ashdrop/cli/src/config.zig @@ -1,3 +1,5 @@ +//! Resolves and validates managed, environment, embedded, and explicit Ashdrop endpoints. + const std = @import("std"); // pub const managed_api = "https://ashdrop.onrender.com"; @@ -7,6 +9,7 @@ pub const managed_api = "http://localhost:8080"; pub const managed_web = "http://localhost:5173"; pub fn resolveApi(flag: ?[]const u8, env: ?[]const u8, embedded: ?[]const u8) error{InvalidEndpoint}![]const u8 { + // A caller can override an embedded self-hosted endpoint without rewriting a received link. return validateEndpoint(flag orelse embedded orelse env orelse managed_api); } diff --git a/ashdrop/cli/src/crypto.zig b/ashdrop/cli/src/crypto.zig index aef6fb8..35cb992 100644 --- a/ashdrop/cli/src/crypto.zig +++ b/ashdrop/cli/src/crypto.zig @@ -1,3 +1,5 @@ +//! Implements the recipient-keyed P-256, HKDF, and AES-GCM protocol used by Ashdrop. + const std = @import("std"); const Aes256Gcm = std.crypto.aead.aes_gcm.Aes256Gcm; @@ -47,12 +49,14 @@ fn sealForRecipientWithRandomness( const recipient = parseRecipientPoint(recipient_sec1) catch return error.InvalidRecipient; var ephemeral_private: [32]u8 = undefined; defer std.crypto.secureZero(u8, &ephemeral_private); + // Each drop samples a new sender key, making reuse for the same recipient negligibly likely. randomPrivateScalar(random, &ephemeral_private); var ephemeral_public = P256.basePoint.mul(ephemeral_private, .big) catch unreachable; defer secureZeroValue(P256, &ephemeral_public); const ephemeral_sec1 = ephemeral_public.toUncompressedSec1(); var key: [Aes256Gcm.key_length]u8 = undefined; defer std.crypto.secureZero(u8, &key); + // The versioned ECDH/HKDF derivation keeps browser and CLI ciphertext interoperable. deriveKey(&ephemeral_private, recipient, &key) catch return error.InvalidRecipient; var iv: [Aes256Gcm.nonce_length]u8 = undefined; @@ -122,6 +126,7 @@ pub fn openForRecipient( const plaintext = try allocator.alloc(u8, encrypted_len); errdefer allocator.free(plaintext); const tag: [Aes256Gcm.tag_length]u8 = ciphertext[encrypted_len..][0..Aes256Gcm.tag_length].*; + // Authentication must succeed before plaintext becomes available to callers. Aes256Gcm.decrypt(plaintext, ciphertext[0..encrypted_len], tag, "", iv, key) catch return error.AuthenticationFailed; return plaintext; } @@ -144,6 +149,7 @@ fn parseEphemeralPoint(sec1: []const u8) error{InvalidInput}!P256 { fn deriveKey(private: *const [32]u8, peer_public: P256, key: *[Aes256Gcm.key_length]u8) error{InvalidInput}!void { var shared_point = peer_public.mul(private.*, .big) catch return error.InvalidInput; defer secureZeroValue(P256, &shared_point); + // Protocol v1 feeds the ECDH x-coordinate, not a serialized point, into HKDF. var shared_coordinates = shared_point.affineCoordinates(); defer secureZeroValue(@TypeOf(shared_coordinates), &shared_coordinates); var shared_x = shared_coordinates.x.toBytes(.big); @@ -184,6 +190,7 @@ fn encodeB64url(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { fn decodeB64url(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { const decoded_len = std.base64.url_safe_no_pad.Decoder.calcSizeForSlice(encoded) catch return error.InvalidInput; + // One canonical encoding prevents alternate links or payloads from representing the same bytes. if (!hasCanonicalTrailingBits(encoded)) return error.InvalidInput; const decoded = try allocator.alloc(u8, decoded_len); diff --git a/ashdrop/cli/src/crypto_test.zig b/ashdrop/cli/src/crypto_test.zig index e7b96e9..b64273a 100644 --- a/ashdrop/cli/src/crypto_test.zig +++ b/ashdrop/cli/src/crypto_test.zig @@ -1,3 +1,5 @@ +//! Tests protocol interoperability, malformed input handling, and allocation cleanup. + const std = @import("std"); const crypto = @import("crypto.zig"); diff --git a/ashdrop/cli/src/files.zig b/ashdrop/cli/src/files.zig index 74f98bb..e60fe83 100644 --- a/ashdrop/cli/src/files.zig +++ b/ashdrop/cli/src/files.zig @@ -1,3 +1,5 @@ +//! Validates shared environment files and writes received plaintext through safe atomic outputs. + const std = @import("std"); pub const max_file_size = 64 * 1024; @@ -50,6 +52,7 @@ fn openOutputParent(io: std.Io, base_dir: std.Io.Dir, path: []const u8) !OutputP } errdefer if (owns_current) current.close(io); + // Resolve each component without links so an intermediate symlink cannot redirect decrypted output. while (start < parent.len) { const end = std.mem.indexOfScalarPos(u8, parent, start, '/') orelse parent.len; const component = parent[start..end]; @@ -85,6 +88,7 @@ pub fn prepareOutput( const owns_dir = output_parent.owns_dir; errdefer if (owns_dir) output_dir.close(io); + // Apply the same no-symlink rule to the final filename before reserving a temporary file. const existing = output_dir.statFile(io, basename, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => null, else => |cause| return cause, @@ -115,6 +119,7 @@ pub fn prepareOutput( pub fn writeAtomically(io: std.Io, output: *PreparedOutput, content: []const u8) !void { try output.atomic_file.file.writeStreamingAll(io, content); try output.atomic_file.file.sync(io); + // Normal delivery links only if the destination stayed absent; replacement requires explicit force. if (output.force) { try output.atomic_file.replace(io); } else { diff --git a/ashdrop/cli/src/identity.zig b/ashdrop/cli/src/identity.zig index bff5588..fd41e89 100644 --- a/ashdrop/cli/src/identity.zig +++ b/ashdrop/cli/src/identity.zig @@ -1,3 +1,5 @@ +//! Creates, validates, and securely persists the local P-256 receive identity. + const std = @import("std"); pub const Identity = struct { @@ -113,6 +115,7 @@ pub fn openConfigDirAt(io: std.Io, home_base: std.Io.Dir, home_path: []const u8) fn openSecurePath(io: std.Io, base: std.Io.Dir, path: []const u8, enforce_final_mode: bool) !std.Io.Dir { if (path.len == 0) return error.InvalidHomePath; + // Identity storage is a security boundary: traversal and symlinked components are never followed. var current = base; var owns_current = false; var start: usize = 0; @@ -143,7 +146,7 @@ fn openSecurePath(io: std.Io, base: std.Io.Dir, path: []const u8, enforce_final_ break :blk true; }; if (created) { - // Repair a restrictive umask before opening the new directory descriptor. + // `mkdir` honors umask, so restore private permissions before this directory can hold keys. try current.setFilePermissions(io, component, directoryPermissions(), .{ .follow_symlinks = false }); } diff --git a/ashdrop/cli/src/links.zig b/ashdrop/cli/src/links.zig index e6f3ddb..6d6d972 100644 --- a/ashdrop/cli/src/links.zig +++ b/ashdrop/cli/src/links.zig @@ -1,3 +1,5 @@ +//! Parses and formats Ashdrop receive addresses, drop references, web URLs, and portable URIs. + const std = @import("std"); const config = @import("config.zig"); @@ -104,6 +106,7 @@ fn formatLink( return std.fmt.allocPrint(allocator, "{s}/{s}/{s}", .{ trimTrailingSlashes(valid_base), web_path, value }); } + // A self-hosted API without a web app remains usable by carrying its endpoint in the URI. const prefix = try std.fmt.allocPrint(allocator, "ashdrop://{s}/{s}?api=", .{ uri_kind, value }); defer allocator.free(prefix); const encoded_api_len = percentEncodedLen(api); @@ -134,6 +137,7 @@ fn parseAshdropUri( const query_index = std.mem.indexOfScalar(u8, remaining, '?') orelse return invalid_error; const value = remaining[0..query_index]; const query = remaining[query_index + 1 ..]; + // Accept one endpoint parameter only so the receiving client has unambiguous routing. if (value.len == 0 or !std.mem.startsWith(u8, query, "api=") or std.mem.indexOfScalar(u8, query[4..], '&') != null) { return invalid_error; } diff --git a/ashdrop/cli/src/main.zig b/ashdrop/cli/src/main.zig index 5d9279f..584c80f 100644 --- a/ashdrop/cli/src/main.zig +++ b/ashdrop/cli/src/main.zig @@ -1,3 +1,5 @@ +//! Defines the Ashdrop command-line entry point, dispatch, diagnostics, and address command. + const std = @import("std"); const config = @import("config.zig"); const commands = @import("commands.zig"); diff --git a/ashdrop/cli/src/test_server.zig b/ashdrop/cli/src/test_server.zig index 28e03fd..d38793f 100644 --- a/ashdrop/cli/src/test_server.zig +++ b/ashdrop/cli/src/test_server.zig @@ -1,3 +1,5 @@ +//! Provides a scripted loopback HTTP server for CLI request and response tests. + const std = @import("std"); pub const ExpectedRequest = struct { diff --git a/ashdrop/cli/test.zig b/ashdrop/cli/test.zig index bdaf85c..718efed 100644 --- a/ashdrop/cli/test.zig +++ b/ashdrop/cli/test.zig @@ -1,3 +1,5 @@ +//! Imports the CLI modules that expose inline unit tests to Zig's test runner. + test { _ = @import("src/main.zig"); _ = @import("src/api.zig"); diff --git a/ashdrop/cli/testdata/generate-protocol-v1.mjs b/ashdrop/cli/testdata/generate-protocol-v1.mjs index 0f07064..7ac03e3 100644 --- a/ashdrop/cli/testdata/generate-protocol-v1.mjs +++ b/ashdrop/cli/testdata/generate-protocol-v1.mjs @@ -1,5 +1,6 @@ // Generate with: node ashdrop/cli/testdata/generate-protocol-v1.mjs > ashdrop/cli/testdata/protocol-v1.json // This uses Node.js Web Crypto via node:crypto webcrypto.subtle. +// Generates the deterministic Web Crypto protocol vector consumed by the Zig CLI tests. import { webcrypto } from "node:crypto"; const { subtle } = webcrypto; From 80b9afee45539993375d80a544b25c0a0a28402b Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Thu, 16 Jul 2026 18:15:28 +0000 Subject: [PATCH 5/9] docs(cli): lead fixture with its purpose --- ashdrop/cli/testdata/generate-protocol-v1.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ashdrop/cli/testdata/generate-protocol-v1.mjs b/ashdrop/cli/testdata/generate-protocol-v1.mjs index 7ac03e3..a576a4c 100644 --- a/ashdrop/cli/testdata/generate-protocol-v1.mjs +++ b/ashdrop/cli/testdata/generate-protocol-v1.mjs @@ -1,6 +1,6 @@ +// Generates the deterministic Web Crypto protocol vector consumed by the Zig CLI tests. // Generate with: node ashdrop/cli/testdata/generate-protocol-v1.mjs > ashdrop/cli/testdata/protocol-v1.json // This uses Node.js Web Crypto via node:crypto webcrypto.subtle. -// Generates the deterministic Web Crypto protocol vector consumed by the Zig CLI tests. import { webcrypto } from "node:crypto"; const { subtle } = webcrypto; From 25176af610768e927c322698fd87187b7690fcb4 Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Fri, 17 Jul 2026 07:11:14 +0000 Subject: [PATCH 6/9] feat(cli): add private address inboxes --- ashdrop/api/main.go | 232 ++++++++++++++++++++++---- ashdrop/api/store.go | 145 +++++++++++++++-- ashdrop/cli/README.md | 42 ++++- ashdrop/cli/src/api.zig | 280 +++++++++++++++++++++++++++++++- ashdrop/cli/src/commands.zig | 102 ++++++++++++ ashdrop/cli/src/crypto.zig | 49 +++++- ashdrop/cli/src/crypto_test.zig | 100 ++++++++++++ ashdrop/cli/src/main.zig | 179 +++++++++++++++++++- ashdrop/cli/src/test_server.zig | 7 + render.yaml | 13 +- 10 files changed, 1083 insertions(+), 66 deletions(-) diff --git a/ashdrop/api/main.go b/ashdrop/api/main.go index 5ba952f..9f18945 100644 --- a/ashdrop/api/main.go +++ b/ashdrop/api/main.go @@ -3,13 +3,20 @@ package main import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/hmac" "crypto/rand" + "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "log" + "net" "net/http" "os" + "strconv" "strings" "sync" "time" @@ -19,12 +26,23 @@ const ( maxBody = 96 * 1024 // ciphertext is capped ~64KB; leave room for JSON maxTTL = 30 * 24 * 3600 // 30 days minTTL = 60 // 1 minute floor + + inboxProofHeader = "X-Ashdrop-Inbox-Proof" + inboxProofInfo = "ashdrop-inbox-v1" + + maxRateLimitWindows = 4096 ) type API struct { store *Store } +type inboxResponseItem struct { + ID string `json:"id"` + ExpiresAt int64 `json:"expiresAt"` + ViewsLeft int `json:"viewsLeft"` +} + func main() { dbPath := getenv("ASHDROP_DB", "ashdrop.db") st, err := OpenStore(dbPath) @@ -44,6 +62,9 @@ func main() { func newHandler(store *Store) http.Handler { api := &API{store: store} + trustProxy, _ := strconv.ParseBool(os.Getenv("ASHDROP_TRUST_PROXY")) + createLimiter := newIPRateLimiter(30) + inboxLimiter := newIPRateLimiter(30) mux := http.NewServeMux() mux.HandleFunc("POST /api/secrets", api.handleCreate) mux.HandleFunc("GET /api/secrets/{id}", api.handleGet) @@ -52,10 +73,12 @@ func newHandler(store *Store) http.Handler { mux.HandleFunc("POST /api/secrets/{id}/burn", api.handleBurn) mux.HandleFunc("GET /api/secrets/{id}/status", api.handleStatus) mux.HandleFunc("DELETE /api/secrets/{id}", api.handleDelete) + mux.HandleFunc("GET /api/inbox-key", api.handleInboxKey) + mux.Handle("GET /api/addresses/{recipientPub}/inbox", inboxRateLimit(http.HandlerFunc(api.handleInbox), inboxLimiter, trustProxy)) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) - return cors(rateLimit(mux)) + return cors(rateLimit(mux, createLimiter, trustProxy)) } func (api *API) handleCreate(w http.ResponseWriter, r *http.Request) { @@ -213,13 +236,111 @@ func (api *API) handleDelete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (api *API) handleInboxKey(w http.ResponseWriter, _ *http.Request) { + key, err := loadInboxServerKey(api.store) + if err != nil { + inboxInternalError(w) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]string{ + "publicKey": base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()), + }) +} + +func (api *API) handleInbox(w http.ResponseWriter, r *http.Request) { + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil || limit < 1 || limit > 100 { + inboxUnauthorized(w) + return + } + + recipientPub := r.PathValue("recipientPub") + recipientBytes, err := base64.RawURLEncoding.DecodeString(recipientPub) + if err != nil || len(recipientBytes) != 65 || recipientBytes[0] != 0x04 || base64.RawURLEncoding.EncodeToString(recipientBytes) != recipientPub { + inboxUnauthorized(w) + return + } + recipientKey, err := ecdh.P256().NewPublicKey(recipientBytes) + if err != nil { + inboxUnauthorized(w) + return + } + + at, err := strconv.ParseInt(r.URL.Query().Get("at"), 10, 64) + serverNow := time.Now().Unix() + if err != nil || at < serverNow-5*60 || at > serverNow+5*60 { + inboxUnauthorized(w) + return + } + + proofText := r.Header.Get(inboxProofHeader) + proof, err := base64.RawURLEncoding.DecodeString(proofText) + if err != nil || len(proof) != sha256.Size || base64.RawURLEncoding.EncodeToString(proof) != proofText { + clear(proof) + inboxUnauthorized(w) + return + } + defer clear(proof) + + serverKey, err := loadInboxServerKey(api.store) + if err != nil { + inboxInternalError(w) + return + } + sharedSecret, err := serverKey.ECDH(recipientKey) + if err != nil { + clear(sharedSecret) + inboxInternalError(w) + return + } + defer clear(sharedSecret) + hmacKey, err := hkdf.Key(sha256.New, sharedSecret, make([]byte, sha256.Size), inboxProofInfo, sha256.Size) + clear(sharedSecret) + if err != nil { + clear(hmacKey) + inboxInternalError(w) + return + } + defer clear(hmacKey) + + canonicalRequest := inboxProofInfo + "\nGET\n/api/addresses/" + recipientPub + "/inbox\nlimit=" + strconv.Itoa(limit) + "&at=" + strconv.FormatInt(at, 10) + mac := hmac.New(sha256.New, hmacKey) + clear(hmacKey) + _, _ = mac.Write([]byte(canonicalRequest)) + expectedProof := mac.Sum(nil) + proofMatches := subtle.ConstantTimeCompare(proof, expectedProof) + clear(expectedProof) + clear(proof) + if proofMatches != 1 { + inboxUnauthorized(w) + return + } + + items, err := api.store.ListInbox(recipientPub, limit) + if err != nil { + inboxInternalError(w) + return + } + responseItems := make([]inboxResponseItem, len(items)) + for i, item := range items { + responseItems[i] = inboxResponseItem{ + ID: item.ID, + ExpiresAt: item.ExpiresAt, + ViewsLeft: item.ViewsLeft, + } + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"items": responseItems}) +} + // ---- middleware ---- func cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Ashdrop-Inbox-Proof") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -228,33 +349,56 @@ func cors(next http.Handler) http.Handler { }) } +type rateWindow struct { + start time.Time + count int +} + +type ipRateLimiter struct { + mu sync.Mutex + windows map[string]rateWindow + limit int +} + +func newIPRateLimiter(limit int) *ipRateLimiter { + return &ipRateLimiter{ + windows: make(map[string]rateWindow), + limit: limit, + } +} + +func (l *ipRateLimiter) allow(ip string) bool { + current := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + + for key, window := range l.windows { + if current.Sub(window.start) > time.Minute { + delete(l.windows, key) + } + } + + window, found := l.windows[ip] + if !found { + if len(l.windows) >= maxRateLimitWindows { + return false + } + l.windows[ip] = rateWindow{start: current, count: 1} + return true + } + window.count++ + l.windows[ip] = window + return window.count <= l.limit +} + // rateLimit is a small fixed-window per-IP limiter on creates. Other requests pass. -func rateLimit(next http.Handler) http.Handler { - type win struct { - start time.Time - count int - } - var ( - mu sync.Mutex - windows = map[string]*win{} - limit = 30 // creates per minute per IP - ) +func rateLimit(next http.Handler, limiter *ipRateLimiter, trustProxy bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/secrets" { next.ServeHTTP(w, r) return } - ip := clientIP(r) - mu.Lock() - x := windows[ip] - if x == nil || time.Since(x.start) > time.Minute { - x = &win{start: time.Now()} - windows[ip] = x - } - x.count++ - over := x.count > limit - mu.Unlock() - if over { + if !limiter.allow(clientIP(r, trustProxy)) { w.Header().Set("Retry-After", "60") httpError(w, http.StatusTooManyRequests, "slow down") return @@ -263,6 +407,18 @@ func rateLimit(next http.Handler) http.Handler { }) } +// inboxRateLimit protects the ECDH inbox path from unauthenticated CPU abuse. +func inboxRateLimit(next http.Handler, limiter *ipRateLimiter, trustProxy bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !limiter.allow(clientIP(r, trustProxy)) { + w.Header().Set("Retry-After", "60") + httpError(w, http.StatusTooManyRequests, "too many requests") + return + } + next.ServeHTTP(w, r) + }) +} + // ---- helpers ---- func viewsLeft(sec *Secret) int { @@ -277,6 +433,23 @@ func validPublicKey(encoded string) bool { return err == nil && len(pub) == 65 && pub[0] == 0x04 && base64.RawURLEncoding.EncodeToString(pub) == encoded } +func inboxUnauthorized(w http.ResponseWriter) { + httpError(w, http.StatusUnauthorized, "unauthorized") +} + +func inboxInternalError(w http.ResponseWriter) { + httpError(w, http.StatusInternalServerError, "internal server error") +} + +func loadInboxServerKey(store *Store) (*ecdh.PrivateKey, error) { + privateKey, err := store.InboxPrivateKey() + if err != nil { + return nil, err + } + defer clear(privateKey) + return ecdh.P256().NewPrivateKey(privateKey) +} + func cleanupLoop(s *Store) { t := time.NewTicker(5 * time.Minute) defer t.Stop() @@ -287,12 +460,15 @@ func cleanupLoop(s *Store) { } } -func clientIP(r *http.Request) string { - if f := r.Header.Get("X-Forwarded-For"); f != "" { - return strings.TrimSpace(strings.Split(f, ",")[0]) +func clientIP(r *http.Request, trustProxy bool) string { + if trustProxy { + forwarded, _, _ := strings.Cut(r.Header.Get("X-Forwarded-For"), ",") + if forwarded = strings.TrimSpace(forwarded); forwarded != "" { + return forwarded + } } - host, _, found := strings.Cut(r.RemoteAddr, ":") - if !found { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { return r.RemoteAddr } return host diff --git a/ashdrop/api/store.go b/ashdrop/api/store.go index 720c1a0..d35d5e3 100644 --- a/ashdrop/api/store.go +++ b/ashdrop/api/store.go @@ -1,7 +1,10 @@ package main import ( + "bytes" "context" + "crypto/ecdh" + "crypto/rand" "database/sql" "errors" "strings" @@ -18,12 +21,20 @@ type Secret struct { MaxViews int // 0 = unlimited Views int ExpiresAt int64 + CreatedAt int64 NotifyTok string OpenedAt *int64 RecipientPub string // non-empty = recipient-keyed drop (ECDH); recipient's public key EphemeralPub string // non-empty = recipient-keyed drop (ECDH); sender's ephemeral public key } +// InboxItem is the recipient-visible metadata for an unburned inbox entry. +type InboxItem struct { + ID string + ExpiresAt int64 + ViewsLeft int +} + // Store is the persistence seam. SQLite today; swappable for Redis at scale // without touching the handlers. type Store struct{ db *sql.DB } @@ -37,12 +48,17 @@ CREATE TABLE IF NOT EXISTS secrets ( views INTEGER NOT NULL DEFAULT 0, burned INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, notify_token TEXT NOT NULL, opened_at INTEGER, recipient_pub TEXT NOT NULL DEFAULT '', ephemeral_pub TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_secrets_expires ON secrets(expires_at); +CREATE TABLE IF NOT EXISTS inbox_keys ( + id INTEGER PRIMARY KEY CHECK (id = 1), + private_key BLOB NOT NULL +); ` func OpenStore(path string) (*Store, error) { @@ -55,11 +71,19 @@ func OpenStore(path string) (*Store, error) { if _, err := db.Exec(schema); err != nil { return nil, err } - // Migrate existing databases: safe to ignore errors when columns already exist. - _, _ = db.Exec(`ALTER TABLE secrets ADD COLUMN pin_protected INTEGER NOT NULL DEFAULT 0`) - _, _ = db.Exec(`ALTER TABLE secrets ADD COLUMN ephemeral_pub TEXT NOT NULL DEFAULT ''`) - _, err = db.Exec(`ALTER TABLE secrets ADD COLUMN recipient_pub TEXT NOT NULL DEFAULT ''`) - if err != nil && !strings.Contains(err.Error(), "duplicate column name: recipient_pub") { + // Migrate existing databases before creating an index that uses new columns. + for _, migration := range []string{ + `ALTER TABLE secrets ADD COLUMN pin_protected INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE secrets ADD COLUMN ephemeral_pub TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE secrets ADD COLUMN recipient_pub TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE secrets ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0`, + } { + if _, err := db.Exec(migration); err != nil && !strings.Contains(err.Error(), "duplicate column name") { + _ = db.Close() + return nil, err + } + } + if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_secrets_recipient_inbox ON secrets(recipient_pub, burned, expires_at, created_at, id)`); err != nil { _ = db.Close() return nil, err } @@ -71,14 +95,107 @@ func (s *Store) Close() error { return s.db.Close() } func now() int64 { return time.Now().Unix() } func (s *Store) Put(id string, sec Secret) error { + createdAt := sec.CreatedAt + if createdAt == 0 { + createdAt = now() + } _, err := s.db.Exec( - `INSERT INTO secrets (id, ciphertext, iv, max_views, views, burned, expires_at, notify_token, recipient_pub, ephemeral_pub) - VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`, - id, sec.Ciphertext, sec.IV, sec.MaxViews, sec.ExpiresAt, sec.NotifyTok, sec.RecipientPub, sec.EphemeralPub, + `INSERT INTO secrets (id, ciphertext, iv, max_views, views, burned, expires_at, created_at, notify_token, recipient_pub, ephemeral_pub) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)`, + id, sec.Ciphertext, sec.IV, sec.MaxViews, sec.ExpiresAt, createdAt, sec.NotifyTok, sec.RecipientPub, sec.EphemeralPub, ) return err } +// InboxPrivateKey returns the canonical P-256 private scalar used to decrypt +// recipient inbox entries. Persisted key material is never replaced on error. +func (s *Store) InboxPrivateKey() ([]byte, error) { + var stored []byte + err := s.db.QueryRow(`SELECT private_key FROM inbox_keys WHERE id = 1`).Scan(&stored) + if err == nil { + key, err := ecdh.P256().NewPrivateKey(stored) + if err != nil || !bytes.Equal(stored, key.Bytes()) { + if err != nil { + return nil, err + } + return nil, errors.New("invalid inbox private key") + } + return append([]byte(nil), stored...), nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + var privateKey []byte + for { + candidate := make([]byte, 32) + if _, err := rand.Read(candidate); err != nil { + return nil, err + } + key, err := ecdh.P256().NewPrivateKey(candidate) + if err == nil { + privateKey = key.Bytes() + break + } + } + + result, err := s.db.Exec(`INSERT OR IGNORE INTO inbox_keys (id, private_key) VALUES (1, ?)`, privateKey) + if err != nil { + return nil, err + } + if rows, err := result.RowsAffected(); err != nil { + return nil, err + } else if rows == 1 { + return append([]byte(nil), privateKey...), nil + } + + // Another store created the key after the initial lookup; validate it rather + // than replacing it so corruption remains observable. + if err := s.db.QueryRow(`SELECT private_key FROM inbox_keys WHERE id = 1`).Scan(&stored); err != nil { + return nil, err + } + key, err := ecdh.P256().NewPrivateKey(stored) + if err != nil || !bytes.Equal(stored, key.Bytes()) { + if err != nil { + return nil, err + } + return nil, errors.New("invalid inbox private key") + } + return append([]byte(nil), stored...), nil +} + +// ListInbox returns recipient-visible metadata for active recipient-keyed drops. +func (s *Store) ListInbox(recipientPub string, limit int) ([]InboxItem, error) { + if limit <= 0 { + return []InboxItem{}, nil + } + rows, err := s.db.Query( + `SELECT id, expires_at, CASE WHEN max_views = 0 THEN -1 ELSE max_views - views END + FROM secrets + WHERE recipient_pub = ? AND burned = 0 AND expires_at > ? + ORDER BY created_at ASC, id ASC + LIMIT ?`, + recipientPub, now(), limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() //nolint:errcheck + + var inbox []InboxItem + for rows.Next() { + var item InboxItem + if err := rows.Scan(&item.ID, &item.ExpiresAt, &item.ViewsLeft); err != nil { + return nil, err + } + inbox = append(inbox, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return inbox, nil +} + // Metadata returns public drop state without reading the encrypted payload. // Expired rows are reaped lazily. Returns (nil, nil) for a missing or burned drop. func (s *Store) Metadata(id string) (*Secret, error) { @@ -86,9 +203,9 @@ func (s *Store) Metadata(id string) (*Secret, error) { var openedAt sql.NullInt64 var burned int row := s.db.QueryRow( - `SELECT max_views, views, burned, expires_at, opened_at, recipient_pub, ephemeral_pub + `SELECT max_views, views, burned, expires_at, created_at, opened_at, recipient_pub, ephemeral_pub FROM secrets WHERE id = ?`, id) - err := row.Scan(&sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &openedAt, &sec.RecipientPub, &sec.EphemeralPub) + err := row.Scan(&sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.CreatedAt, &openedAt, &sec.RecipientPub, &sec.EphemeralPub) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -122,9 +239,9 @@ func (s *Store) Open(id string) (*Secret, error) { var burned int var openedAt sql.NullInt64 row := tx.QueryRow( - `SELECT ciphertext, iv, max_views, views, burned, expires_at, opened_at, ephemeral_pub + `SELECT ciphertext, iv, max_views, views, burned, expires_at, created_at, opened_at, ephemeral_pub FROM secrets WHERE id = ?`, id) - err = row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &openedAt, &sec.EphemeralPub) + err = row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.CreatedAt, &openedAt, &sec.EphemeralPub) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -181,9 +298,9 @@ func (s *Store) Fetch(id string) (*Secret, error) { var openedAt sql.NullInt64 var burned int row := s.db.QueryRow( - `SELECT ciphertext, iv, max_views, views, burned, expires_at, notify_token, opened_at, recipient_pub, ephemeral_pub + `SELECT ciphertext, iv, max_views, views, burned, expires_at, created_at, notify_token, opened_at, recipient_pub, ephemeral_pub FROM secrets WHERE id = ?`, id) - err := row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.NotifyTok, &openedAt, &sec.RecipientPub, &sec.EphemeralPub) + err := row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.CreatedAt, &sec.NotifyTok, &openedAt, &sec.RecipientPub, &sec.EphemeralPub) if errors.Is(err, sql.ErrNoRows) { return nil, nil } diff --git a/ashdrop/cli/README.md b/ashdrop/cli/README.md index d67318a..261fe56 100644 --- a/ashdrop/cli/README.md +++ b/ashdrop/cli/README.md @@ -48,6 +48,31 @@ The identity file contains the private key needed to decrypt drops. The CLI has no private-key export command. Browser and CLI identities are separate; there is no automatic identity transfer between them. +## Inbox + +```sh +./zig-out/bin/ashdrop inbox [--limit ] [--api ] +``` + +`inbox` lists up to 20 pending recipient-keyed drops by default. `--limit` +accepts a decimal value from `1` through `100`. Its output contains only the +drop `ID`, `expiresAt`, and `viewsLeft`; it never fetches or prints plaintext. +Use a listed ID with `pull` to receive a drop: + +```sh +./zig-out/bin/ashdrop pull +``` + +The receive address is public and safe to hand to senders. Listing its pending +drops is private: the CLI loads the local receive identity, obtains the API's +public inbox key, and derives an authenticated listing proof through P-256 +ECDH, HKDF, and HMAC. The API generates and persists its own inbox key; the +CLI never uploads the recipient private key. + +Inbox proofs require HTTPS for non-loopback API endpoints. `http://localhost`, +any `http://127.x.x.x` address, and `http://[::1]` are allowed for local +development. + ## Share ```sh @@ -102,15 +127,16 @@ API: https://ashdrop.onrender.com Web: https://ashdrop.vercel.app ``` -`address` and `share` accept `--api` and `--web`. `pull` accepts `--api`. +`address` and `share` accept `--api` and `--web`. `pull` and `inbox` accept +`--api`. `ASHDROP_API_URL` provides the API endpoint for all network commands, -including `share` and `pull` (and is also used when `address` formats a -self-hosted receive reference). `ASHDROP_WEB_URL` affects only the human URLs -emitted by `address` and `share`; `pull` does not use it. An explicit `--api` -takes precedence over an API embedded in an Ashdrop URI, which takes precedence -over `ASHDROP_API_URL`; otherwise the managed API is used. `--web` takes -precedence over `ASHDROP_WEB_URL`; otherwise links using the managed API use -the managed web URL. +including `share`, `pull`, and `inbox` (and is also used when `address` formats +a self-hosted receive reference). `ASHDROP_WEB_URL` affects only the human URLs +emitted by `address` and `share`; `pull` and `inbox` do not use it. An explicit +`--api` takes precedence over an API embedded in an Ashdrop URI, which takes +precedence over `ASHDROP_API_URL`; otherwise the managed API is used. `--web` +takes precedence over `ASHDROP_WEB_URL`; otherwise links using the managed API +use the managed web URL. For a custom API without a configured web URL, `address` and `share` print self-contained Ashdrop URIs instead of web URLs: diff --git a/ashdrop/cli/src/api.zig b/ashdrop/cli/src/api.zig index 3d026d0..c945723 100644 --- a/ashdrop/cli/src/api.zig +++ b/ashdrop/cli/src/api.zig @@ -51,6 +51,22 @@ pub const OpenedSecret = struct { } }; +pub const InboxItem = struct { + id: []u8, + expiresAt: i64, + viewsLeft: i64, + + pub fn deinit(self: *InboxItem, allocator: std.mem.Allocator) void { + allocator.free(self.id); + self.* = undefined; + } +}; + +pub fn deinitInboxItems(allocator: std.mem.Allocator, items: []InboxItem) void { + for (items) |*item| item.deinit(allocator); + allocator.free(items); +} + pub const Client = struct { allocator: std.mem.Allocator, io: std.Io, @@ -94,6 +110,38 @@ pub const Client = struct { return try parseOpenedSecret(self.allocator, response.body); } + pub fn inboxKey(self: *Client) ![65]u8 { + try validateInboxEndpoint(self.base_url); + const response = try self.request(.GET, "/api/inbox-key", null); + defer self.allocator.free(response.body); + if (response.status < 200 or response.status >= 300) { + try checkErrorResponse(response.status, response.body); + return error.RemoteFailure; + } + return parseInboxKey(self.allocator, response.body); + } + + pub fn inbox( + self: *Client, + recipient_pub: []const u8, + limit: u32, + at: i64, + proof: []const u8, + ) ![]InboxItem { + try validateInboxEndpoint(self.base_url); + _ = parseCanonicalP256Public(recipient_pub) catch return error.InvalidInboxRequest; + if (limit == 0 or limit > 100 or !isCanonicalInboxProof(proof)) return error.InvalidInboxRequest; + const response = try self.inboxRequest(recipient_pub, limit, at, proof); + defer self.allocator.free(response.body); + if (response.status == 401) return error.InboxUnauthorized; + if (response.status == 429) return error.InboxRateLimited; + if (response.status < 200 or response.status >= 300) { + try checkErrorResponse(response.status, response.body); + return error.RemoteFailure; + } + return parseInboxItems(self.allocator, response.body, limit); + } + const Response = struct { status: u16, body: []u8, @@ -102,7 +150,31 @@ pub const Client = struct { fn request(self: *Client, method: std.http.Method, path: []const u8, payload: ?[]const u8) !Response { const url = try endpointUrl(self.allocator, self.base_url, path); defer self.allocator.free(url); + return self.fetch(method, url, payload, &.{}); + } + fn inboxRequest(self: *Client, recipient_pub: []const u8, limit: u32, at: i64, proof: []const u8) !Response { + const path = try std.fmt.allocPrint(self.allocator, "/api/addresses/{s}/inbox", .{recipient_pub}); + defer self.allocator.free(path); + const endpoint = try endpointUrl(self.allocator, self.base_url, path); + defer self.allocator.free(endpoint); + const url = try std.fmt.allocPrint(self.allocator, "{s}?limit={d}&at={d}", .{ endpoint, limit, at }); + defer self.allocator.free(url); + const headers = [_]std.http.Header{ + .{ .name = "x-ashdrop-inbox-proof", .value = proof }, + }; + return self.fetch(.GET, url, null, &headers); + } + + // All API paths use one bounded, redirect-free fetch implementation. + fn fetch( + self: *Client, + method: std.http.Method, + url: []const u8, + payload: ?[]const u8, + extra_headers: []const std.http.Header, + ) !Response { + if (extra_headers.len > 1) return error.InvalidRequestHeaders; // Bound untrusted API responses before copying them into allocator-owned memory. var response_buffer: [max_response_size]u8 = undefined; var response_writer = std.Io.Writer.fixed(&response_buffer); @@ -111,16 +183,16 @@ pub const Client = struct { .io = self.io, }; defer http_client.deinit(); - const headers = [_]std.http.Header{ - .{ .name = "content-type", .value = "application/json" }, - }; + var headers: [2]std.http.Header = undefined; + headers[0] = .{ .name = "content-type", .value = "application/json" }; + if (extra_headers.len == 1) headers[1] = extra_headers[0]; const result = http_client.fetch(.{ .location = .{ .url = url }, .method = method, .payload = payload, - // A configured API must not redirect ciphertext or metadata to another origin. + // A configured API must not redirect ciphertext, metadata, or inbox credentials. .redirect_behavior = .not_allowed, - .extra_headers = &headers, + .extra_headers = headers[0 .. 1 + extra_headers.len], .response_writer = &response_writer, }) catch |err| switch (err) { error.WriteFailed => return error.ResponseTooLarge, @@ -241,6 +313,86 @@ fn parseOpenedSecret(allocator: std.mem.Allocator, body: []const u8) !OpenedSecr }; } +fn parseInboxKey(allocator: std.mem.Allocator, body: []const u8) ![65]u8 { + const Wire = struct { publicKey: []const u8 }; + var parsed = std.json.parseFromSlice(Wire, allocator, body, .{}) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return error.InvalidResponse, + }; + defer parsed.deinit(); + return parseCanonicalP256Public(parsed.value.publicKey) catch error.InvalidResponse; +} + +fn parseInboxItems(allocator: std.mem.Allocator, body: []const u8, limit: u32) ![]InboxItem { + const WireItem = struct { + id: []const u8, + expiresAt: i64, + viewsLeft: i64, + }; + const Wire = struct { items: []const WireItem }; + var parsed = std.json.parseFromSlice(Wire, allocator, body, .{}) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return error.InvalidResponse, + }; + defer parsed.deinit(); + if (parsed.value.items.len > limit) return error.InvalidResponse; + + const items = try allocator.alloc(InboxItem, parsed.value.items.len); + var initialized: usize = 0; + errdefer { + for (items[0..initialized]) |*item| item.deinit(allocator); + allocator.free(items); + } + for (parsed.value.items, 0..) |wire, index| { + if (!isCanonicalDropId(wire.id) or wire.viewsLeft < -1) return error.InvalidResponse; + items[index] = .{ + .id = try allocator.dupe(u8, wire.id), + .expiresAt = wire.expiresAt, + .viewsLeft = wire.viewsLeft, + }; + initialized += 1; + } + return items; +} + +fn parseCanonicalP256Public(encoded: []const u8) ![65]u8 { + // A single base64url form prevents alternate inbox identities for the same P-256 key. + if (encoded.len != 87) return error.InvalidPublicKey; + var key: [65]u8 = undefined; + std.base64.url_safe_no_pad.Decoder.decode(&key, encoded) catch return error.InvalidPublicKey; + var canonical: [87]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&canonical, &key); + if (!std.mem.eql(u8, encoded, &canonical) or key[0] != 0x04) return error.InvalidPublicKey; + _ = std.crypto.ecc.P256.fromSec1(&key) catch return error.InvalidPublicKey; + return key; +} + +fn isCanonicalInboxProof(encoded: []const u8) bool { + if (encoded.len != 43) return false; + var proof: [32]u8 = undefined; + std.base64.url_safe_no_pad.Decoder.decode(&proof, encoded) catch return false; + var canonical: [43]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&canonical, &proof); + return std.mem.eql(u8, encoded, &canonical); +} + +fn validateInboxEndpoint(base_url: []const u8) error{InsecureInboxEndpoint}!void { + // Inbox proofs require HTTPS outside explicitly local loopback development endpoints. + const uri = std.Uri.parse(base_url) catch return error.InsecureInboxEndpoint; + if (std.mem.eql(u8, uri.scheme, "https")) return; + if (!std.mem.eql(u8, uri.scheme, "http")) return error.InsecureInboxEndpoint; + const host_component = uri.host orelse return error.InsecureInboxEndpoint; + var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; + const host = host_component.toRaw(&host_buffer) catch return error.InsecureInboxEndpoint; + if (std.ascii.eqlIgnoreCase(host, "localhost") or + std.mem.eql(u8, host, "::1") or + std.mem.eql(u8, host, "[::1]")) return; + if (std.Io.net.IpAddress.parseLiteral(host)) |address| { + if (address == .ip4 and address.ip4.bytes[0] == 127) return; + } else |_| {} + return error.InsecureInboxEndpoint; +} + fn isUnreserved(byte: u8) bool { return (byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z') or @@ -441,3 +593,121 @@ test "HTTP client rejects redirects without following their location" { try std.testing.expectError(error.TestServerMissedRequest, server.deinit()); server_live = false; } + +fn testInboxPublicKey() [65]u8 { + return std.crypto.ecc.P256.basePoint.toUncompressedSec1(); +} + +fn testInboxPublicKeyB64() [87]u8 { + const key = testInboxPublicKey(); + var encoded: [87]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&encoded, &key); + return encoded; +} + +test "inbox client requests the key and authenticated item endpoint" { + const test_server = @import("test_server.zig"); + const public_key = testInboxPublicKey(); + const recipient = testInboxPublicKeyB64(); + const proof = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const key_body = try std.fmt.allocPrint(std.testing.allocator, "{{\"publicKey\":\"{s}\"}}", .{&recipient}); + defer std.testing.allocator.free(key_body); + const inbox_target = try std.fmt.allocPrint( + std.testing.allocator, + "/api/addresses/{s}/inbox?limit=2&at=1700000000", + .{&recipient}, + ); + defer std.testing.allocator.free(inbox_target); + const inbox_headers = [_]std.http.Header{.{ .name = "x-ashdrop-inbox-proof", .value = proof }}; + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/inbox-key", .response_status = .ok, .response_body = key_body }, + .{ + .method = .GET, + .target = inbox_target, + .required_headers = &inbox_headers, + .response_status = .ok, + .response_body = "{\"items\":[{\"id\":\"0123456789abcdef0123456789abcdef\",\"expiresAt\":1700003600,\"viewsLeft\":1}]}", + }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + + try std.testing.expectEqualSlices(u8, &public_key, &(try client.inboxKey())); + const items = try client.inbox(&recipient, 2, 1_700_000_000, proof); + defer deinitInboxItems(std.testing.allocator, items); + try std.testing.expectEqual(@as(usize, 1), items.len); + try std.testing.expectEqualStrings("0123456789abcdef0123456789abcdef", items[0].id); + try std.testing.expectEqual(@as(i64, 1_700_003_600), items[0].expiresAt); + try std.testing.expectEqual(@as(i64, 1), items[0].viewsLeft); + try server.deinit(); + server_live = false; +} + +test "inbox client rejects a noncanonical server public key" { + const test_server = @import("test_server.zig"); + var encoded = testInboxPublicKeyB64(); + const alphabet = std.base64.url_safe_alphabet_chars; + const index = std.mem.indexOfScalar(u8, &alphabet, encoded[encoded.len - 1]).?; + encoded[encoded.len - 1] = alphabet[index | 1]; + const body = try std.fmt.allocPrint(std.testing.allocator, "{{\"publicKey\":\"{s}\"}}", .{&encoded}); + defer std.testing.allocator.free(body); + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/inbox-key", .response_status = .ok, .response_body = body }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + + try std.testing.expectError(error.InvalidResponse, client.inboxKey()); + try server.deinit(); + server_live = false; +} + +test "inbox client maps authorization rate limits response caps and insecure HTTP" { + const test_server = @import("test_server.zig"); + const recipient = testInboxPublicKeyB64(); + const proof = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const target = try std.fmt.allocPrint( + std.testing.allocator, + "/api/addresses/{s}/inbox?limit=2&at=1700000000", + .{&recipient}, + ); + defer std.testing.allocator.free(target); + const inbox_headers = [_]std.http.Header{.{ .name = "x-ashdrop-inbox-proof", .value = proof }}; + var oversized: [max_response_size + 1]u8 = undefined; + @memset(&oversized, 'x'); + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = target, .required_headers = &inbox_headers, .response_status = .unauthorized, .response_body = "{\"error\":\"do not disclose\"}" }, + .{ .method = .GET, .target = target, .required_headers = &inbox_headers, .response_status = .too_many_requests, .response_body = "{\"error\":\"slow down\"}" }, + .{ .method = .GET, .target = target, .required_headers = &inbox_headers, .response_status = .ok, .response_body = &oversized }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const base_url = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(base_url); + var client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = base_url }; + + try std.testing.expectError(error.InboxUnauthorized, client.inbox(&recipient, 2, 1_700_000_000, proof)); + try std.testing.expectError(error.InboxRateLimited, client.inbox(&recipient, 2, 1_700_000_000, proof)); + try std.testing.expectError(error.ResponseTooLarge, client.inbox(&recipient, 2, 1_700_000_000, proof)); + try server.deinit(); + server_live = false; + + var insecure_client = Client{ .allocator = std.testing.allocator, .io = std.testing.io, .base_url = "http://api.example" }; + try std.testing.expectError(error.InsecureInboxEndpoint, insecure_client.inboxKey()); +} + +test "inbox client permits every IPv4 loopback address" { + try validateInboxEndpoint("http://127.0.0.2"); +} diff --git a/ashdrop/cli/src/commands.zig b/ashdrop/cli/src/commands.zig index 46afaf3..2be0282 100644 --- a/ashdrop/cli/src/commands.zig +++ b/ashdrop/cli/src/commands.zig @@ -16,6 +16,7 @@ pub const Runtime = struct { home: []const u8, api_env: ?[]const u8 = null, web_env: ?[]const u8 = null, + inbox_now: ?i64 = null, stdout: *std.Io.Writer, stderr: *std.Io.Writer, }; @@ -36,6 +37,11 @@ pub const PullOptions = struct { api: ?[]const u8 = null, }; +pub const InboxOptions = struct { + limit: u32 = 20, + api: ?[]const u8 = null, +}; + pub const ResolvedPull = struct { options: PullOptions, endpoint: []const u8, @@ -142,6 +148,30 @@ pub fn parsePullArgs(args: []const []const u8) error{Usage}!PullOptions { return options; } +pub fn parseInboxArgs(args: []const []const u8) error{Usage}!InboxOptions { + if (args.len == 0 or !std.mem.eql(u8, args[0], "inbox")) return error.Usage; + + var options = InboxOptions{}; + var limit_set = false; + var index: usize = 1; + while (index < args.len) : (index += 1) { + const arg = args[index]; + if (!std.mem.eql(u8, arg, "--limit") and !std.mem.eql(u8, arg, "--api")) return error.Usage; + index += 1; + if (index == args.len or args[index].len == 0) return error.Usage; + const value = args[index]; + if (std.mem.eql(u8, arg, "--limit")) { + if (limit_set) return error.Usage; + options.limit = parseInboxLimit(value) catch return error.Usage; + limit_set = true; + } else { + if (options.api != null) return error.Usage; + options.api = value; + } + } + return options; +} + pub fn resolvePull(allocator: std.mem.Allocator, args: []const []const u8, api_env: ?[]const u8) !ResolvedPull { var options = try parsePullArgs(args); var drop = try links.parseDrop(allocator, options.drop); @@ -229,6 +259,34 @@ pub fn pull(args: []const []const u8, runtime: Runtime) !void { }); } +pub fn inbox(args: []const []const u8, runtime: Runtime) !void { + const options = try parseInboxArgs(args); + const endpoint = try config.resolveApi(options.api, runtime.api_env, null); + var config_dir = try identity.openConfigDirAt(runtime.io, runtime.home_base, runtime.home); + defer config_dir.close(runtime.io); + const local_identity = try identity.load(runtime.allocator, runtime.io, config_dir, "identity.json"); + const recipient = links.formatRawReceive(local_identity.publicSec1()); + var client = api.Client{ + .allocator = runtime.allocator, + .io = runtime.io, + .base_url = endpoint, + }; + const server_key = try client.inboxKey(); + const at = runtime.inbox_now orelse std.Io.Clock.real.now(runtime.io).toSeconds(); + const proof = try crypto.inboxProof(runtime.allocator, &local_identity.d, &server_key, options.limit, at); + defer { + std.crypto.secureZero(u8, proof); + runtime.allocator.free(proof); + } + const items = try client.inbox(&recipient, options.limit, at, proof); + defer api.deinitInboxItems(runtime.allocator, items); + + try runtime.stdout.writeAll("ID\texpiresAt\tviewsLeft\n"); + for (items) |item| { + try runtime.stdout.print("{s}\t{d}\t{d}\n", .{ item.id, item.expiresAt, item.viewsLeft }); + } +} + pub fn pullWithRemote(options: PullOptions, runtime: Runtime, remote: PullRemote) !void { // Validate the destination before opening because a consumed one-view drop cannot be restored. var output = files.prepareOutput(runtime.allocator, runtime.io, runtime.cwd, options.output, options.force) catch |err| switch (err) { @@ -298,6 +356,16 @@ fn parseViews(value: []const u8) error{InvalidViews}!u32 { return std.fmt.parseInt(u32, value, 10) catch error.InvalidViews; } +fn parseInboxLimit(value: []const u8) error{InvalidInboxLimit}!u32 { + if (value.len == 0) return error.InvalidInboxLimit; + for (value) |byte| { + if (byte < '0' or byte > '9') return error.InvalidInboxLimit; + } + const limit = std.fmt.parseInt(u32, value, 10) catch return error.InvalidInboxLimit; + if (limit == 0 or limit > 100) return error.InvalidInboxLimit; + return limit; +} + test "share parser accepts defaults and endpoint flags" { const args = [_][]const u8{ "share", @@ -329,6 +397,40 @@ test "pull parser accepts default output and force flag" { try std.testing.expect(forced_options.force); } +test "inbox parser accepts defaults limits and API overrides" { + const defaults = [_][]const u8{"inbox"}; + const default_options = try parseInboxArgs(&defaults); + try std.testing.expectEqual(@as(u32, 20), default_options.limit); + try std.testing.expect(default_options.api == null); + + const overridden = [_][]const u8{ + "inbox", + "--limit", + "100", + "--api", + "https://api.example", + }; + const options = try parseInboxArgs(&overridden); + try std.testing.expectEqual(@as(u32, 100), options.limit); + try std.testing.expectEqualStrings("https://api.example", options.api.?); +} + +test "inbox parser rejects invalid limits and repeated flags" { + const invalid_limits = [_][]const []const u8{ + &.{ "inbox", "--limit", "0" }, + &.{ "inbox", "--limit", "101" }, + &.{ "inbox", "--limit", "nope" }, + }; + for (invalid_limits) |args| { + try std.testing.expectError(error.Usage, parseInboxArgs(args)); + } + + const repeated_limit = [_][]const u8{ "inbox", "--limit", "2", "--limit", "3" }; + const repeated_api = [_][]const u8{ "inbox", "--api", "https://one.example", "--api", "https://two.example" }; + try std.testing.expectError(error.Usage, parseInboxArgs(&repeated_limit)); + try std.testing.expectError(error.Usage, parseInboxArgs(&repeated_api)); +} + test "command parsers reject repeated flags even at default values" { const repeated_share = [_][]const u8{ "share", diff --git a/ashdrop/cli/src/crypto.zig b/ashdrop/cli/src/crypto.zig index 35cb992..21e8283 100644 --- a/ashdrop/cli/src/crypto.zig +++ b/ashdrop/cli/src/crypto.zig @@ -4,9 +4,11 @@ const std = @import("std"); const Aes256Gcm = std.crypto.aead.aes_gcm.Aes256Gcm; const HkdfSha256 = std.crypto.kdf.hkdf.HkdfSha256; +const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256; const P256 = std.crypto.ecc.P256; const hkdf_info = "ashdrop-ecdh-v1"; +const inbox_hkdf_info = "ashdrop-inbox-v1"; const RandomSource = struct { context: ?*anyopaque, @@ -131,6 +133,40 @@ pub fn openForRecipient( return plaintext; } +/// Derives the canonical proof required to list inbox entries for a local recipient identity. +pub fn inboxProof( + allocator: std.mem.Allocator, + recipient_private: *const [32]u8, + server_inbox_sec1: []const u8, + limit: u32, + at: i64, +) ![]u8 { + try validatePrivateScalar(recipient_private); + if (limit == 0 or limit > 100) return error.InvalidInboxRequest; + const server_public = parseInboxServerPoint(server_inbox_sec1) catch return error.InvalidInboxKey; + + var recipient_point = P256.basePoint.mul(recipient_private.*, .big) catch return error.InvalidPrivateKey; + defer secureZeroValue(P256, &recipient_point); + const recipient_sec1 = recipient_point.toUncompressedSec1(); + var recipient_pub: [87]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&recipient_pub, &recipient_sec1); + + var hmac_key: [HmacSha256.key_length]u8 = undefined; + defer std.crypto.secureZero(u8, &hmac_key); + deriveKeyWithInfo(recipient_private, server_public, inbox_hkdf_info, &hmac_key) catch return error.InvalidInboxKey; + + var request_buffer: [160]u8 = undefined; + const request = std.fmt.bufPrint( + &request_buffer, + "ashdrop-inbox-v1\nGET\n/api/addresses/{s}/inbox\nlimit={d}&at={d}", + .{ &recipient_pub, limit, at }, + ) catch unreachable; + var mac: [HmacSha256.mac_length]u8 = undefined; + defer std.crypto.secureZero(u8, &mac); + HmacSha256.create(&mac, request, &hmac_key); + return encodeB64url(allocator, &mac); +} + fn validatePrivateScalar(private: *const [32]u8) error{InvalidPrivateKey}!void { P256.scalar.rejectNonCanonical(private.*, .big) catch return error.InvalidPrivateKey; if (std.mem.allEqual(u8, private, 0)) return error.InvalidPrivateKey; @@ -146,8 +182,17 @@ fn parseEphemeralPoint(sec1: []const u8) error{InvalidInput}!P256 { return P256.fromSec1(sec1) catch error.InvalidInput; } +fn parseInboxServerPoint(sec1: []const u8) error{InvalidInboxKey}!P256 { + if (sec1.len != 65 or sec1[0] != 0x04) return error.InvalidInboxKey; + return P256.fromSec1(sec1) catch error.InvalidInboxKey; +} + fn deriveKey(private: *const [32]u8, peer_public: P256, key: *[Aes256Gcm.key_length]u8) error{InvalidInput}!void { - var shared_point = peer_public.mul(private.*, .big) catch return error.InvalidInput; + deriveKeyWithInfo(private, peer_public, hkdf_info, key) catch return error.InvalidInput; +} + +fn deriveKeyWithInfo(private: *const [32]u8, peer_public: P256, info: []const u8, key: *[Aes256Gcm.key_length]u8) !void { + var shared_point = try peer_public.mul(private.*, .big); defer secureZeroValue(P256, &shared_point); // Protocol v1 feeds the ECDH x-coordinate, not a serialized point, into HKDF. var shared_coordinates = shared_point.affineCoordinates(); @@ -157,7 +202,7 @@ fn deriveKey(private: *const [32]u8, peer_public: P256, key: *[Aes256Gcm.key_len const salt: [32]u8 = @splat(0); var prk = HkdfSha256.extract(&salt, &shared_x); defer std.crypto.secureZero(u8, &prk); - HkdfSha256.expand(key, hkdf_info, prk); + HkdfSha256.expand(key, info, prk); } fn fillFromIo(context: ?*anyopaque, output: []u8) void { diff --git a/ashdrop/cli/src/crypto_test.zig b/ashdrop/cli/src/crypto_test.zig index b64273a..902f15d 100644 --- a/ashdrop/cli/src/crypto_test.zig +++ b/ashdrop/cli/src/crypto_test.zig @@ -26,6 +26,52 @@ fn decodeFixed(comptime len: usize, encoded: []const u8) ![len]u8 { return decoded; } +fn scalar(last_byte: u8) [32]u8 { + var value: [32]u8 = @splat(0); + value[31] = last_byte; + return value; +} + +fn publicSec1(private_scalar: [32]u8) ![65]u8 { + var point = try std.crypto.ecc.P256.basePoint.mul(private_scalar, .big); + defer std.crypto.secureZero(@TypeOf(point), @as([*]volatile @TypeOf(point), @ptrCast(&point))[0..1]); + return point.toUncompressedSec1(); +} + +fn referenceInboxProof(recipient_private: [32]u8, server_public: []const u8, limit: u32, at: i64) ![43]u8 { + const P256 = std.crypto.ecc.P256; + const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256; + const server = try P256.fromSec1(server_public); + var shared_point = try server.mul(recipient_private, .big); + defer std.crypto.secureZero(@TypeOf(shared_point), @as([*]volatile @TypeOf(shared_point), @ptrCast(&shared_point))[0..1]); + var coordinates = shared_point.affineCoordinates(); + defer std.crypto.secureZero(@TypeOf(coordinates), @as([*]volatile @TypeOf(coordinates), @ptrCast(&coordinates))[0..1]); + var shared_x = coordinates.x.toBytes(.big); + defer std.crypto.secureZero(u8, &shared_x); + const salt: [32]u8 = @splat(0); + var prk = std.crypto.kdf.hkdf.HkdfSha256.extract(&salt, &shared_x); + defer std.crypto.secureZero(u8, &prk); + var key: [32]u8 = undefined; + defer std.crypto.secureZero(u8, &key); + std.crypto.kdf.hkdf.HkdfSha256.expand(&key, "ashdrop-inbox-v1", prk); + + const recipient_public = try publicSec1(recipient_private); + var recipient_encoded: [87]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&recipient_encoded, &recipient_public); + var request: [192]u8 = undefined; + const canonical_request = try std.fmt.bufPrint( + &request, + "ashdrop-inbox-v1\nGET\n/api/addresses/{s}/inbox\nlimit={d}&at={d}", + .{ &recipient_encoded, limit, at }, + ); + var mac: [HmacSha256.mac_length]u8 = undefined; + defer std.crypto.secureZero(u8, &mac); + HmacSha256.create(&mac, canonical_request, &key); + var encoded: [43]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&encoded, &mac); + return encoded; +} + fn sealAllocationFailure(allocator: std.mem.Allocator, recipient_public: [65]u8) !void { var sealed = try crypto.sealForRecipient(allocator, std.testing.io, "allocation cleanup", &recipient_public); defer sealed.deinit(allocator); @@ -237,6 +283,60 @@ test "recipient protocol reports AES authentication failures" { ); } +test "inbox proof agrees with the P-256 HKDF-HMAC protocol" { + const recipient_private = scalar(1); + const server_public = try publicSec1(scalar(2)); + const expected = try referenceInboxProof(recipient_private, &server_public, 25, 1_700_000_000); + const proof = try crypto.inboxProof( + std.testing.allocator, + &recipient_private, + &server_public, + 25, + 1_700_000_000, + ); + defer std.testing.allocator.free(proof); + + try std.testing.expectEqualStrings(&expected, proof); +} + +test "inbox proof binds the recipient limit and timestamp" { + const first_recipient = scalar(1); + const second_recipient = scalar(3); + const server_public = try publicSec1(scalar(2)); + const base = try crypto.inboxProof(std.testing.allocator, &first_recipient, &server_public, 25, 1_700_000_000); + defer std.testing.allocator.free(base); + const changed_recipient = try crypto.inboxProof(std.testing.allocator, &second_recipient, &server_public, 25, 1_700_000_000); + defer std.testing.allocator.free(changed_recipient); + const changed_limit = try crypto.inboxProof(std.testing.allocator, &first_recipient, &server_public, 26, 1_700_000_000); + defer std.testing.allocator.free(changed_limit); + const changed_time = try crypto.inboxProof(std.testing.allocator, &first_recipient, &server_public, 25, 1_700_000_001); + defer std.testing.allocator.free(changed_time); + + try std.testing.expect(!std.mem.eql(u8, base, changed_recipient)); + try std.testing.expect(!std.mem.eql(u8, base, changed_limit)); + try std.testing.expect(!std.mem.eql(u8, base, changed_time)); +} + +test "inbox proof rejects malformed server keys and private scalars" { + const valid_private = scalar(1); + const server_public = try publicSec1(scalar(2)); + const short_key = [_]u8{0x04}; + var malformed_key: [65]u8 = @splat(0); + malformed_key[0] = 0x04; + const order = [32]u8{ + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, + 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, + }; + + try std.testing.expectError(error.InvalidInboxKey, crypto.inboxProof(std.testing.allocator, &valid_private, &short_key, 1, 1)); + try std.testing.expectError(error.InvalidInboxKey, crypto.inboxProof(std.testing.allocator, &valid_private, &malformed_key, 1, 1)); + const zero: [32]u8 = @splat(0); + try std.testing.expectError(error.InvalidPrivateKey, crypto.inboxProof(std.testing.allocator, &zero, &server_public, 1, 1)); + try std.testing.expectError(error.InvalidPrivateKey, crypto.inboxProof(std.testing.allocator, &order, &server_public, 1, 1)); +} + test "recipient protocol cleans up every sealing allocation failure" { var fixture = try loadFixture(); defer fixture.deinit(); diff --git a/ashdrop/cli/src/main.zig b/ashdrop/cli/src/main.zig index 584c80f..3e73c78 100644 --- a/ashdrop/cli/src/main.zig +++ b/ashdrop/cli/src/main.zig @@ -3,6 +3,7 @@ const std = @import("std"); const config = @import("config.zig"); const commands = @import("commands.zig"); +const crypto = @import("crypto.zig"); const identity = @import("identity.zig"); const links = @import("links.zig"); @@ -20,6 +21,7 @@ const AddressRuntime = struct { home: []const u8, api_env: ?[]const u8 = null, web_env: ?[]const u8 = null, + inbox_now: ?i64 = null, stdout: *std.Io.Writer, stderr: *std.Io.Writer, }; @@ -56,6 +58,7 @@ fn runCommand(args: anytype, runtime: AddressRuntime) u8 { if (std.mem.eql(u8, command, "address")) return runAddress(args, runtime); if (std.mem.eql(u8, command, "share")) return runShare(args, runtime); if (std.mem.eql(u8, command, "pull")) return runPull(args, runtime); + if (std.mem.eql(u8, command, "inbox")) return runInbox(args, runtime); return writeFailure(runtime.stderr, error.Usage); } @@ -69,6 +72,11 @@ fn runPull(args: []const []const u8, runtime: AddressRuntime) u8 { return 0; } +fn runInbox(args: []const []const u8, runtime: AddressRuntime) u8 { + commands.inbox(args, commandRuntime(runtime)) catch |err| return writeFailure(runtime.stderr, err); + return 0; +} + fn commandRuntime(runtime: AddressRuntime) commands.Runtime { return .{ .allocator = runtime.allocator, @@ -78,6 +86,7 @@ fn commandRuntime(runtime: AddressRuntime) commands.Runtime { .home = runtime.home, .api_env = runtime.api_env, .web_env = runtime.web_env, + .inbox_now = runtime.inbox_now, .stdout = runtime.stdout, .stderr = runtime.stderr, }; @@ -114,6 +123,7 @@ fn writeFailure(stderr: *std.Io.Writer, err: anyerror) u8 { const status: u8 = switch (err) { error.Usage, error.InvalidEndpoint, + error.InsecureInboxEndpoint, error.HomeMissing, error.InvalidHomePath, error.IdentityAlreadyExists, @@ -127,8 +137,8 @@ fn writeFailure(stderr: *std.Io.Writer, err: anyerror) u8 { else => 1, }; const message = switch (err) { - error.Usage => "usage: ashdrop [options]\n", - error.InvalidEndpoint => "ashdrop: invalid API or web endpoint\n", + error.Usage => "usage: ashdrop [options]\n", + error.InvalidEndpoint, error.InsecureInboxEndpoint => "ashdrop: invalid API or web endpoint\n", error.HomeMissing => "ashdrop: HOME is not set\n", error.InvalidHomePath => "ashdrop: HOME is invalid\n", error.IdentityAlreadyExists => "ashdrop: receive identity already exists\n", @@ -149,7 +159,12 @@ fn writeFailure(stderr: *std.Io.Writer, err: anyerror) u8 { error.RateLimited => "ashdrop: request was rate limited\n", error.DropUnavailable => "ashdrop: drop no longer exists\n", error.RecipientMismatch => "ashdrop: drop is for a different receive identity\n", - error.RemoteFailure, error.ResponseTooLarge, error.InvalidResponse => "ashdrop: API request failed\n", + error.InboxUnauthorized, + error.InboxRateLimited, + error.RemoteFailure, + error.ResponseTooLarge, + error.InvalidResponse, + => "ashdrop: API request failed\n", else => "ashdrop: command failed\n", }; stderr.writeAll(message) catch {}; @@ -325,14 +340,168 @@ test "share and pull malformed commands report standard usage on stderr" { const share = [_][]const u8{"share"}; try std.testing.expectEqual(@as(u8, 2), runCommand(&share, runtime)); try std.testing.expectEqual(@as(usize, 0), stdout.written().len); - try std.testing.expectEqualStrings("usage: ashdrop [options]\n", stderr.written()); + try std.testing.expectEqualStrings("usage: ashdrop [options]\n", stderr.written()); stdout.clearRetainingCapacity(); stderr.clearRetainingCapacity(); const pull = [_][]const u8{"pull"}; try std.testing.expectEqual(@as(u8, 2), runCommand(&pull, runtime)); try std.testing.expectEqual(@as(usize, 0), stdout.written().len); - try std.testing.expectEqualStrings("usage: ashdrop [options]\n", stderr.written()); + try std.testing.expectEqualStrings("usage: ashdrop [options]\n", stderr.written()); +} + +test "inbox command prints only item metadata" { + const test_server = @import("test_server.zig"); + const at = 1_700_000_000; + const plaintext = "TOKEN=never-print-this\n"; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + const local_identity = try identity.create(std.testing.io, config_dir, "identity.json"); + const recipient = links.formatRawReceive(local_identity.publicSec1()); + const server_public = std.crypto.ecc.P256.basePoint.toUncompressedSec1(); + const server_key = links.formatRawReceive(server_public); + const proof = try crypto.inboxProof(std.testing.allocator, &local_identity.d, &server_public, 2, at); + defer { + std.crypto.secureZero(u8, proof); + std.testing.allocator.free(proof); + } + const key_body = try std.fmt.allocPrint(std.testing.allocator, "{{\"publicKey\":\"{s}\"}}", .{&server_key}); + defer std.testing.allocator.free(key_body); + const inbox_target = try std.fmt.allocPrint( + std.testing.allocator, + "/api/addresses/{s}/inbox?limit=2&at={d}", + .{ &recipient, at }, + ); + defer std.testing.allocator.free(inbox_target); + const inbox_headers = [_]std.http.Header{.{ .name = "x-ashdrop-inbox-proof", .value = proof }}; + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/inbox-key", .response_status = .ok, .response_body = key_body }, + .{ + .method = .GET, + .target = inbox_target, + .required_headers = &inbox_headers, + .response_status = .ok, + .response_body = "{\"items\":[{\"id\":\"0123456789abcdef0123456789abcdef\",\"expiresAt\":1700003600,\"viewsLeft\":1}]}", + }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const api_env = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(api_env); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .api_env = api_env, + .inbox_now = at, + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const args = [_][]const u8{ "inbox", "--limit", "2" }; + + try std.testing.expectEqual(@as(u8, 0), runCommand(&args, runtime)); + try std.testing.expectEqualStrings("ID\texpiresAt\tviewsLeft\n0123456789abcdef0123456789abcdef\t1700003600\t1\n", stdout.written()); + try std.testing.expect(std.mem.indexOf(u8, stdout.written(), plaintext) == null); + try std.testing.expectEqual(@as(usize, 0), stderr.written().len); + try server.deinit(); + server_live = false; +} + +test "inbox missing identity reports status 2 on stderr" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const args = [_][]const u8{"inbox"}; + + try std.testing.expectEqual(@as(u8, 2), runCommand(&args, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: receive identity does not exist; run `ashdrop address create`\n", stderr.written()); +} + +test "inbox unauthorized responses are generic operational failures" { + const test_server = @import("test_server.zig"); + const at = 1_700_000_000; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var config_dir = try identity.openConfigDirAt(std.testing.io, tmp.dir, "home"); + defer config_dir.close(std.testing.io); + const local_identity = try identity.create(std.testing.io, config_dir, "identity.json"); + const recipient = links.formatRawReceive(local_identity.publicSec1()); + const server_public = std.crypto.ecc.P256.basePoint.toUncompressedSec1(); + const server_key = links.formatRawReceive(server_public); + const proof = try crypto.inboxProof(std.testing.allocator, &local_identity.d, &server_public, 20, at); + defer { + std.crypto.secureZero(u8, proof); + std.testing.allocator.free(proof); + } + const key_body = try std.fmt.allocPrint(std.testing.allocator, "{{\"publicKey\":\"{s}\"}}", .{&server_key}); + defer std.testing.allocator.free(key_body); + const inbox_target = try std.fmt.allocPrint( + std.testing.allocator, + "/api/addresses/{s}/inbox?limit=20&at={d}", + .{ &recipient, at }, + ); + defer std.testing.allocator.free(inbox_target); + const inbox_headers = [_]std.http.Header{.{ .name = "x-ashdrop-inbox-proof", .value = proof }}; + const expected = [_]test_server.ExpectedRequest{ + .{ .method = .GET, .target = "/api/inbox-key", .response_status = .ok, .response_body = key_body }, + .{ + .method = .GET, + .target = inbox_target, + .required_headers = &inbox_headers, + .response_status = .unauthorized, + .response_body = "{\"error\":\"inbox is not available\"}", + }, + }; + var server: test_server.Server = undefined; + try server.init(std.testing.io, &expected); + var server_live = true; + defer if (server_live) server.deinit() catch {}; + const api_env = try std.fmt.allocPrint(std.testing.allocator, "http://127.0.0.1:{d}", .{server.port()}); + defer std.testing.allocator.free(api_env); + + var stdout = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stdout.deinit(); + var stderr = std.Io.Writer.Allocating.init(std.testing.allocator); + defer stderr.deinit(); + const runtime = AddressRuntime{ + .allocator = std.testing.allocator, + .io = std.testing.io, + .home_base = tmp.dir, + .home = "home", + .api_env = api_env, + .inbox_now = at, + .stdout = &stdout.writer, + .stderr = &stderr.writer, + }; + const args = [_][]const u8{"inbox"}; + + try std.testing.expectEqual(@as(u8, 1), runCommand(&args, runtime)); + try std.testing.expectEqual(@as(usize, 0), stdout.written().len); + try std.testing.expectEqualStrings("ashdrop: API request failed\n", stderr.written()); + try server.deinit(); + server_live = false; } test "address missing identity reports status 2 on stderr" { diff --git a/ashdrop/cli/src/test_server.zig b/ashdrop/cli/src/test_server.zig index d38793f..24d8302 100644 --- a/ashdrop/cli/src/test_server.zig +++ b/ashdrop/cli/src/test_server.zig @@ -6,6 +6,7 @@ pub const ExpectedRequest = struct { method: std.http.Method, target: []const u8, json_body: bool = false, + required_headers: []const std.http.Header = &.{}, response_status: std.http.Status, response_body: []const u8, response_headers: []const std.http.Header = &.{}, @@ -74,6 +75,12 @@ pub const Server = struct { if (expected.json_body and !std.mem.eql(u8, request.head.content_type orelse "", "application/json")) { return error.TestServerUnexpectedContentType; } + for (expected.required_headers) |required| { + var headers = request.iterateHeaders(); + while (headers.next()) |header| { + if (std.ascii.eqlIgnoreCase(header.name, required.name) and std.mem.eql(u8, header.value, required.value)) break; + } else return error.TestServerMissingRequiredHeader; + } var body_buffer: [8192]u8 = undefined; const body_reader = request.readerExpectNone(&.{}); diff --git a/render.yaml b/render.yaml index ce32c79..510fa0f 100644 --- a/render.yaml +++ b/render.yaml @@ -12,17 +12,22 @@ services: healthCheckPath: /healthz autoDeploy: true # Render injects PORT at runtime and the app binds to it — don't set PORT here. + envVars: + # Render's load balancer is the trusted proxy. + - key: ASHDROP_TRUST_PROXY + value: "true" # --- Persistence (optional; needs a paid instance, not free) --- # SQLite lives on an ephemeral filesystem by default, so drops reset on every # deploy/restart. That's tolerable for an MVP (secrets are short-lived and # re-creatable). To keep drops across restarts, switch `plan` off free, then - # uncomment the disk + env below — the image already declares VOLUME /data. + # uncomment the disk and add ASHDROP_DB to the envVars list above — the image + # already declares VOLUME /data. # # disk: # name: ashdrop-data # mountPath: /data # sizeGB: 1 - # envVars: - # - key: ASHDROP_DB - # value: /data/ashdrop.db + # Add this entry to the envVars list above: + # - key: ASHDROP_DB + # value: /data/ashdrop.db From 4290935160866d1a6b69c28ab8c4c8a3a3698d66 Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Fri, 17 Jul 2026 15:00:38 +0000 Subject: [PATCH 7/9] fix: address pr review findings --- ashdrop/api/main.go | 15 +++++++++------ ashdrop/cli/src/config.zig | 12 +++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ashdrop/api/main.go b/ashdrop/api/main.go index 9f18945..38b609b 100644 --- a/ashdrop/api/main.go +++ b/ashdrop/api/main.go @@ -103,11 +103,10 @@ func (api *API) handleCreate(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusRequestEntityTooLarge, "secret too large") return } - if req.RecipientPub != "" { - if !validPublicKey(req.RecipientPub) || !validPublicKey(req.EphemeralPub) { - httpError(w, http.StatusBadRequest, "invalid recipient public key") - return - } + if (req.RecipientPub == "") != (req.EphemeralPub == "") || + (req.RecipientPub != "" && (!validPublicKey(req.RecipientPub) || !validPublicKey(req.EphemeralPub))) { + httpError(w, http.StatusBadRequest, "invalid recipient public key") + return } ttl := req.TTL @@ -430,7 +429,11 @@ func viewsLeft(sec *Secret) int { func validPublicKey(encoded string) bool { pub, err := base64.RawURLEncoding.DecodeString(encoded) - return err == nil && len(pub) == 65 && pub[0] == 0x04 && base64.RawURLEncoding.EncodeToString(pub) == encoded + if err != nil || len(pub) != 65 || pub[0] != 0x04 || base64.RawURLEncoding.EncodeToString(pub) != encoded { + return false + } + _, err = ecdh.P256().NewPublicKey(pub) + return err == nil } func inboxUnauthorized(w http.ResponseWriter) { diff --git a/ashdrop/cli/src/config.zig b/ashdrop/cli/src/config.zig index b5eb85f..ab2ae9b 100644 --- a/ashdrop/cli/src/config.zig +++ b/ashdrop/cli/src/config.zig @@ -2,11 +2,8 @@ const std = @import("std"); -// pub const managed_api = "https://ashdrop.onrender.com"; -// pub const managed_web = "https://ashdrop.vercel.app"; -// -pub const managed_api = "http://localhost:8080"; -pub const managed_web = "http://localhost:5173"; +pub const managed_api = "https://ashdrop.onrender.com"; +pub const managed_web = "https://ashdrop.vercel.app"; pub fn resolveApi(flag: ?[]const u8, env: ?[]const u8, embedded: ?[]const u8) error{InvalidEndpoint}![]const u8 { // A caller can override an embedded self-hosted endpoint without rewriting a received link. @@ -50,6 +47,11 @@ test "ordinary API references prefer explicit flag then environment" { try std.testing.expectEqualStrings(managed_api, try resolveApi(null, null, null)); } +test "managed endpoints use deployed https services" { + try std.testing.expectEqualStrings("https://ashdrop.onrender.com", managed_api); + try std.testing.expectEqualStrings("https://ashdrop.vercel.app", managed_web); +} + test "web endpoint resolves independently" { try std.testing.expectEqualStrings( "https://flag-web.example", From 69d848f4b50caea3f008293c97db7afe2a1fad9b Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Fri, 17 Jul 2026 16:49:52 +0000 Subject: [PATCH 8/9] build(cli): support stable Zig 0.16 --- ashdrop/cli/README.md | 6 +++--- ashdrop/cli/build.zig | 7 ++++++- ashdrop/cli/build.zig.zon | 2 +- ashdrop/cli/src/config.zig | 14 ++++++-------- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/ashdrop/cli/README.md b/ashdrop/cli/README.md index 261fe56..b278ceb 100644 --- a/ashdrop/cli/README.md +++ b/ashdrop/cli/README.md @@ -6,9 +6,9 @@ received file locally. It does not print environment-file plaintext. ## Build -Run these commands from `ashdrop/cli`. `build.zig.zon` declares Zig -`0.17.0-dev.1398+cb5635714` as the minimum and verified version; it does not -enforce an exact toolchain pin. +Run these commands from `ashdrop/cli`. `build.zig.zon` declares stable Zig +`0.16.0` as the minimum and verified version; it does not enforce an exact +toolchain pin. ```sh # Development build; installs ./zig-out/bin/ashdrop diff --git a/ashdrop/cli/build.zig b/ashdrop/cli/build.zig index 90c0b66..c2ad7ba 100644 --- a/ashdrop/cli/build.zig +++ b/ashdrop/cli/build.zig @@ -18,7 +18,12 @@ pub fn build(b: *std.Build) void { const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); - run_cmd.addPassthruArgs(); + // Keep argument forwarding compatible with stable 0.16 and newer build APIs. + if (@hasDecl(std.Build.Step.Run, "addPassthruArgs")) { + run_cmd.addPassthruArgs(); + } else if (b.args) |args| { + run_cmd.addArgs(args); + } const run_step = b.step("run", "Run the Ashdrop CLI"); run_step.dependOn(&run_cmd.step); diff --git a/ashdrop/cli/build.zig.zon b/ashdrop/cli/build.zig.zon index c9a3805..dbb5d5c 100644 --- a/ashdrop/cli/build.zig.zon +++ b/ashdrop/cli/build.zig.zon @@ -2,7 +2,7 @@ .{ .name = .ashdrop, .version = "0.1.0", - .minimum_zig_version = "0.17.0-dev.1398+cb5635714", + .minimum_zig_version = "0.16.0", .fingerprint = 0xc50801f2eac2c15d, .dependencies = .{}, .paths = .{ diff --git a/ashdrop/cli/src/config.zig b/ashdrop/cli/src/config.zig index ab2ae9b..e18e448 100644 --- a/ashdrop/cli/src/config.zig +++ b/ashdrop/cli/src/config.zig @@ -23,15 +23,13 @@ fn validateEndpoint(endpoint: []const u8) error{InvalidEndpoint}![]const u8 { if (!std.mem.eql(u8, uri.scheme, "http") and !std.mem.eql(u8, uri.scheme, "https")) return error.InvalidEndpoint; if (uri.user != null or uri.password != null or uri.query != null or uri.fragment != null) return error.InvalidEndpoint; - var hostname_buffer: [std.Io.net.HostName.max_len]u8 = undefined; - if (std.Io.net.HostName.fromUri(uri, &hostname_buffer)) |_| { - return endpoint; - } else |_| { - const host = uri.host orelse return error.InvalidEndpoint; - var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; - const raw_host = host.toRaw(&host_buffer) catch return error.InvalidEndpoint; + const host = uri.host orelse return error.InvalidEndpoint; + var host_buffer: [std.Io.net.HostName.max_len]u8 = undefined; + const raw_host = host.toRaw(&host_buffer) catch return error.InvalidEndpoint; + // IP literals are valid endpoints even though they are not DNS host names. + std.Io.net.HostName.validate(raw_host) catch { _ = std.Io.net.IpAddress.parseLiteral(raw_host) catch return error.InvalidEndpoint; - } + }; return endpoint; } From aabe9163e384ea0a94a48d06bd0bdc7c481e9c2a Mon Sep 17 00:00:00 2001 From: EmmanuelKeifala Date: Sat, 18 Jul 2026 05:26:16 +0000 Subject: [PATCH 9/9] fix: address api review blockers --- .gitignore | 1 - ashdrop/api/main_test.go | 612 ++++++++++++++++++++++++++++++++ ashdrop/api/store.go | 51 +-- ashdrop/cli/src/crypto.zig | 2 +- ashdrop/cli/src/crypto_test.zig | 10 + 5 files changed, 640 insertions(+), 36 deletions(-) create mode 100644 ashdrop/api/main_test.go diff --git a/.gitignore b/.gitignore index c22e78a..ead3a38 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ ashdrop/api/ashdrop # zig CLI ashdrop/cli/.zig-cache/ ashdrop/cli/zig-out/ -docs/* # env / local .env diff --git a/ashdrop/api/main_test.go b/ashdrop/api/main_test.go new file mode 100644 index 0000000..36c8b92 --- /dev/null +++ b/ashdrop/api/main_test.go @@ -0,0 +1,612 @@ +package main + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +type testAPI struct { + store *Store + handler http.Handler +} + +type createResponse struct { + ID string `json:"id"` + NotifyToken string `json:"notifyToken"` + ExpiresAt int64 `json:"expiresAt"` +} + +type inboxResponse struct { + Items []inboxResponseItem `json:"items"` +} + +func newTestAPI(t *testing.T) *testAPI { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "ashdrop.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return &testAPI{store: store, handler: newHandler(store)} +} + +func request(t *testing.T, handler http.Handler, method, target, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + return requestFrom(t, handler, method, target, body, headers, "192.0.2.1:1234") +} + +func requestFrom(t *testing.T, handler http.Handler, method, target, body string, headers map[string]string, remoteAddr string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, strings.NewReader(body)) + req.RemoteAddr = remoteAddr + for name, value := range headers { + req.Header.Set(name, value) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder +} + +func decodeJSON[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { + t.Helper() + var value T + if err := json.Unmarshal(recorder.Body.Bytes(), &value); err != nil { + t.Fatalf("decode response %q: %v", recorder.Body.String(), err) + } + return value +} + +func p256Key(t *testing.T) (*ecdh.PrivateKey, string) { + t.Helper() + privateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate P-256 key: %v", err) + } + return privateKey, base64.RawURLEncoding.EncodeToString(privateKey.PublicKey().Bytes()) +} + +func createPayload(t *testing.T, recipientPub, ephemeralPub string, ttl, maxViews int) string { + t.Helper() + body, err := json.Marshal(map[string]any{ + "ciphertext": "ciphertext-value", + "iv": "nonce-value", + "ttl": ttl, + "maxViews": maxViews, + "recipientPub": recipientPub, + "ephemeralPub": ephemeralPub, + }) + if err != nil { + t.Fatalf("encode create payload: %v", err) + } + return string(body) +} + +func createDrop(t *testing.T, api *testAPI, recipientPub, ephemeralPub string, ttl, maxViews int) createResponse { + t.Helper() + recorder := request(t, api.handler, http.MethodPost, "/api/secrets", createPayload(t, recipientPub, ephemeralPub, ttl, maxViews), nil) + if recorder.Code != http.StatusCreated { + t.Fatalf("create status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + return decodeJSON[createResponse](t, recorder) +} + +func inboxProof(t *testing.T, api *testAPI, recipientPrivate *ecdh.PrivateKey, recipientPub string, limit int, at int64) string { + t.Helper() + canonical := inboxProofInfo + "\nGET\n/api/addresses/" + recipientPub + "/inbox\nlimit=" + fmt.Sprint(limit) + "&at=" + fmt.Sprint(at) + return inboxProofForCanonical(t, api, recipientPrivate, canonical) +} + +func inboxProofForCanonical(t *testing.T, api *testAPI, recipientPrivate *ecdh.PrivateKey, canonical string) string { + t.Helper() + recorder := request(t, api.handler, http.MethodGet, "/api/inbox-key", "", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("inbox key status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + response := decodeJSON[struct { + PublicKey string `json:"publicKey"` + }](t, recorder) + serverBytes, err := base64.RawURLEncoding.DecodeString(response.PublicKey) + if err != nil { + t.Fatalf("decode inbox key: %v", err) + } + serverPublic, err := ecdh.P256().NewPublicKey(serverBytes) + if err != nil { + t.Fatalf("parse inbox key: %v", err) + } + shared, err := recipientPrivate.ECDH(serverPublic) + if err != nil { + t.Fatalf("derive inbox shared secret: %v", err) + } + key, err := hkdf.Key(sha256.New, shared, make([]byte, sha256.Size), inboxProofInfo, sha256.Size) + clear(shared) + if err != nil { + t.Fatalf("derive inbox proof key: %v", err) + } + defer clear(key) + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(canonical)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func inboxRequest(t *testing.T, api *testAPI, recipientPub string, limit int, at int64, proof string) *httptest.ResponseRecorder { + t.Helper() + target := fmt.Sprintf("/api/addresses/%s/inbox?limit=%d&at=%d", recipientPub, limit, at) + return request(t, api.handler, http.MethodGet, target, "", map[string]string{inboxProofHeader: proof}) +} + +func assertError(t *testing.T, recorder *httptest.ResponseRecorder, status int, message string) { + t.Helper() + if recorder.Code != status { + t.Fatalf("status = %d, want %d; body = %s", recorder.Code, status, recorder.Body.String()) + } + response := decodeJSON[map[string]string](t, recorder) + if response["error"] != message { + t.Fatalf("error = %q, want %q", response["error"], message) + } +} + +func TestCreateRecipientKeyValidation(t *testing.T) { + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + offCurveBytes := make([]byte, 65) + offCurveBytes[0] = 0x04 + offCurve := base64.RawURLEncoding.EncodeToString(offCurveBytes) + wrongPrefixBytes := make([]byte, 65) + wrongPrefixBytes[0] = 0x02 + wrongPrefix := base64.RawURLEncoding.EncodeToString(wrongPrefixBytes) + + tests := []struct { + name string + body string + wantStatus int + }{ + {name: "valid pair", body: createPayload(t, recipient, ephemeral, 3600, 1), wantStatus: http.StatusCreated}, + {name: "malformed JSON", body: "{", wantStatus: http.StatusBadRequest}, + {name: "missing ciphertext", body: `{"iv":"nonce"}`, wantStatus: http.StatusBadRequest}, + {name: "missing IV", body: `{"ciphertext":"cipher"}`, wantStatus: http.StatusBadRequest}, + {name: "recipient only", body: createPayload(t, recipient, "", 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "ephemeral only", body: createPayload(t, "", ephemeral, 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "malformed recipient", body: createPayload(t, "%%%", ephemeral, 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "padded recipient", body: createPayload(t, recipient+"=", ephemeral, 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "short recipient", body: createPayload(t, base64.RawURLEncoding.EncodeToString([]byte{4, 1}), ephemeral, 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "wrong recipient prefix", body: createPayload(t, wrongPrefix, ephemeral, 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "off curve recipient", body: createPayload(t, offCurve, ephemeral, 3600, 1), wantStatus: http.StatusBadRequest}, + {name: "off curve ephemeral", body: createPayload(t, recipient, offCurve, 3600, 1), wantStatus: http.StatusBadRequest}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + api := newTestAPI(t) + recorder := request(t, api.handler, http.MethodPost, "/api/secrets", test.body, nil) + if recorder.Code != test.wantStatus { + t.Fatalf("status = %d, want %d; body = %s", recorder.Code, test.wantStatus, recorder.Body.String()) + } + }) + } +} + +func TestCreateAppliesTTLAndViewBounds(t *testing.T) { + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + tests := []struct { + name string + ttl int + maxViews int + wantTTL int64 + wantViewsLeft int + }{ + {name: "short TTL defaults", ttl: minTTL - 1, maxViews: -1, wantTTL: 24 * 3600, wantViewsLeft: -1}, + {name: "long TTL clamps", ttl: maxTTL + 1, maxViews: 2, wantTTL: maxTTL, wantViewsLeft: 2}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + api := newTestAPI(t) + before := time.Now().Unix() + created := createDrop(t, api, recipient, ephemeral, test.ttl, test.maxViews) + if created.ExpiresAt < before+test.wantTTL || created.ExpiresAt > time.Now().Unix()+test.wantTTL { + t.Fatalf("expiresAt = %d, want current time + %d", created.ExpiresAt, test.wantTTL) + } + recorder := request(t, api.handler, http.MethodGet, "/api/secrets/"+created.ID+"/metadata", "", nil) + metadata := decodeJSON[struct { + ViewsLeft int `json:"viewsLeft"` + }](t, recorder) + if metadata.ViewsLeft != test.wantViewsLeft { + t.Fatalf("viewsLeft = %d, want %d", metadata.ViewsLeft, test.wantViewsLeft) + } + }) + } +} + +func TestCreateRateLimit(t *testing.T) { + api := newTestAPI(t) + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + body := createPayload(t, recipient, ephemeral, 3600, 1) + for attempt := 1; attempt <= 30; attempt++ { + recorder := request(t, api.handler, http.MethodPost, "/api/secrets", body, nil) + if recorder.Code != http.StatusCreated { + t.Fatalf("attempt %d status = %d, want 201", attempt, recorder.Code) + } + } + otherIP := requestFrom(t, api.handler, http.MethodPost, "/api/secrets", body, nil, "198.51.100.2:4321") + if otherIP.Code != http.StatusCreated { + t.Fatalf("other IP status = %d, want 201", otherIP.Code) + } + limited := request(t, api.handler, http.MethodPost, "/api/secrets", body, map[string]string{"X-Forwarded-For": "203.0.113.7"}) + if limited.Code != http.StatusTooManyRequests || limited.Header().Get("Retry-After") != "60" { + t.Fatalf("rate limit response: status=%d retry=%q", limited.Code, limited.Header().Get("Retry-After")) + } +} + +func TestCreateRateLimitUsesTrustedProxyAddress(t *testing.T) { + t.Setenv("ASHDROP_TRUST_PROXY", "true") + api := newTestAPI(t) + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + body := createPayload(t, recipient, ephemeral, 3600, 1) + for attempt := 1; attempt <= 30; attempt++ { + recorder := request(t, api.handler, http.MethodPost, "/api/secrets", body, map[string]string{"X-Forwarded-For": "203.0.113.1"}) + if recorder.Code != http.StatusCreated { + t.Fatalf("attempt %d status = %d, want 201", attempt, recorder.Code) + } + } + otherForwardedIP := request(t, api.handler, http.MethodPost, "/api/secrets", body, map[string]string{"X-Forwarded-For": "203.0.113.2, 10.0.0.1"}) + if otherForwardedIP.Code != http.StatusCreated { + t.Fatalf("other forwarded IP status = %d, want 201", otherForwardedIP.Code) + } + limited := request(t, api.handler, http.MethodPost, "/api/secrets", body, map[string]string{"X-Forwarded-For": "203.0.113.1"}) + if limited.Code != http.StatusTooManyRequests { + t.Fatalf("limited forwarded IP status = %d, want 429", limited.Code) + } +} + +func TestMetadataRouteStates(t *testing.T) { + api := newTestAPI(t) + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + created := createDrop(t, api, recipient, ephemeral, 3600, 2) + + recorder := request(t, api.handler, http.MethodGet, "/api/secrets/"+created.ID+"/metadata", "", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("metadata status = %d", recorder.Code) + } + metadata := decodeJSON[map[string]any](t, recorder) + if metadata["recipientKeyed"] != true || metadata["recipientPub"] != recipient || metadata["viewsLeft"] != float64(2) { + t.Fatalf("unexpected metadata: %#v", metadata) + } + if len(metadata) != 4 { + t.Fatalf("metadata fields = %#v", metadata) + } + if _, found := metadata["ciphertext"]; found { + t.Fatal("metadata exposed ciphertext") + } + + firstOpen := request(t, api.handler, http.MethodPost, "/api/secrets/"+created.ID+"/open", "", nil) + if firstOpen.Code != http.StatusOK { + t.Fatalf("first open status = %d", firstOpen.Code) + } + recorder = request(t, api.handler, http.MethodGet, "/api/secrets/"+created.ID+"/metadata", "", nil) + metadata = decodeJSON[map[string]any](t, recorder) + if metadata["viewsLeft"] != float64(1) { + t.Fatalf("viewsLeft after open = %#v", metadata["viewsLeft"]) + } + + _ = request(t, api.handler, http.MethodPost, "/api/secrets/"+created.ID+"/open", "", nil) + assertError(t, request(t, api.handler, http.MethodGet, "/api/secrets/"+created.ID+"/metadata", "", nil), http.StatusNotFound, "this secret no longer exists") + assertError(t, request(t, api.handler, http.MethodGet, "/api/secrets/missing/metadata", "", nil), http.StatusNotFound, "this secret no longer exists") + + if err := api.store.Put("expired", Secret{Ciphertext: "cipher", IV: "iv", MaxViews: 1, ExpiresAt: time.Now().Unix() - 1, NotifyTok: "notify", RecipientPub: recipient, EphemeralPub: ephemeral}); err != nil { + t.Fatal(err) + } + assertError(t, request(t, api.handler, http.MethodGet, "/api/secrets/expired/metadata", "", nil), http.StatusNotFound, "this secret no longer exists") +} + +func TestOpenRouteStates(t *testing.T) { + api := newTestAPI(t) + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + limited := createDrop(t, api, recipient, ephemeral, 3600, 1) + + recorder := request(t, api.handler, http.MethodPost, "/api/secrets/"+limited.ID+"/open", "", nil) + if recorder.Code != http.StatusOK { + t.Fatalf("open status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + opened := decodeJSON[map[string]any](t, recorder) + if opened["ciphertext"] != "ciphertext-value" || opened["iv"] != "nonce-value" || opened["ephemeralPub"] != ephemeral || opened["recipientKeyed"] != true { + t.Fatalf("unexpected open response: %#v", opened) + } + if _, found := opened["recipientPub"]; found { + t.Fatal("open response exposed recipient public key") + } + assertError(t, request(t, api.handler, http.MethodPost, "/api/secrets/"+limited.ID+"/open", "", nil), http.StatusNotFound, "this secret no longer exists") + assertError(t, request(t, api.handler, http.MethodPost, "/api/secrets/missing/open", "", nil), http.StatusNotFound, "this secret no longer exists") + + unlimited := createDrop(t, api, recipient, ephemeral, 3600, 0) + for range 2 { + if got := request(t, api.handler, http.MethodPost, "/api/secrets/"+unlimited.ID+"/open", "", nil).Code; got != http.StatusOK { + t.Fatalf("unlimited open status = %d", got) + } + } + + if err := api.store.Put("expired", Secret{Ciphertext: "cipher", IV: "iv", MaxViews: 1, ExpiresAt: time.Now().Unix() - 1, NotifyTok: "notify", RecipientPub: recipient, EphemeralPub: ephemeral}); err != nil { + t.Fatal(err) + } + assertError(t, request(t, api.handler, http.MethodPost, "/api/secrets/expired/open", "", nil), http.StatusNotFound, "this secret no longer exists") +} + +func TestOpenFinalViewIsAtomic(t *testing.T) { + api := newTestAPI(t) + api.store.db.SetMaxOpenConns(8) + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + created := createDrop(t, api, recipient, ephemeral, 3600, 1) + + const requests = 20 + statuses := make(chan int, requests) + var wait sync.WaitGroup + for range requests { + wait.Add(1) + go func() { + defer wait.Done() + statuses <- request(t, api.handler, http.MethodPost, "/api/secrets/"+created.ID+"/open", "", nil).Code + }() + } + wait.Wait() + close(statuses) + counts := map[int]int{} + for status := range statuses { + counts[status]++ + } + if counts[http.StatusOK] != 1 || counts[http.StatusNotFound] != requests-1 { + t.Fatalf("open status counts = %#v", counts) + } + var ciphertext string + if err := api.store.db.QueryRow(`SELECT ciphertext FROM secrets WHERE id = ?`, created.ID).Scan(&ciphertext); err != nil { + t.Fatal(err) + } + if ciphertext != "" { + t.Fatal("final open did not wipe ciphertext") + } +} + +func TestInboxKeyRoute(t *testing.T) { + api := newTestAPI(t) + first := request(t, api.handler, http.MethodGet, "/api/inbox-key", "", nil) + if first.Code != http.StatusOK || first.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("first inbox key response: status=%d cache=%q", first.Code, first.Header().Get("Cache-Control")) + } + firstBody := decodeJSON[map[string]string](t, first) + encoded := firstBody["publicKey"] + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil || len(decoded) != 65 || decoded[0] != 0x04 || base64.RawURLEncoding.EncodeToString(decoded) != encoded { + t.Fatalf("noncanonical inbox key %q: %v", encoded, err) + } + if _, err := ecdh.P256().NewPublicKey(decoded); err != nil { + t.Fatalf("invalid inbox public key: %v", err) + } + second := decodeJSON[map[string]string](t, request(t, api.handler, http.MethodGet, "/api/inbox-key", "", nil)) + if second["publicKey"] != encoded { + t.Fatal("inbox key changed between requests") + } + + if err := api.store.Close(); err != nil { + t.Fatal(err) + } + assertError(t, request(t, api.handler, http.MethodGet, "/api/inbox-key", "", nil), http.StatusInternalServerError, "internal server error") +} + +func TestInboxListingFiltersAndOrders(t *testing.T) { + api := newTestAPI(t) + recipientPrivate, recipient := p256Key(t) + _, otherRecipient := p256Key(t) + _, ephemeral := p256Key(t) + expires := time.Now().Unix() + 3600 + secrets := []struct { + id string + recipient string + createdAt int64 + maxViews int + }{ + {id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", recipient: recipient, createdAt: 2, maxViews: 0}, + {id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", recipient: recipient, createdAt: 2, maxViews: 2}, + {id: "cccccccccccccccccccccccccccccccc", recipient: otherRecipient, createdAt: 1, maxViews: 1}, + } + for _, secret := range secrets { + if err := api.store.Put(secret.id, Secret{Ciphertext: "cipher", IV: "iv", MaxViews: secret.maxViews, ExpiresAt: expires, CreatedAt: secret.createdAt, NotifyTok: "notify", RecipientPub: secret.recipient, EphemeralPub: ephemeral}); err != nil { + t.Fatal(err) + } + } + if err := api.store.Put("expiredexpiredexpiredexpired1234", Secret{Ciphertext: "cipher", IV: "iv", MaxViews: 1, ExpiresAt: time.Now().Unix() - 1, CreatedAt: 0, NotifyTok: "notify", RecipientPub: recipient, EphemeralPub: ephemeral}); err != nil { + t.Fatal(err) + } + if err := api.store.Put("burnedburnedburnedburnedburned12", Secret{Ciphertext: "cipher", IV: "iv", MaxViews: 1, ExpiresAt: expires, CreatedAt: 0, NotifyTok: "notify", RecipientPub: recipient, EphemeralPub: ephemeral}); err != nil { + t.Fatal(err) + } + if _, err := api.store.Open("burnedburnedburnedburnedburned12"); err != nil { + t.Fatal(err) + } + + at := time.Now().Unix() + proof := inboxProof(t, api, recipientPrivate, recipient, 2, at) + recorder := inboxRequest(t, api, recipient, 2, at, proof) + if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("inbox response: status=%d cache=%q body=%s", recorder.Code, recorder.Header().Get("Cache-Control"), recorder.Body.String()) + } + response := decodeJSON[inboxResponse](t, recorder) + if len(response.Items) != 2 || response.Items[0].ID != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || response.Items[0].ViewsLeft != 2 || response.Items[1].ID != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" || response.Items[1].ViewsLeft != -1 { + t.Fatalf("unexpected inbox items: %#v", response.Items) + } + var raw map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + if strings.Contains(recorder.Body.String(), "cipher") || strings.Contains(recorder.Body.String(), "nonce") { + t.Fatal("inbox exposed encrypted payload") + } +} + +func TestInboxAuthenticationEdges(t *testing.T) { + api := newTestAPI(t) + recipientPrivate, recipient := p256Key(t) + _, wrongRecipient := p256Key(t) + now := time.Now().Unix() + validProof := inboxProof(t, api, recipientPrivate, recipient, 20, now) + wrongMethodProof := inboxProofForCanonical(t, api, recipientPrivate, inboxProofInfo+"\nPOST\n/api/addresses/"+recipient+"/inbox\nlimit=20&at="+fmt.Sprint(now)) + wrongPathProof := inboxProofForCanonical(t, api, recipientPrivate, inboxProofInfo+"\nGET\n/api/addresses/"+recipient+"/wrong\nlimit=20&at="+fmt.Sprint(now)) + offCurveBytes := make([]byte, 65) + offCurveBytes[0] = 0x04 + offCurve := base64.RawURLEncoding.EncodeToString(offCurveBytes) + wrongPrefixBytes := make([]byte, 65) + wrongPrefixBytes[0] = 0x02 + wrongPrefix := base64.RawURLEncoding.EncodeToString(wrongPrefixBytes) + + tests := []struct { + name string + recipient string + limit string + at string + proof string + }{ + {name: "missing limit", recipient: recipient, at: fmt.Sprint(now), proof: validProof}, + {name: "zero limit", recipient: recipient, limit: "0", at: fmt.Sprint(now), proof: validProof}, + {name: "negative limit", recipient: recipient, limit: "-1", at: fmt.Sprint(now), proof: validProof}, + {name: "large limit", recipient: recipient, limit: "101", at: fmt.Sprint(now), proof: validProof}, + {name: "missing timestamp", recipient: recipient, limit: "20", proof: validProof}, + {name: "stale timestamp", recipient: recipient, limit: "20", at: fmt.Sprint(now - 301), proof: validProof}, + {name: "future timestamp", recipient: recipient, limit: "20", at: fmt.Sprint(now + 301), proof: validProof}, + {name: "malformed recipient", recipient: "%25%25%25", limit: "20", at: fmt.Sprint(now), proof: validProof}, + {name: "padded recipient", recipient: recipient + "=", limit: "20", at: fmt.Sprint(now), proof: validProof}, + {name: "short recipient", recipient: "BA", limit: "20", at: fmt.Sprint(now), proof: validProof}, + {name: "wrong recipient prefix", recipient: wrongPrefix, limit: "20", at: fmt.Sprint(now), proof: validProof}, + {name: "off curve recipient", recipient: offCurve, limit: "20", at: fmt.Sprint(now), proof: validProof}, + {name: "missing proof", recipient: recipient, limit: "20", at: fmt.Sprint(now)}, + {name: "malformed proof", recipient: recipient, limit: "20", at: fmt.Sprint(now), proof: "%%%"}, + {name: "padded proof", recipient: recipient, limit: "20", at: fmt.Sprint(now), proof: validProof + "="}, + {name: "short proof", recipient: recipient, limit: "20", at: fmt.Sprint(now), proof: "AA"}, + {name: "wrong proof", recipient: recipient, limit: "20", at: fmt.Sprint(now), proof: base64.RawURLEncoding.EncodeToString(make([]byte, sha256.Size))}, + {name: "wrong recipient binding", recipient: wrongRecipient, limit: "20", at: fmt.Sprint(now), proof: validProof}, + {name: "wrong limit binding", recipient: recipient, limit: "10", at: fmt.Sprint(now), proof: validProof}, + {name: "wrong timestamp binding", recipient: recipient, limit: "20", at: fmt.Sprint(now + 1), proof: validProof}, + {name: "wrong method binding", recipient: recipient, limit: "20", at: fmt.Sprint(now), proof: wrongMethodProof}, + {name: "wrong path binding", recipient: recipient, limit: "20", at: fmt.Sprint(now), proof: wrongPathProof}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + target := "/api/addresses/" + test.recipient + "/inbox?limit=" + test.limit + "&at=" + test.at + recorder := request(t, api.handler, http.MethodGet, target, "", map[string]string{inboxProofHeader: test.proof}) + assertError(t, recorder, http.StatusUnauthorized, "unauthorized") + }) + } +} + +func TestInboxAcceptsLimitBoundaries(t *testing.T) { + api := newTestAPI(t) + recipientPrivate, recipient := p256Key(t) + for _, limit := range []int{1, 100} { + at := time.Now().Unix() + proof := inboxProof(t, api, recipientPrivate, recipient, limit, at) + recorder := inboxRequest(t, api, recipient, limit, at, proof) + if recorder.Code != http.StatusOK { + t.Fatalf("limit %d status = %d, body = %s", limit, recorder.Code, recorder.Body.String()) + } + } +} + +func TestCLIRouteStoreFailures(t *testing.T) { + _, recipient := p256Key(t) + _, ephemeral := p256Key(t) + tests := []struct { + name string + method string + target string + body string + message string + }{ + {name: "create", method: http.MethodPost, target: "/api/secrets", body: createPayload(t, recipient, ephemeral, 3600, 1), message: "could not store secret"}, + {name: "metadata", method: http.MethodGet, target: "/api/secrets/id/metadata", message: "lookup failed"}, + {name: "open", method: http.MethodPost, target: "/api/secrets/id/open", message: "lookup failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + api := newTestAPI(t) + if err := api.store.Close(); err != nil { + t.Fatal(err) + } + assertError(t, request(t, api.handler, test.method, test.target, test.body, nil), http.StatusInternalServerError, test.message) + }) + } +} + +func TestCLIRoutesRejectWrongMethods(t *testing.T) { + api := newTestAPI(t) + tests := []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/api/secrets"}, + {method: http.MethodPost, path: "/api/secrets/id/metadata"}, + {method: http.MethodGet, path: "/api/secrets/id/open"}, + {method: http.MethodPost, path: "/api/inbox-key"}, + {method: http.MethodPost, path: "/api/addresses/recipient/inbox"}, + } + for _, test := range tests { + recorder := request(t, api.handler, test.method, test.path, "", nil) + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatalf("%s %s status = %d, want 405", test.method, test.path, recorder.Code) + } + } +} + +func TestInboxStoreFailure(t *testing.T) { + api := newTestAPI(t) + recipientPrivate, recipient := p256Key(t) + at := time.Now().Unix() + proof := inboxProof(t, api, recipientPrivate, recipient, 20, at) + if err := api.store.Close(); err != nil { + t.Fatal(err) + } + assertError(t, inboxRequest(t, api, recipient, 20, at, proof), http.StatusInternalServerError, "internal server error") +} + +func TestInboxListStoreFailure(t *testing.T) { + api := newTestAPI(t) + recipientPrivate, recipient := p256Key(t) + at := time.Now().Unix() + proof := inboxProof(t, api, recipientPrivate, recipient, 20, at) + if _, err := api.store.db.Exec(`DROP TABLE secrets`); err != nil { + t.Fatal(err) + } + assertError(t, inboxRequest(t, api, recipient, 20, at, proof), http.StatusInternalServerError, "internal server error") +} + +func TestInboxRateLimit(t *testing.T) { + api := newTestAPI(t) + for attempt := 1; attempt <= 30; attempt++ { + recorder := request(t, api.handler, http.MethodGet, "/api/addresses/invalid/inbox?limit=20&at=0", "", nil) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want 401", attempt, recorder.Code) + } + } + otherIP := requestFrom(t, api.handler, http.MethodGet, "/api/addresses/invalid/inbox?limit=20&at=0", "", nil, "198.51.100.2:4321") + if otherIP.Code != http.StatusUnauthorized { + t.Fatalf("other IP status = %d, want 401", otherIP.Code) + } + limited := request(t, api.handler, http.MethodGet, "/api/addresses/invalid/inbox?limit=20&at=0", "", map[string]string{"X-Forwarded-For": "203.0.113.7"}) + if limited.Code != http.StatusTooManyRequests || limited.Header().Get("Retry-After") != "60" { + t.Fatalf("rate limit response: status=%d retry=%q", limited.Code, limited.Header().Get("Retry-After")) + } +} diff --git a/ashdrop/api/store.go b/ashdrop/api/store.go index d35d5e3..f2e8f3d 100644 --- a/ashdrop/api/store.go +++ b/ashdrop/api/store.go @@ -236,22 +236,17 @@ func (s *Store) Open(id string) (*Secret, error) { defer tx.Rollback() //nolint:errcheck var sec Secret - var burned int - var openedAt sql.NullInt64 + var openedAt int64 + current := now() row := tx.QueryRow( - `SELECT ciphertext, iv, max_views, views, burned, expires_at, created_at, opened_at, ephemeral_pub - FROM secrets WHERE id = ?`, id) - err = row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &burned, &sec.ExpiresAt, &sec.CreatedAt, &openedAt, &sec.EphemeralPub) + `UPDATE secrets + SET views = views + 1, opened_at = COALESCE(opened_at, ?) + WHERE id = ? AND burned = 0 AND expires_at > ? AND (max_views = 0 OR views < max_views) + RETURNING ciphertext, iv, max_views, views, expires_at, created_at, opened_at, ephemeral_pub`, + current, id, current) + err = row.Scan(&sec.Ciphertext, &sec.IV, &sec.MaxViews, &sec.Views, &sec.ExpiresAt, &sec.CreatedAt, &openedAt, &sec.EphemeralPub) if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - - current := now() - if sec.ExpiresAt <= current { - if _, err := tx.Exec(`DELETE FROM secrets WHERE id = ?`, id); err != nil { + if _, err := tx.Exec(`DELETE FROM secrets WHERE id = ? AND expires_at <= ?`, id, current); err != nil { return nil, err } if err := tx.Commit(); err != nil { @@ -259,31 +254,19 @@ func (s *Store) Open(id string) (*Secret, error) { } return nil, nil } - if burned == 1 || (sec.MaxViews > 0 && sec.Views >= sec.MaxViews) { - return nil, nil - } - - sec.Views++ - if openedAt.Valid { - v := openedAt.Int64 - sec.OpenedAt = &v - } else { - sec.OpenedAt = ¤t + if err != nil { + return nil, err } + sec.OpenedAt = &openedAt if sec.MaxViews > 0 && sec.Views >= sec.MaxViews { _, err = tx.Exec( - `UPDATE secrets SET views = ?, burned = 1, ciphertext = '', iv = '', opened_at = ? WHERE id = ?`, - sec.Views, *sec.OpenedAt, id, - ) - } else { - _, err = tx.Exec( - `UPDATE secrets SET views = ?, opened_at = ? WHERE id = ?`, - sec.Views, *sec.OpenedAt, id, + `UPDATE secrets SET burned = 1, ciphertext = '', iv = '' WHERE id = ?`, + id, ) - } - if err != nil { - return nil, err + if err != nil { + return nil, err + } } if err := tx.Commit(); err != nil { return nil, err diff --git a/ashdrop/cli/src/crypto.zig b/ashdrop/cli/src/crypto.zig index 21e8283..8121c37 100644 --- a/ashdrop/cli/src/crypto.zig +++ b/ashdrop/cli/src/crypto.zig @@ -155,7 +155,7 @@ pub fn inboxProof( defer std.crypto.secureZero(u8, &hmac_key); deriveKeyWithInfo(recipient_private, server_public, inbox_hkdf_info, &hmac_key) catch return error.InvalidInboxKey; - var request_buffer: [160]u8 = undefined; + var request_buffer: [192]u8 = undefined; const request = std.fmt.bufPrint( &request_buffer, "ashdrop-inbox-v1\nGET\n/api/addresses/{s}/inbox\nlimit={d}&at={d}", diff --git a/ashdrop/cli/src/crypto_test.zig b/ashdrop/cli/src/crypto_test.zig index 902f15d..6d6589a 100644 --- a/ashdrop/cli/src/crypto_test.zig +++ b/ashdrop/cli/src/crypto_test.zig @@ -317,6 +317,16 @@ test "inbox proof binds the recipient limit and timestamp" { try std.testing.expect(!std.mem.eql(u8, base, changed_time)); } +test "inbox proof supports full width signed timestamps" { + const recipient_private = scalar(1); + const server_public = try publicSec1(scalar(2)); + for ([_]i64{ std.math.minInt(i64), std.math.maxInt(i64) }) |timestamp| { + const proof = try crypto.inboxProof(std.testing.allocator, &recipient_private, &server_public, 100, timestamp); + defer std.testing.allocator.free(proof); + try std.testing.expectEqual(@as(usize, 43), proof.len); + } +} + test "inbox proof rejects malformed server keys and private scalars" { const valid_private = scalar(1); const server_public = try publicSec1(scalar(2));