Add register-by-signature, web fixes, bin scripts, docs
CI / test (push) Successful in 5s

- Register by signing service key: GET /v1/service-key, POST /v1/auth/register-by-signature
- Login auto-attempts register first for new users
- Web: default API URL momswap.produktor.duckdns.org, /libs/ static handler
- Docker: webbuild stage for geo-api-client, copy web+libs to runtime
- Bin scripts: test.sh, run.sh, up.sh, down.sh
- docs/ed25519-security-use-cases.md: use cases, message formats, examples
- SERVICE_PUBLIC_KEY env (defaults to ADMIN_PUBLIC_KEY)

Made-with: Cursor
This commit is contained in:
2026-03-01 12:58:44 +00:00
parent 978e0403eb
commit a5a97a0ad9
19 changed files with 405 additions and 41 deletions
+29 -4
View File
@@ -29,12 +29,13 @@ type Config struct {
}
type Service struct {
store *store.MemoryStore
config Config
store *store.MemoryStore
config Config
servicePublicKey string
}
func NewService(memoryStore *store.MemoryStore, cfg Config) *Service {
return &Service{store: memoryStore, config: cfg}
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service {
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey}
}
type InvitationPayload struct {
@@ -55,6 +56,30 @@ func (s *Service) BootstrapAdmin(publicKey string) {
})
}
func (s *Service) ServicePublicKey() string {
return s.servicePublicKey
}
func (s *Service) RegisterBySignature(publicKey, signature string) error {
if s.servicePublicKey == "" {
return fmt.Errorf("%w: registration by signature not configured", ErrBadRequest)
}
if publicKey == "" {
return fmt.Errorf("%w: missing public key", ErrBadRequest)
}
if _, err := s.store.GetUser(publicKey); err == nil {
return ErrAlreadyUser
}
if err := auth.VerifySignature(publicKey, s.servicePublicKey, signature); err != nil {
return fmt.Errorf("%w: signature verification failed", ErrBadRequest)
}
s.store.UpsertUser(store.User{
PublicKey: publicKey,
CreatedAt: time.Now().UTC(),
})
return nil
}
func (s *Service) CreateChallenge(publicKey string) (string, error) {
if publicKey == "" {
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
+57 -1
View File
@@ -21,7 +21,7 @@ func newTestServer(adminPublicKey string) *httptest.Server {
svc := app.NewService(memory, app.Config{
ChallengeTTL: 5 * time.Minute,
SessionTTL: 24 * time.Hour,
})
}, adminPublicKey)
svc.BootstrapAdmin(adminPublicKey)
api := httpapi.NewAPI(svc)
return httptest.NewServer(api.Routes())
@@ -36,6 +36,22 @@ func mustJSON(t *testing.T, value interface{}) []byte {
return b
}
func getJSON(t *testing.T, client *http.Client, url string, token string) (*http.Response, map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest(http.MethodGet, url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
out := map[string]interface{}{}
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp, out
}
func postJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(mustJSON(t, body)))
@@ -107,6 +123,46 @@ func registerUserViaAdmin(t *testing.T, client *http.Client, baseURL, adminPub s
}
}
func TestRegisterBySignature(t *testing.T) {
adminPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate admin key: %v", err)
}
adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub)
server := newTestServer(adminPubB64)
defer server.Close()
client := server.Client()
svcKeyResp, svcKeyData := getJSON(t, client, server.URL+"/v1/service-key", "")
if svcKeyResp.StatusCode != http.StatusOK {
t.Fatalf("get service key status=%d body=%v", svcKeyResp.StatusCode, svcKeyData)
}
if svcKeyData["publicKey"] != adminPubB64 {
t.Fatalf("service key mismatch: got %v", svcKeyData["publicKey"])
}
userPub, userPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate user key: %v", err)
}
userPubB64 := base64.RawURLEncoding.EncodeToString(userPub)
sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(userPriv, []byte(adminPubB64)))
regResp, regData := postJSON(t, client, server.URL+"/v1/auth/register-by-signature", map[string]string{
"publicKey": userPubB64,
"signature": sig,
}, "")
if regResp.StatusCode != http.StatusCreated {
t.Fatalf("register-by-signature status=%d body=%v", regResp.StatusCode, regData)
}
userToken := loginUser(t, client, server.URL, userPubB64, userPriv)
if userToken == "" {
t.Fatal("login after register-by-signature failed")
}
}
func TestRegisterLoginAndProfile(t *testing.T) {
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
+31
View File
@@ -22,11 +22,14 @@ func NewAPI(svc *app.Service) *API {
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
staticFiles := http.FileServer(http.Dir("web"))
libsFiles := http.FileServer(http.Dir("libs"))
mux.HandleFunc("GET /healthz", a.health)
mux.HandleFunc("GET /v1/service-key", a.getServiceKey)
mux.HandleFunc("POST /v1/auth/challenge", a.createChallenge)
mux.HandleFunc("POST /v1/auth/login", a.login)
mux.HandleFunc("POST /v1/auth/register", a.register)
mux.HandleFunc("POST /v1/auth/register-by-signature", a.registerBySignature)
mux.HandleFunc("POST /v1/invitations", a.createInvitation)
mux.HandleFunc("GET /v1/me/keys", a.me)
@@ -37,6 +40,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
mux.Handle("/", http.RedirectHandler("/web/", http.StatusTemporaryRedirect))
return withCORS(mux)
@@ -82,6 +86,8 @@ func statusFromErr(err error) int {
return http.StatusForbidden
case errors.Is(err, app.ErrBadRequest):
return http.StatusBadRequest
case errors.Is(err, app.ErrAlreadyUser):
return http.StatusConflict
case errors.Is(err, app.ErrInviteInvalid),
errors.Is(err, app.ErrInviteExpired),
errors.Is(err, app.ErrInviteExhaust):
@@ -156,6 +162,15 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"accessToken": token})
}
func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) {
pk := a.service.ServicePublicKey()
if pk == "" {
writeErr(w, app.ErrBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk})
}
func (a *API) register(w http.ResponseWriter, r *http.Request) {
var req struct {
PublicKey string `json:"publicKey"`
@@ -174,6 +189,22 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
}
func (a *API) registerBySignature(w http.ResponseWriter, r *http.Request) {
var req struct {
PublicKey string `json:"publicKey"`
Signature string `json:"signature"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
if err := a.service.RegisterBySignature(req.PublicKey, req.Signature); err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
}
func (a *API) createInvitation(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {