From 8a34c90c3384cdcedabd729d085335a737a988dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 11:35:45 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Encrypt=20mail=20at=20rest=20=E2=80=94=20su?= =?UTF-8?q?bject,=20body,=20recipient,=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AES-256-GCM encryption for sensitive mail fields (Subject, Body, To, ToID, RawHeaders) stored on disk. Key auto-generated at ~/.mu/keys/encryption.key on first run (or set MU_ENCRYPTION_KEY env). Existing unencrypted messages migrate transparently on next save. File permissions tightened from 0644 to 0600 for all data files. https://claude.ai/code/session_01GRGLA9yj7BpqKiyi6xFwnm --- internal/data/data.go | 2 +- mail/encrypt.go | 230 ++++++++++++++++++++++++++++++++++++++++++ mail/encrypt_test.go | 148 +++++++++++++++++++++++++++ mail/mail.go | 24 ++++- 4 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 mail/encrypt.go create mode 100644 mail/encrypt_test.go diff --git a/internal/data/data.go b/internal/data/data.go index dee6387a..5b2309b7 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -43,7 +43,7 @@ func SaveFile(key, val string) error { file := filepath.Join(path, key) // Create all parent directories including subdirectories in key os.MkdirAll(filepath.Dir(file), 0700) - os.WriteFile(file, []byte(val), 0644) + os.WriteFile(file, []byte(val), 0600) return nil } diff --git a/mail/encrypt.go b/mail/encrypt.go new file mode 100644 index 00000000..bdd3fc2b --- /dev/null +++ b/mail/encrypt.go @@ -0,0 +1,230 @@ +package mail + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "mu/internal/app" +) + +const encPrefix = "enc:" // prefix to identify encrypted fields + +var ( + encKey []byte + encOnce sync.Once + encEnabled bool +) + +// initEncryption loads or generates the encryption key +func initEncryption() { + encOnce.Do(func() { + // Try env var first + if keyStr := os.Getenv("MU_ENCRYPTION_KEY"); keyStr != "" { + decoded, err := base64.StdEncoding.DecodeString(keyStr) + if err == nil && len(decoded) == 32 { + encKey = decoded + encEnabled = true + app.Log("mail", "Encryption enabled (from MU_ENCRYPTION_KEY)") + return + } + app.Log("mail", "WARNING: MU_ENCRYPTION_KEY invalid (must be 32 bytes, base64-encoded)") + } + + // Try key file + keyDir := os.ExpandEnv("$HOME/.mu/keys") + keyFile := filepath.Join(keyDir, "encryption.key") + + if data, err := os.ReadFile(keyFile); err == nil { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err == nil && len(decoded) == 32 { + encKey = decoded + encEnabled = true + app.Log("mail", "Encryption enabled (from %s)", keyFile) + return + } + } + + // Generate new key + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + app.Log("mail", "WARNING: Failed to generate encryption key: %v", err) + return + } + + os.MkdirAll(keyDir, 0700) + encoded := base64.StdEncoding.EncodeToString(key) + if err := os.WriteFile(keyFile, []byte(encoded), 0600); err != nil { + app.Log("mail", "WARNING: Failed to save encryption key: %v", err) + return + } + + encKey = key + encEnabled = true + app.Log("mail", "Encryption enabled (new key generated at %s)", keyFile) + }) +} + +// encrypt encrypts plaintext using AES-256-GCM +func encrypt(plaintext string) (string, error) { + if !encEnabled || plaintext == "" { + return plaintext, nil + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decrypt decrypts ciphertext using AES-256-GCM +func decrypt(ciphertext string) (string, error) { + if !encEnabled || ciphertext == "" { + return ciphertext, nil + } + + // Not encrypted — return as-is (handles pre-encryption messages) + if !strings.HasPrefix(ciphertext, encPrefix) { + return ciphertext, nil + } + + data, err := base64.StdEncoding.DecodeString(ciphertext[len(encPrefix):]) + if err != nil { + return ciphertext, err + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +// encryptMessage encrypts sensitive fields in a message for storage +func encryptMessage(m *Message) error { + if !encEnabled { + return nil + } + + var err error + + if m.Subject != "" && !strings.HasPrefix(m.Subject, encPrefix) { + m.Subject, err = encrypt(m.Subject) + if err != nil { + return err + } + } + + if m.Body != "" && !strings.HasPrefix(m.Body, encPrefix) { + m.Body, err = encrypt(m.Body) + if err != nil { + return err + } + } + + if m.To != "" && !strings.HasPrefix(m.To, encPrefix) { + m.To, err = encrypt(m.To) + if err != nil { + return err + } + } + + if m.ToID != "" && !strings.HasPrefix(m.ToID, encPrefix) { + m.ToID, err = encrypt(m.ToID) + if err != nil { + return err + } + } + + if m.RawHeaders != "" && !strings.HasPrefix(m.RawHeaders, encPrefix) { + m.RawHeaders, err = encrypt(m.RawHeaders) + if err != nil { + return err + } + } + + return nil +} + +// decryptMessage decrypts sensitive fields in a message after loading +func decryptMessage(m *Message) error { + if !encEnabled { + return nil + } + + var err error + + if strings.HasPrefix(m.Subject, encPrefix) { + m.Subject, err = decrypt(m.Subject) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.Body, encPrefix) { + m.Body, err = decrypt(m.Body) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.To, encPrefix) { + m.To, err = decrypt(m.To) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.ToID, encPrefix) { + m.ToID, err = decrypt(m.ToID) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.RawHeaders, encPrefix) { + m.RawHeaders, err = decrypt(m.RawHeaders) + if err != nil { + return err + } + } + + return nil +} diff --git a/mail/encrypt_test.go b/mail/encrypt_test.go new file mode 100644 index 00000000..8b0430e0 --- /dev/null +++ b/mail/encrypt_test.go @@ -0,0 +1,148 @@ +package mail + +import ( + "crypto/rand" + "io" + "strings" + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + // Set up a test key + encKey = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, encKey); err != nil { + t.Fatal(err) + } + encEnabled = true + + tests := []string{ + "Hello, world!", + "Sensitive email content with special chars: &", + "Short", + strings.Repeat("Long message ", 1000), + "", + } + + for _, plaintext := range tests { + encrypted, err := encrypt(plaintext) + if err != nil { + t.Fatalf("encrypt(%q): %v", plaintext[:min(len(plaintext), 20)], err) + } + + if plaintext != "" && !strings.HasPrefix(encrypted, encPrefix) { + t.Fatalf("encrypted text should have prefix %q", encPrefix) + } + + if plaintext != "" && encrypted == plaintext { + t.Fatal("encrypted text should differ from plaintext") + } + + decrypted, err := decrypt(encrypted) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + + if decrypted != plaintext { + t.Fatalf("roundtrip failed: got %q, want %q", decrypted[:min(len(decrypted), 50)], plaintext[:min(len(plaintext), 50)]) + } + } +} + +func TestEncryptMessage(t *testing.T) { + encKey = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, encKey); err != nil { + t.Fatal(err) + } + encEnabled = true + + m := &Message{ + ID: "123", + From: "alice", + FromID: "alice-id", + To: "bob", + ToID: "bob-id", + Subject: "Secret meeting", + Body: "Meet me at the park", + } + + if err := encryptMessage(m); err != nil { + t.Fatal(err) + } + + // ID, From, FromID should NOT be encrypted + if strings.HasPrefix(m.ID, encPrefix) { + t.Error("ID should not be encrypted") + } + if strings.HasPrefix(m.From, encPrefix) { + t.Error("From should not be encrypted") + } + if strings.HasPrefix(m.FromID, encPrefix) { + t.Error("FromID should not be encrypted") + } + + // To, ToID, Subject, Body SHOULD be encrypted + if !strings.HasPrefix(m.Subject, encPrefix) { + t.Error("Subject should be encrypted") + } + if !strings.HasPrefix(m.Body, encPrefix) { + t.Error("Body should be encrypted") + } + if !strings.HasPrefix(m.To, encPrefix) { + t.Error("To should be encrypted") + } + if !strings.HasPrefix(m.ToID, encPrefix) { + t.Error("ToID should be encrypted") + } + + // Decrypt and verify + if err := decryptMessage(m); err != nil { + t.Fatal(err) + } + + if m.Subject != "Secret meeting" { + t.Errorf("Subject = %q, want %q", m.Subject, "Secret meeting") + } + if m.Body != "Meet me at the park" { + t.Errorf("Body = %q, want %q", m.Body, "Meet me at the park") + } + if m.To != "bob" { + t.Errorf("To = %q, want %q", m.To, "bob") + } + if m.ToID != "bob-id" { + t.Errorf("ToID = %q, want %q", m.ToID, "bob-id") + } +} + +func TestDecryptPlaintextPassthrough(t *testing.T) { + encKey = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, encKey); err != nil { + t.Fatal(err) + } + encEnabled = true + + // Pre-encryption messages (no enc: prefix) should pass through unchanged + m := &Message{ + Subject: "Old unencrypted subject", + Body: "Old unencrypted body", + To: "charlie", + ToID: "charlie-id", + } + + if err := decryptMessage(m); err != nil { + t.Fatal(err) + } + + if m.Subject != "Old unencrypted subject" { + t.Error("Plaintext subject should pass through unchanged") + } + if m.Body != "Old unencrypted body" { + t.Error("Plaintext body should pass through unchanged") + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/mail/mail.go b/mail/mail.go index 7a748f0c..f3c8e4a5 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -68,7 +68,8 @@ type Message struct { // Load messages from disk // Load messages from disk and configure SMTP/DKIM func Load() { - // Loaded + // Initialize encryption + initEncryption() b, err := data.LoadFile("mail.json") if err != nil { @@ -80,6 +81,13 @@ func Load() { } else { app.Log("mail", "Loaded %d messages", len(messages)) + // Decrypt messages loaded from disk + for _, m := range messages { + if err := decryptMessage(m); err != nil { + app.Log("mail", "WARNING: Failed to decrypt message %s: %v", m.ID, err) + } + } + // Fix threading for any messages with broken chains fixThreading() @@ -247,7 +255,19 @@ func addMessageToInbox(inbox *Inbox, msg *Message, userID string) { // Save messages to disk (caller must hold mutex) func save() error { - b, err := json.Marshal(messages) + // Make copies with encrypted fields for storage + encrypted := make([]*Message, len(messages)) + for i, m := range messages { + cp := *m + if err := encryptMessage(&cp); err != nil { + app.Log("mail", "WARNING: Failed to encrypt message %s: %v", m.ID, err) + encrypted[i] = m // fall back to unencrypted + continue + } + encrypted[i] = &cp + } + + b, err := json.Marshal(encrypted) if err != nil { return err } From 521c84dba8221efe06c5ee99be994d8889b02999 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 11:42:08 +0000 Subject: [PATCH 2/2] Update credits: daily allowance 20->100, chat cost 3->5 https://claude.ai/code/session_01GRGLA9yj7BpqKiyi6xFwnm --- README.md | 2 +- docs/ENVIRONMENT_VARIABLES.md | 4 ++-- docs/MCP.md | 6 +++--- docs/SYSTEM_DESIGN.md | 2 +- docs/VISION.md | 2 +- docs/WALLET_AND_CREDITS.md | 10 +++++----- docs/WHITEPAPER.md | 2 +- internal/api/api.go | 2 +- wallet/wallet.go | 4 ++-- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a4e1eb93..c568811b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ See [API docs](https://mu.xyz/api) · [MCP docs](docs/MCP.md) ## Pricing -Browsing is included. AI features use credits — 20/day with every account, then pay as you go from 1p per query. +Browsing is included. AI features use credits — 100/day with every account, then pay as you go from 1p per query. - **Card** — Top up via Stripe. 1 credit = 1p. - **Crypto** — AI agents pay per-request with USDC via [x402](https://x402.org). No account needed. diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 3fac8744..8f7ef1eb 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -123,7 +123,7 @@ export DONATION_URL="https://gocardless.com/your-donation-link" ```bash # Daily AI queries per account (default: 10) -export DAILY_QUOTA="10" +export DAILY_QUOTA="100" # Credit costs per operation (default values shown) export CREDIT_COST_NEWS="1" # News search (1p) @@ -190,7 +190,7 @@ export MAIL_SELECTOR="default" | `X402_ASSETS` | `USDC,EURC` | Accepted tokens (comma-separated symbols) | | `X402_FACILITATOR_URL` | `https://x402.org/facilitator` | x402 facilitator endpoint | | `X402_NETWORK` | `eip155:8453` | Blockchain network for x402 payments | -| `DAILY_QUOTA` | `10` | Daily AI queries per account | +| `DAILY_QUOTA` | `100` | Daily AI queries per account | | `CREDIT_COST_NEWS` | `1` | Credits per news search | | `CREDIT_COST_VIDEO` | `2` | Credits per video search | | `CREDIT_COST_VIDEO_WATCH` | `0` | Credits per video watch (free by default) | diff --git a/docs/MCP.md b/docs/MCP.md index ec6dd7fd..237c3d17 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -120,7 +120,7 @@ Both return a session token. Use it in subsequent requests: Authorization: Bearer SESSION_TOKEN ``` -Every account includes **20 credits per day** and can top up with a card via Stripe. +Every account includes **100 credits per day** and can top up with a card via Stripe. ## Available Tools @@ -128,7 +128,7 @@ Every account includes **20 credits per day** and can top up with a card via Str |------|-------------|-------------| | `login` | Log in and get session token | Free | | `signup` | Create account and get session token | Free | -| `chat` | Chat with AI assistant | 3 credits | +| `chat` | Chat with AI assistant | 5 credits | | `news` | Read the latest news feed | Free | | `news_search` | Search for news articles | 1 credit | | `blog_list` | Get all blog posts | Free | @@ -210,7 +210,7 @@ curl -X POST https://mu.xyz/mcp \ | Auth header | `X-PAYMENT` | `Authorization: Bearer` | | Payment model | Per request | Pre-paid credits | | Currency | USDC | GBP | -| Daily allowance | No | 10 queries/day | +| Daily allowance | No | 100 queries/day | | Best for | Autonomous agents | Human users, MCP clients | ## Self-Hosting diff --git a/docs/SYSTEM_DESIGN.md b/docs/SYSTEM_DESIGN.md index 07fd2ffd..98fba659 100644 --- a/docs/SYSTEM_DESIGN.md +++ b/docs/SYSTEM_DESIGN.md @@ -233,7 +233,7 @@ Live financial data. Credit-based usage metering. -- **Pay as you go** — 20 credits/day included, then 1 credit = 1p +- **Pay as you go** — 100 credits/day included, then 1 credit = 1p - **Stripe payments** - Card top-up - **Quota enforcement** - Integrated with API and agent - **Transaction tracking** - Usage history diff --git a/docs/VISION.md b/docs/VISION.md index 738f89e3..2e3a8c4e 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -60,7 +60,7 @@ AI agents can pay per-request with USDC through the [x402 protocol](https://x402 ## Pricing - **Browsing included** — news, blogs, videos, markets -- **20 credits per day** — covers search, chat, and AI features +- **100 credits per day** — covers search, chat, and AI features - **Pay as you go** — 1 credit = 1p, top up via card - **Crypto** — AI agents pay per-request via [x402](https://x402.org) - **Self-host** — run your own instance, no restrictions diff --git a/docs/WALLET_AND_CREDITS.md b/docs/WALLET_AND_CREDITS.md index 886f4532..10f0175d 100644 --- a/docs/WALLET_AND_CREDITS.md +++ b/docs/WALLET_AND_CREDITS.md @@ -6,7 +6,7 @@ Mu is a tool, not a destination. Like Google Search in 2000 — you arrive with Credits are a straightforward way to pay for what you use. No dark patterns, no pressure to upgrade, no "unlimited" tiers that incentivize us to maximize your engagement. -- **Daily allowance**: 10 AI queries/day — enough for casual utility use +- **Daily allowance**: 100 AI queries/day — enough for daily use - **Pay-as-you-go**: Top up with a card or pay per-request with crypto - **Self-host**: Run your own instance for free, forever @@ -23,7 +23,7 @@ We charge because LLMs and APIs cost money. Here's our actual cost breakdown — ### Daily Allowance -Every account includes **10 AI queries per day**: +Every account includes **100 credits per day**: - Resets at midnight UTC - Covers news search, video search, and chat AI queries - No payment required @@ -38,7 +38,7 @@ This should be enough if you're using Mu as a utility. If you need more, pay-as- | News Summary | 1 credit (1p) | AI-generated summary | | Video Search | 2 credits (2p) | YouTube API cost | | Video Watch | Free | No value added over YouTube | -| Chat AI Query | 3 credits (3p) | LLM inference cost | +| Chat AI Query | 5 credits (5p) | LLM inference cost | | Chat Room | 1 credit (1p) | Room creation | | Places Search | 5 credits (5p) | Google Places API cost | | Places Nearby | 2 credits (2p) | Google Places API cost | @@ -53,7 +53,7 @@ This should be enough if you're using Mu as a utility. If you need more, pay-as- | User Type | Daily Allowance | Credits | Notes | |-----------|------------|---------|-------| | Guest | 0 | N/A | Must register | -| Registered | 10 queries | Pay-as-you-go | When daily allowance used | +| Registered | 100 credits | Pay-as-you-go | When daily allowance used | | Admin | Unlimited | Not needed | Site administrators | ## Why No "Unlimited" Tier? @@ -216,7 +216,7 @@ X402_NETWORK="eip155:8453" X402_ASSET="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # Quota (optional - these are defaults) -DAILY_QUOTA="10" +DAILY_QUOTA="100" CREDIT_COST_NEWS="1" CREDIT_COST_NEWS_SUMMARY="1" CREDIT_COST_VIDEO="2" diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md index 70ca5a55..c05812fb 100644 --- a/docs/WHITEPAPER.md +++ b/docs/WHITEPAPER.md @@ -138,7 +138,7 @@ Read-only operations — browsing news feeds, reading blog posts, watching video ### 3.3 Daily Quota -Each account includes a daily allocation of twenty queries, resetting at midnight UTC. This quota is sufficient for casual utility use. When the daily quota is exhausted, subsequent operations consume credits from the user's wallet. This model ensures accessibility while covering infrastructure costs for heavy usage. +Each account includes a daily allocation of one hundred queries, resetting at midnight UTC. This quota is sufficient for casual utility use. When the daily quota is exhausted, subsequent operations consume credits from the user's wallet. This model ensures accessibility while covering infrastructure costs for heavy usage. ### 3.4 Incentive Alignment diff --git a/internal/api/api.go b/internal/api/api.go index 85e43bc5..02d85b72 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -783,7 +783,7 @@ func init() { Name: "MCP Server", Path: "/mcp", Method: "POST", - Description: "Model Context Protocol server for AI tool integration. Supports initialize, tools/list, tools/call, and ping methods. Tools include chat, news, blog, video, mail, search, wallet, weather, places, markets, reminder, login, and signup. Metered tools (chat: 3 credits, news_search: 1 credit, video_search: 2 credits, mail_send: 4 credits, weather_forecast: 1 credit + optional 1 credit for pollen data) use the same wallet credit system as the REST API. 20 queries/day included.", + Description: "Model Context Protocol server for AI tool integration. Supports initialize, tools/list, tools/call, and ping methods. Tools include chat, news, blog, video, mail, search, wallet, weather, places, markets, reminder, login, and signup. Metered tools (chat: 5 credits, news_search: 1 credit, video_search: 2 credits, mail_send: 4 credits, weather_forecast: 1 credit + optional 1 credit for pollen data) use the same wallet credit system as the REST API. 100 credits/day included.", Params: []*Param{ { Name: "jsonrpc", diff --git a/wallet/wallet.go b/wallet/wallet.go index c8a70d05..1bc0f9b0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -20,7 +20,7 @@ import ( var ( CostNewsSearch = getEnvInt("CREDIT_COST_NEWS", 1) CostVideoSearch = getEnvInt("CREDIT_COST_VIDEO", 2) - CostChatQuery = getEnvInt("CREDIT_COST_CHAT", 3) + CostChatQuery = getEnvInt("CREDIT_COST_CHAT", 5) CostBlogCreate = getEnvInt("CREDIT_COST_BLOG_CREATE", 1) CostMailSend = getEnvInt("CREDIT_COST_MAIL", 1) // Internal mail send CostExternalEmail = getEnvInt("CREDIT_COST_EMAIL", 4) // External email (SMTP delivery cost) @@ -35,7 +35,7 @@ var ( CostSocialSearch = getEnvInt("CREDIT_COST_SOCIAL", 1) CostAppBuild = getEnvInt("CREDIT_COST_APP_BUILD", 100) CostAppEdit = getEnvInt("CREDIT_COST_APP_EDIT", 50) - DailyQuota = getEnvInt("DAILY_QUOTA", getEnvInt("FREE_DAILY_QUOTA", 20)) + DailyQuota = getEnvInt("DAILY_QUOTA", getEnvInt("FREE_DAILY_QUOTA", 100)) ) // PaymentsEnabled returns true if payments are configured