Skip to content

Commit 099d3e4

Browse files
asimclaude
andauthored
Encrypt mail at rest, update credit system (#541)
* Encrypt mail at rest — subject, body, recipient, headers 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 * Update credits: daily allowance 20->100, chat cost 3->5 https://claude.ai/code/session_01GRGLA9yj7BpqKiyi6xFwnm --------- Co-authored-by: Claude <[email protected]>
1 parent f145830 commit 099d3e4

13 files changed

Lines changed: 418 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ See [API docs](https://mu.xyz/api) · [MCP docs](docs/MCP.md)
5555

5656
## Pricing
5757

58-
Browsing is included. AI features use credits — 20/day with every account, then pay as you go from 1p per query.
58+
Browsing is included. AI features use credits — 100/day with every account, then pay as you go from 1p per query.
5959

6060
- **Card** — Top up via Stripe. 1 credit = 1p.
6161
- **Crypto** — AI agents pay per-request with USDC via [x402](https://x402.org). No account needed.

docs/ENVIRONMENT_VARIABLES.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export DONATION_URL="https://gocardless.com/your-donation-link"
123123

124124
```bash
125125
# Daily AI queries per account (default: 10)
126-
export DAILY_QUOTA="10"
126+
export DAILY_QUOTA="100"
127127

128128
# Credit costs per operation (default values shown)
129129
export CREDIT_COST_NEWS="1" # News search (1p)
@@ -190,7 +190,7 @@ export MAIL_SELECTOR="default"
190190
| `X402_ASSETS` | `USDC,EURC` | Accepted tokens (comma-separated symbols) |
191191
| `X402_FACILITATOR_URL` | `https://x402.org/facilitator` | x402 facilitator endpoint |
192192
| `X402_NETWORK` | `eip155:8453` | Blockchain network for x402 payments |
193-
| `DAILY_QUOTA` | `10` | Daily AI queries per account |
193+
| `DAILY_QUOTA` | `100` | Daily AI queries per account |
194194
| `CREDIT_COST_NEWS` | `1` | Credits per news search |
195195
| `CREDIT_COST_VIDEO` | `2` | Credits per video search |
196196
| `CREDIT_COST_VIDEO_WATCH` | `0` | Credits per video watch (free by default) |

docs/MCP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,15 @@ Both return a session token. Use it in subsequent requests:
120120
Authorization: Bearer SESSION_TOKEN
121121
```
122122

123-
Every account includes **20 credits per day** and can top up with a card via Stripe.
123+
Every account includes **100 credits per day** and can top up with a card via Stripe.
124124

125125
## Available Tools
126126

127127
| Tool | Description | Credit Cost |
128128
|------|-------------|-------------|
129129
| `login` | Log in and get session token | Free |
130130
| `signup` | Create account and get session token | Free |
131-
| `chat` | Chat with AI assistant | 3 credits |
131+
| `chat` | Chat with AI assistant | 5 credits |
132132
| `news` | Read the latest news feed | Free |
133133
| `news_search` | Search for news articles | 1 credit |
134134
| `blog_list` | Get all blog posts | Free |
@@ -210,7 +210,7 @@ curl -X POST https://mu.xyz/mcp \
210210
| Auth header | `X-PAYMENT` | `Authorization: Bearer` |
211211
| Payment model | Per request | Pre-paid credits |
212212
| Currency | USDC | GBP |
213-
| Daily allowance | No | 10 queries/day |
213+
| Daily allowance | No | 100 queries/day |
214214
| Best for | Autonomous agents | Human users, MCP clients |
215215

216216
## Self-Hosting

docs/SYSTEM_DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ Live financial data.
233233

234234
Credit-based usage metering.
235235

236-
- **Pay as you go**20 credits/day included, then 1 credit = 1p
236+
- **Pay as you go**100 credits/day included, then 1 credit = 1p
237237
- **Stripe payments** - Card top-up
238238
- **Quota enforcement** - Integrated with API and agent
239239
- **Transaction tracking** - Usage history

docs/VISION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ AI agents can pay per-request with USDC through the [x402 protocol](https://x402
6060
## Pricing
6161

6262
- **Browsing included** — news, blogs, videos, markets
63-
- **20 credits per day** — covers search, chat, and AI features
63+
- **100 credits per day** — covers search, chat, and AI features
6464
- **Pay as you go** — 1 credit = 1p, top up via card
6565
- **Crypto** — AI agents pay per-request via [x402](https://x402.org)
6666
- **Self-host** — run your own instance, no restrictions

docs/WALLET_AND_CREDITS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Mu is a tool, not a destination. Like Google Search in 2000 — you arrive with
66

77
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.
88

9-
- **Daily allowance**: 10 AI queries/day — enough for casual utility use
9+
- **Daily allowance**: 100 AI queries/day — enough for daily use
1010
- **Pay-as-you-go**: Top up with a card or pay per-request with crypto
1111
- **Self-host**: Run your own instance for free, forever
1212

@@ -23,7 +23,7 @@ We charge because LLMs and APIs cost money. Here's our actual cost breakdown —
2323

2424
### Daily Allowance
2525

26-
Every account includes **10 AI queries per day**:
26+
Every account includes **100 credits per day**:
2727
- Resets at midnight UTC
2828
- Covers news search, video search, and chat AI queries
2929
- 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-
3838
| News Summary | 1 credit (1p) | AI-generated summary |
3939
| Video Search | 2 credits (2p) | YouTube API cost |
4040
| Video Watch | Free | No value added over YouTube |
41-
| Chat AI Query | 3 credits (3p) | LLM inference cost |
41+
| Chat AI Query | 5 credits (5p) | LLM inference cost |
4242
| Chat Room | 1 credit (1p) | Room creation |
4343
| Places Search | 5 credits (5p) | Google Places API cost |
4444
| 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-
5353
| User Type | Daily Allowance | Credits | Notes |
5454
|-----------|------------|---------|-------|
5555
| Guest | 0 | N/A | Must register |
56-
| Registered | 10 queries | Pay-as-you-go | When daily allowance used |
56+
| Registered | 100 credits | Pay-as-you-go | When daily allowance used |
5757
| Admin | Unlimited | Not needed | Site administrators |
5858

5959
## Why No "Unlimited" Tier?
@@ -216,7 +216,7 @@ X402_NETWORK="eip155:8453"
216216
X402_ASSET="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
217217

218218
# Quota (optional - these are defaults)
219-
DAILY_QUOTA="10"
219+
DAILY_QUOTA="100"
220220
CREDIT_COST_NEWS="1"
221221
CREDIT_COST_NEWS_SUMMARY="1"
222222
CREDIT_COST_VIDEO="2"

docs/WHITEPAPER.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Read-only operations — browsing news feeds, reading blog posts, watching video
138138

139139
### 3.3 Daily Quota
140140

141-
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.
141+
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.
142142

143143
### 3.4 Incentive Alignment
144144

internal/api/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,7 @@ func init() {
783783
Name: "MCP Server",
784784
Path: "/mcp",
785785
Method: "POST",
786-
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.",
786+
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.",
787787
Params: []*Param{
788788
{
789789
Name: "jsonrpc",

internal/data/data.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func SaveFile(key, val string) error {
4343
file := filepath.Join(path, key)
4444
// Create all parent directories including subdirectories in key
4545
os.MkdirAll(filepath.Dir(file), 0700)
46-
os.WriteFile(file, []byte(val), 0644)
46+
os.WriteFile(file, []byte(val), 0600)
4747
return nil
4848
}
4949

mail/encrypt.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package mail
2+
3+
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/rand"
7+
"encoding/base64"
8+
"errors"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"strings"
13+
"sync"
14+
15+
"mu/internal/app"
16+
)
17+
18+
const encPrefix = "enc:" // prefix to identify encrypted fields
19+
20+
var (
21+
encKey []byte
22+
encOnce sync.Once
23+
encEnabled bool
24+
)
25+
26+
// initEncryption loads or generates the encryption key
27+
func initEncryption() {
28+
encOnce.Do(func() {
29+
// Try env var first
30+
if keyStr := os.Getenv("MU_ENCRYPTION_KEY"); keyStr != "" {
31+
decoded, err := base64.StdEncoding.DecodeString(keyStr)
32+
if err == nil && len(decoded) == 32 {
33+
encKey = decoded
34+
encEnabled = true
35+
app.Log("mail", "Encryption enabled (from MU_ENCRYPTION_KEY)")
36+
return
37+
}
38+
app.Log("mail", "WARNING: MU_ENCRYPTION_KEY invalid (must be 32 bytes, base64-encoded)")
39+
}
40+
41+
// Try key file
42+
keyDir := os.ExpandEnv("$HOME/.mu/keys")
43+
keyFile := filepath.Join(keyDir, "encryption.key")
44+
45+
if data, err := os.ReadFile(keyFile); err == nil {
46+
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
47+
if err == nil && len(decoded) == 32 {
48+
encKey = decoded
49+
encEnabled = true
50+
app.Log("mail", "Encryption enabled (from %s)", keyFile)
51+
return
52+
}
53+
}
54+
55+
// Generate new key
56+
key := make([]byte, 32)
57+
if _, err := io.ReadFull(rand.Reader, key); err != nil {
58+
app.Log("mail", "WARNING: Failed to generate encryption key: %v", err)
59+
return
60+
}
61+
62+
os.MkdirAll(keyDir, 0700)
63+
encoded := base64.StdEncoding.EncodeToString(key)
64+
if err := os.WriteFile(keyFile, []byte(encoded), 0600); err != nil {
65+
app.Log("mail", "WARNING: Failed to save encryption key: %v", err)
66+
return
67+
}
68+
69+
encKey = key
70+
encEnabled = true
71+
app.Log("mail", "Encryption enabled (new key generated at %s)", keyFile)
72+
})
73+
}
74+
75+
// encrypt encrypts plaintext using AES-256-GCM
76+
func encrypt(plaintext string) (string, error) {
77+
if !encEnabled || plaintext == "" {
78+
return plaintext, nil
79+
}
80+
81+
block, err := aes.NewCipher(encKey)
82+
if err != nil {
83+
return "", err
84+
}
85+
86+
gcm, err := cipher.NewGCM(block)
87+
if err != nil {
88+
return "", err
89+
}
90+
91+
nonce := make([]byte, gcm.NonceSize())
92+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
93+
return "", err
94+
}
95+
96+
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
97+
return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
98+
}
99+
100+
// decrypt decrypts ciphertext using AES-256-GCM
101+
func decrypt(ciphertext string) (string, error) {
102+
if !encEnabled || ciphertext == "" {
103+
return ciphertext, nil
104+
}
105+
106+
// Not encrypted — return as-is (handles pre-encryption messages)
107+
if !strings.HasPrefix(ciphertext, encPrefix) {
108+
return ciphertext, nil
109+
}
110+
111+
data, err := base64.StdEncoding.DecodeString(ciphertext[len(encPrefix):])
112+
if err != nil {
113+
return ciphertext, err
114+
}
115+
116+
block, err := aes.NewCipher(encKey)
117+
if err != nil {
118+
return "", err
119+
}
120+
121+
gcm, err := cipher.NewGCM(block)
122+
if err != nil {
123+
return "", err
124+
}
125+
126+
nonceSize := gcm.NonceSize()
127+
if len(data) < nonceSize {
128+
return "", errors.New("ciphertext too short")
129+
}
130+
131+
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
132+
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
133+
if err != nil {
134+
return "", err
135+
}
136+
137+
return string(plaintext), nil
138+
}
139+
140+
// encryptMessage encrypts sensitive fields in a message for storage
141+
func encryptMessage(m *Message) error {
142+
if !encEnabled {
143+
return nil
144+
}
145+
146+
var err error
147+
148+
if m.Subject != "" && !strings.HasPrefix(m.Subject, encPrefix) {
149+
m.Subject, err = encrypt(m.Subject)
150+
if err != nil {
151+
return err
152+
}
153+
}
154+
155+
if m.Body != "" && !strings.HasPrefix(m.Body, encPrefix) {
156+
m.Body, err = encrypt(m.Body)
157+
if err != nil {
158+
return err
159+
}
160+
}
161+
162+
if m.To != "" && !strings.HasPrefix(m.To, encPrefix) {
163+
m.To, err = encrypt(m.To)
164+
if err != nil {
165+
return err
166+
}
167+
}
168+
169+
if m.ToID != "" && !strings.HasPrefix(m.ToID, encPrefix) {
170+
m.ToID, err = encrypt(m.ToID)
171+
if err != nil {
172+
return err
173+
}
174+
}
175+
176+
if m.RawHeaders != "" && !strings.HasPrefix(m.RawHeaders, encPrefix) {
177+
m.RawHeaders, err = encrypt(m.RawHeaders)
178+
if err != nil {
179+
return err
180+
}
181+
}
182+
183+
return nil
184+
}
185+
186+
// decryptMessage decrypts sensitive fields in a message after loading
187+
func decryptMessage(m *Message) error {
188+
if !encEnabled {
189+
return nil
190+
}
191+
192+
var err error
193+
194+
if strings.HasPrefix(m.Subject, encPrefix) {
195+
m.Subject, err = decrypt(m.Subject)
196+
if err != nil {
197+
return err
198+
}
199+
}
200+
201+
if strings.HasPrefix(m.Body, encPrefix) {
202+
m.Body, err = decrypt(m.Body)
203+
if err != nil {
204+
return err
205+
}
206+
}
207+
208+
if strings.HasPrefix(m.To, encPrefix) {
209+
m.To, err = decrypt(m.To)
210+
if err != nil {
211+
return err
212+
}
213+
}
214+
215+
if strings.HasPrefix(m.ToID, encPrefix) {
216+
m.ToID, err = decrypt(m.ToID)
217+
if err != nil {
218+
return err
219+
}
220+
}
221+
222+
if strings.HasPrefix(m.RawHeaders, encPrefix) {
223+
m.RawHeaders, err = decrypt(m.RawHeaders)
224+
if err != nil {
225+
return err
226+
}
227+
}
228+
229+
return nil
230+
}

0 commit comments

Comments
 (0)