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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
133 changes: 101 additions & 32 deletions ashdrop/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
package main

import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"encoding/hex"
"encoding/json"
"log"
Expand All @@ -15,48 +17,56 @@ 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")
st, err := OpenStore(dbPath)
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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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()
Expand Down
111 changes: 111 additions & 0 deletions ashdrop/api/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading