oficina / memoria Public

Verify Home identity non-reuse and preserve framework security policy

Paul Campbell pushed by paul@keymail.dev 1f2850b05704ceeffab52427096f3d515b399dd5 parent 543474c
7 files changed, +124 −5
  • internal/memoria/handlers.go +3 −3
  • internal/memoria/handlers_connector.go +1 −1
  • internal/memoria/home.go +8 −0
  • internal/memoria/home_config_test.go +48 −0
  • internal/memoria/home_identity_test.go +19 −0
  • internal/memoria/home_security_test.go +44 −0
  • internal/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}}