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/api/main.go b/ashdrop/api/main.go index b936c00..e94dc35 100644 --- a/ashdrop/api/main.go +++ b/ashdrop/api/main.go @@ -3,7 +3,9 @@ package main import ( + "crypto/ecdh" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "log" @@ -15,12 +17,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 +32,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 +81,16 @@ func handleCreate(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusRequestEntityTooLarge, "secret too large") return } + if (req.RecipientPub == "") != (req.EphemeralPub == "") { + httpError(w, http.StatusBadRequest, "recipient and ephemeral public keys must be provided together") + 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 +111,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 +125,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 +135,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 +210,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 +233,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 +245,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 +270,22 @@ 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) + 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 cleanupLoop(s *Store) { t := time.NewTicker(5 * time.Minute) defer t.Stop() diff --git a/ashdrop/api/main_test.go b/ashdrop/api/main_test.go new file mode 100644 index 0000000..df0a772 --- /dev/null +++ b/ashdrop/api/main_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func testPublicKey(t *testing.T) string { + t.Helper() + private, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes()) +} + +func testStore(t *testing.T) *Store { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "ashdrop.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Error(err) + } + }) + return store +} + +func TestValidPublicKeyRejectsOffCurvePoint(t *testing.T) { + if !validPublicKey(testPublicKey(t)) { + t.Fatal("generated P-256 public key was rejected") + } + + offCurve := make([]byte, 65) + offCurve[0] = 0x04 + if validPublicKey(base64.RawURLEncoding.EncodeToString(offCurve)) { + t.Fatal("off-curve SEC1 point was accepted") + } +} + +func TestCreateRejectsInvalidOrPartialRecipientKeys(t *testing.T) { + store := testStore(t) + handler := newHandler(store) + valid := testPublicKey(t) + offCurve := make([]byte, 65) + offCurve[0] = 0x04 + invalid := base64.RawURLEncoding.EncodeToString(offCurve) + + for name, keys := range map[string]struct{ recipient, ephemeral string }{ + "off-curve recipient": {recipient: invalid, ephemeral: valid}, + "missing recipient": {ephemeral: valid}, + "missing ephemeral": {recipient: valid}, + } { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "ciphertext": "ciphertext", + "iv": "iv", + "recipientPub": keys.recipient, + "ephemeralPub": keys.ephemeral, + }) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/secrets", strings.NewReader(string(body))) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest) + } + }) + } +} + +func TestPartialRecipientMaterialIsUnavailable(t *testing.T) { + store := testStore(t) + if err := store.Put("legacy", Secret{ + Ciphertext: "ciphertext", + IV: "iv", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + NotifyTok: "notify", + EphemeralPub: testPublicKey(t), + }); err != nil { + t.Fatal(err) + } + + for name, operation := range map[string]func() (*Secret, error){ + "metadata": func() (*Secret, error) { return store.Metadata("legacy") }, + "fetch": func() (*Secret, error) { return store.Fetch("legacy") }, + "open": func() (*Secret, error) { return store.Open("legacy") }, + } { + t.Run(name, func(t *testing.T) { + secret, err := operation() + if err != nil { + t.Fatal(err) + } + if secret != nil { + t.Fatal("partial recipient material exposed a drop") + } + }) + } +} diff --git a/ashdrop/api/store.go b/ashdrop/api/store.go index 5b2fe9c..24655af 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,114 @@ 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 (sec.RecipientPub == "") != (sec.EphemeralPub == "") { + 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, recipient_pub, ephemeral_pub + FROM secrets WHERE id = ?`, id) + err = row.Scan(&sec.Ciphertext, &sec.IV, &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 + } + + 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 + } + if (sec.RecipientPub == "") != (sec.EphemeralPub == "") { + 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 +187,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 +203,9 @@ func (s *Store) Fetch(id string) (*Secret, error) { if burned == 1 { return nil, nil } + if sec.RecipientPub != "" || sec.EphemeralPub != "" { + return nil, nil + } if openedAt.Valid { v := openedAt.Int64 sec.OpenedAt = &v 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..90c0b66 --- /dev/null +++ b/ashdrop/cli/build.zig @@ -0,0 +1,36 @@ +//! Builds the standalone Ashdrop CLI executable and its unit-test runner. + +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..c9a3805 --- /dev/null +++ b/ashdrop/cli/build.zig.zon @@ -0,0 +1,13 @@ +// Declares the standalone Ashdrop CLI package and its source paths. +.{ + .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..153c5f5 --- /dev/null +++ b/ashdrop/cli/src/api.zig @@ -0,0 +1,479 @@ +//! Provides the Ashdrop HTTP client, wire types, response bounds, and error mapping. + +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); + + // 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 = .{ + .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, + // A configured API must not redirect ciphertext or metadata to another origin. + .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; + // 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(); + 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 supports create requests larger than the test server read buffer" { + const test_server = @import("test_server.zig"); + const ciphertext = try std.testing.allocator.alloc(u8, 9 * 1024); + defer std.testing.allocator.free(ciphertext); + @memset(ciphertext, 'a'); + 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}", + }, + }; + 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("0123456789abcdef0123456789abcdef", created.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..b048dd4 --- /dev/null +++ b/ashdrop/cli/src/commands.zig @@ -0,0 +1,718 @@ +//! 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"); +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 { + // 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, + 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()); + + // 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; + + 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; + // 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, + }; + 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 = "https://env.example", + }, + .{ + .input = "ashdrop://drop/0123456789abcdef0123456789abcdef?api=https%3A%2F%2Furi.example%3A18080", + .expected_endpoint = "https://uri.example:18080", + }, + }; + + for (references) |reference| { + const args = [_][]const u8{ "pull", reference.input }; + var target = try resolvePull(std.testing.allocator, &args, "https://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..8d006ad --- /dev/null +++ b/ashdrop/cli/src/config.zig @@ -0,0 +1,115 @@ +//! Resolves and validates managed, environment, embedded, and explicit Ashdrop endpoints. + +const std = @import("std"); + +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. + 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; + const host_name = std.Io.net.HostName.fromUri(uri, &hostname_buffer) catch |err| switch (err) { + error.InvalidHostName => null, + else => return error.InvalidEndpoint, + }; + if (std.mem.eql(u8, uri.scheme, "https")) return endpoint; + + 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; + if (std.Io.net.IpAddress.parseLiteral(raw_host)) |address| { + if (isLoopbackAddress(address)) return endpoint; + } else |_| { + if (host_name) |name| { + if (std.ascii.eqlIgnoreCase(name.bytes, "localhost") or std.ascii.eqlIgnoreCase(name.bytes, "localhost.")) return endpoint; + } + } + return error.InvalidEndpoint; +} + +fn isLoopbackAddress(address: std.Io.net.IpAddress) bool { + return switch (address) { + .ip4 => |ip4| ip4.bytes[0] == 127, + .ip6 => |ip6| ip6.isLoopBack() or if (std.Io.net.Ip4Address.fromIp6(ip6)) |ip4| ip4.bytes[0] == 127 else false, + }; +} + +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)); + try std.testing.expectEqualStrings("http://127.0.0.1:8080", try resolveApi("http://127.0.0.1:8080", null, null)); + try std.testing.expectEqualStrings("http://[::1]:8080", try resolveWeb("http://[::1]:8080", null)); +} + +test "endpoint configuration rejects remote HTTP" { + const invalid = [_][]const u8{ + "http://example.com", + "http://localhost.example", + "http://192.168.1.1", + "http://0.0.0.0", + "http://[::]", + "http://[2001:db8::1]", + }; + for (invalid) |endpoint| { + try std.testing.expectError(error.InvalidEndpoint, resolveApi(endpoint, null, null)); + try std.testing.expectError(error.InvalidEndpoint, resolveWeb(endpoint, null)); + } +} diff --git a/ashdrop/cli/src/crypto.zig b/ashdrop/cli/src/crypto.zig new file mode 100644 index 0000000..35cb992 --- /dev/null +++ b/ashdrop/cli/src/crypto.zig @@ -0,0 +1,276 @@ +//! 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; +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); + // 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; + 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].*; + // 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; +} + +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); + // 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); + 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; + // 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); + 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..b64273a --- /dev/null +++ b/ashdrop/cli/src/crypto_test.zig @@ -0,0 +1,267 @@ +//! Tests protocol interoperability, malformed input handling, and allocation cleanup. + +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..e90f72a --- /dev/null +++ b/ashdrop/cli/src/files.zig @@ -0,0 +1,240 @@ +//! Validates shared environment files and writes received plaintext through safe atomic outputs. + +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; +} + +pub fn syncDirectory(io: std.Io, dir: std.Io.Dir) !void { + var directory = try dir.openFile(io, ".", .{ .allow_directory = true }); + defer directory.close(io); + try directory.sync(io); +} + +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); + + // 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]; + 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); + + // 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, + }; + 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); + // Normal delivery links only if the destination stayed absent; replacement requires explicit force. + 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, + }; + } + try syncDirectory(io, output.dir); +} + +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..a377301 --- /dev/null +++ b/ashdrop/cli/src/identity.zig @@ -0,0 +1,340 @@ +//! Creates, validates, and securely persists the local P-256 receive identity. + +const std = @import("std"); +const files = @import("files.zig"); + +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 = try dir.createFileAtomic(io, filename, .{ + .permissions = filePermissions(), + .replace = false, + }); + defer file.deinit(io); + try file.file.setPermissions(io, filePermissions()); + try file.file.writeStreamingAll(io, json); + try file.file.sync(io); + file.link(io) catch |err| switch (err) { + error.PathAlreadyExists => return error.IdentityAlreadyExists, + else => |cause| return cause, + }; + try files.syncDirectory(io, dir); +} + +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; + + // Identity storage is a security boundary: traversal and symlinked components are never followed. + 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) { + // `mkdir` honors umask, so restore private permissions before this directory can hold keys. + 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..6d6d972 --- /dev/null +++ b/ashdrop/cli/src/links.zig @@ -0,0 +1,378 @@ +//! Parses and formats Ashdrop receive addresses, drop references, web URLs, and portable URIs. + +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 }); + } + + // 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); + 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 ..]; + // 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; + } + 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..584c80f --- /dev/null +++ b/ashdrop/cli/src/main.zig @@ -0,0 +1,576 @@ +//! 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"); +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..e6e8011 --- /dev/null +++ b/ashdrop/cli/src/test_server.zig @@ -0,0 +1,105 @@ +//! Provides a scripted loopback HTTP server for CLI request and response tests. + +const std = @import("std"); +const files = @import("files.zig"); + +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.thread.join(); + self.listener.deinit(self.io); + 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; + } + + if (request.head.content_length) |length| { + if (length >= files.max_api_body_size) return error.TestServerReadFailed; + } + var body_buffer: [files.max_api_body_size]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..718efed --- /dev/null +++ b/ashdrop/cli/test.zig @@ -0,0 +1,8 @@ +//! Imports the CLI modules that expose inline unit tests to Zig's test runner. + +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..a576a4c --- /dev/null +++ b/ashdrop/cli/testdata/generate-protocol-v1.mjs @@ -0,0 +1,80 @@ +// 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. +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" +} 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..30cf0ff 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,27 @@ onMount(async () => { try { + const metadata = await fetchMetadata(id); + if (!metadata) { phase = 'gone'; return; } + if (metadata.recipientKeyed) { + if (!metadata.recipientPub) { + phase = 'error'; + errMsg = 'This recipient-keyed drop is missing its recipient identity.'; + return; + } + 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 +72,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 +142,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 →