Verify Home identity non-reuse and preserve framework security policy
7 files changed,
+124
−5
internal/memoria/handlers.go+3 −3internal/memoria/handlers_connector.go+1 −1internal/memoria/home.go+8 −0internal/memoria/home_config_test.go+48 −0internal/memoria/home_identity_test.go+19 −0internal/memoria/home_security_test.go+44 −0internal/memoria/templates/layout.html+1 −1
CI passed — run details
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 7794194..21a8c94 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -543,7 +543,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| drop := newDropZone() |
| p := page{ |
| - Signed: true, HomeEnabled: a.home != nil, |
| + Signed: true, HomeEnabled: a.showHomeConnection(r), |
| Chunks: a.chunkCount(), |
| Query: q, |
| Searched: q != "", |
| @@ -586,7 +586,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| // "/" and still gets these results, inside a page. |
| func (a *app) searchResults(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| - p := page{Signed: true, HomeEnabled: a.home != nil, Query: q, Searched: q != ""} |
| + p := page{Signed: true, HomeEnabled: a.showHomeConnection(r), Query: q, Searched: q != ""} |
| a.runSearch(r, &p) |
| // The passage count is only spoken by the no-match copy, and this |
| // route runs once per keystroke. Counting only when the count will |
| @@ -1056,7 +1056,7 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) { |
| } |
| _ = body |
| render(w, "document", documentPage{ |
| - Signed: true, HomeEnabled: a.home != nil, |
| + Signed: true, HomeEnabled: a.showHomeConnection(r), |
| Chunks: a.chunkCount(), |
| Title: title, |
| Source: source, |
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go| index f3d034e..e5e4475 100644 |
| --- a/internal/memoria/handlers_connector.go |
| +++ b/internal/memoria/handlers_connector.go |
| @@ -100,7 +100,7 @@ func failMessage(code string) string { |
| } |
| func (a *app) connectorBase(r *http.Request) connectorPage { |
| - return connectorPage{Signed: true, HomeEnabled: a.home != nil, Chunks: a.chunkCount()} |
| + return connectorPage{Signed: true, HomeEnabled: a.showHomeConnection(r), Chunks: a.chunkCount()} |
| } |
| func (a *app) connectors(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/memoria/home.go b/internal/memoria/home.go| index 0b95d87..8429789 100644 |
| --- a/internal/memoria/home.go |
| +++ b/internal/memoria/home.go |
| @@ -100,3 +100,11 @@ func (a *app) homeAccount(w http.ResponseWriter, r *http.Request) { |
| } |
| a.home.routes.Account(w, r) |
| } |
| + |
| +func (a *app) showHomeConnection(r *http.Request) bool { |
| + if a.home == nil { |
| + return false |
| + } |
| + session, ok := sessions.Current(r) |
| + return ok && (session.Method == "magiclink" || session.Method == "keymail") |
| +} |
diff --git a/internal/memoria/home_config_test.go b/internal/memoria/home_config_test.go| new file mode 100644 |
| index 0000000..c30e285 |
| --- /dev/null |
| +++ b/internal/memoria/home_config_test.go |
| @@ -0,0 +1,48 @@ |
| +package memoria |
| + |
| +import ( |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "testing" |
| +) |
| + |
| +func TestHomeConfigRequiresCompleteExplicitCredentials(t *testing.T) { |
| + env := map[string]string{"MEMORIA_DEK": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), "MEMORIA_INSTANCE_KEY": "test-only-instance", "MEMORIA_OWNERS": "alice@example.test", "MEMORIA_ALLOW_LOGGED_MAIL": "1", "MEMORIA_ORIGIN": homeTestOrigin} |
| + get := func(k string) string { return env[k] } |
| + cfg, err := LoadConfig(get) |
| + if err != nil || cfg.Home != nil { |
| + t.Fatalf("standalone: %v", err) |
| + } |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(key.SignPub()) |
| + id := hex.EncodeToString(sum[:]) |
| + raw, err := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fields := map[string]string{"MEMORIA_HOME_ISSUER": homeTestIssuer, "MEMORIA_HOME_CONSUMER_ID": "memoria-test", "MEMORIA_HOME_KEYS_BASE64": base64.StdEncoding.EncodeToString(raw), "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))} |
| + for k, v := range fields { |
| + env[k] = v |
| + } |
| + cfg, err = LoadConfig(get) |
| + if err != nil || cfg.Home == nil { |
| + t.Fatalf("complete: %v", err) |
| + } |
| + if cfg.Home.Issuer != homeTestIssuer || !bytes.Equal(cfg.Home.Keys[id], key.SignPub()) { |
| + t.Fatal("changed credentials") |
| + } |
| + for k, v := range fields { |
| + delete(env, k) |
| + if _, err = LoadConfig(get); err == nil { |
| + t.Fatalf("accepted without %s", k) |
| + } |
| + env[k] = v |
| + } |
| +} |
diff --git a/internal/memoria/home_identity_test.go b/internal/memoria/home_identity_test.go| index cb48791..8318c82 100644 |
| --- a/internal/memoria/home_identity_test.go |
| +++ b/internal/memoria/home_identity_test.go |
| @@ -123,3 +123,22 @@ func TestHomeLocalIdentityRejectsWholeRowReassignment(t *testing.T) { |
| t.Fatal("moving ciphertext and its lookup together reassigned a linked identity") |
| } |
| } |
| + |
| +func TestHomeLocalIdentityIDIsNotReusedAfterDeletion(t *testing.T) { |
| + a := homeIdentityFixture(t) |
| + s := sessions.Session{Subject: "alice@example.test", Method: "magiclink"} |
| + first, _, err := a.ensureHomeLocalIdentity(t.Context(), s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = a.db.Writer().Exec("DELETE FROM memoria_home_local_identities WHERE id=?", first); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil { |
| + t.Fatal("deleted identity still resolved") |
| + } |
| + next, _, err := a.ensureHomeLocalIdentity(t.Context(), s) |
| + if err != nil || next <= first { |
| + t.Fatal("deleted identity ID reused", first, next, err) |
| + } |
| +} |
diff --git a/internal/memoria/home_security_test.go b/internal/memoria/home_security_test.go| new file mode 100644 |
| index 0000000..fe8ea15 |
| --- /dev/null |
| +++ b/internal/memoria/home_security_test.go |
| @@ -0,0 +1,44 @@ |
| +package memoria |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo" |
| +) |
| + |
| +func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| + baselineHandler, closeBaseline, err := rastrillo.Handler(rastrillo.Options{Mux: mux}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if closeBaseline != nil { |
| + defer closeBaseline() |
| + } |
| + rec := httptest.NewRecorder() |
| + baselineHandler.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/", nil)) |
| + baseline := rec.Header().Get("Content-Security-Policy") |
| + want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+homeTestIssuer, 1) |
| + if want == baseline { |
| + t.Fatal("framework form-action changed") |
| + } |
| + f := newHomeRouterFixture(t) |
| + wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if done != nil { |
| + defer done() |
| + } |
| + for _, path := range []string{"/signin/local", "/auth/start", "/account/home", "/signed-out"} { |
| + rec := httptest.NewRecorder() |
| + wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil)) |
| + if got := rec.Header().Get("Content-Security-Policy"); got != want { |
| + t.Fatalf("policy drift at %s: %q", path, got) |
| + } |
| + } |
| +} |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index ada8afb..c850a01 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -29,7 +29,7 @@ |
| <header rst-page-header> |
| <a href="/" rst-brand>memoria</a> |
| {{if .Signed}}<nav rst-inline> |
| - {{if .HomeEnabled}}<a href="/account/home" rst-btn="quiet compact">Connect Home account</a>{{end}} |
| + {{if .HomeEnabled}}<a href="/account/home" rst-btn="ghost">Connect Home account</a>{{end}} |
| <a href="/connectors" rst-btn="quiet compact">Sources</a> |
| <form method="post" action="/signout" rst-inline><button rst-btn="quiet compact">Sign out</button></form> |
| </nav>{{end}} |