0xMiden / 0xMiden/wallet

[Discussion] Bread Wallet Backend

未关闭
#850 5 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看
主要语言
TypeScript
星标
5
派生
28
平均合并
9 小时 50 分钟
30 天内合并 PR
123

描述

This issue specifies the backend service for Miden Wallet. The service has two functions:

1. **Encrypted backup.** The wallet backs up account metadata, contacts and preferences. Each account, each contact and the preferences object is one encrypted entry. The wallet encrypts every entry on the device. The server stores opaque entries. The server cannot read them. The service is for mainnet only.
2. **On-ramp and off-ramp support.** The wallet shows a third-party fiat widget. Providers require the integrator to sign or stamp the wallet address into the widget session with a merchant secret. The server holds the provider secrets and performs that step. The design is provider-agnostic. Each provider is one implementation behind the same endpoint shape.

## 2. Architecture

### 2.1 Overview

```
Device A Backend Device B
-------- ------- --------
mnemonic mnemonic
| |
v v
sync key (secp256k1) --- login: nonce + signature ---> profileId <--- sync key (secp256k1)
| |
v v
storageKey = sha256(sign("miden-sync:storage-key:" + profileId)) same storageKey
| |
v v
AES-GCM(scrypt(storageKey)) --- PUT feature/hashedKey {entry} ------> per-profile store
|
GET feature ---------------------------> decrypt with same key
```

Every device with the same mnemonic derives the same sync key and the same storage key. The server only stores what a device sends and returns it unchanged. The server has no key.

### 2.2 Frontend: key derivation

**Step 1. Sync key.** The wallet derives one sync key from the mnemonic. The derivation path is its own SLIP-0010 branch. It must not share a branch with `deriveClientSeed` or with the cold-seed deriver. This prevents replay of a sync signature as an account or guardian signature.

- Curve: secp256k1. Signatures are compact, hex, over the SHA-256 of the message bytes.
- The key is deterministic. Every device with the same mnemonic derives the same key.
- Hardware-only wallets also hold a mnemonic, so the sync key is available on all platforms.
- One mnemonic gives one profile. An imported second mnemonic gives a second profile.

**Step 2. Login.** The wallet asks the server for a nonce, signs a fixed-format message with the sync key, and receives a JWT plus a `profileId`. See section 3.

**Step 3. Storage key.** The wallet derives the storage key on the device and never sends it:

```
message = "miden-sync:storage-key:" + profileId
signature = secp256k1_sign(sync_private_key, sha256(message))
storageKey = sha256(signature)
```

The wallet caches the storage key for the unlock session.

**Step 4. Encryption key.** The wallet stretches the storage key one time per unlock:

```
encKey = scrypt(password = storageKey, salt = FIXED_SALT, N = 2^17, r = 8, p = 1, dkLen = 32)
```

The salt is fixed on purpose. This lets the client run scrypt one time per unlock and cache `encKey`. MetaMask started with a random salt per entry and later migrated to a fixed salt. This spec starts with the fixed salt.

### 2.3 Frontend: the entries that are encrypted

Entries are grouped into **features**. A feature is a namespace with one entry per item.

| Feature | Entry key, before hashing | One entry per |
|---|---|---|
| `accounts` | `":"`, wallet type and `hdIndex`, for example `guardian:3` | HD account |
| `contacts` | contact address | contact |
| `preferences` | `"global"` | profile |

The server accepts only these feature names. Add a feature to the server allowlist before the client uses it.

Each entry's plaintext is one JSON object. Field names are short to keep the ciphertext small.

**accounts**

```json
{ "v": 1, "i": 3, "t": "guardian", "n": "Savings", "nlu": 1757400000000, "as": "ecdsa", "ge": "https://guardian.example.com", "dt": null }
```

| Field | Long name | Meaning |
|---|---|---|
| `v` | version | Schema version of the entry. `1` in this spec. |
| `i` | index | `hdIndex`, the derivation index of the account |
| `t` | type | `WalletType`: `onchain`, `offchain` or `guardian` |
| `n` | name | Display name |
| `nlu` | name last updated | Unix ms of the last rename. Latest wins on merge. |
| `as` | auth scheme | `falcon` or `ecdsa`. Fixed at creation. The account id depends on it. Without it a peer must probe each scheme on chain, as the mnemonic restore does today. |
| `ge` | guardian endpoint | Guardian operator URL. Guardian accounts only. Lets a peer recover from the correct Guardian without a probe. |
| `dt` | deleted at | Unix ms when the user hid the account, or `null` when live. Tombstone. |

Not in the account entry, on purpose:

- **Account id.** The peer derives it from the mnemonic with `i`, `t` and `as`. No on-chain identifier exists in the entry, even in encrypted form.
- **Imported accounts** (`hdIndex: -1`). Their keys are not derivable from the mnemonic. If the wallet syncs them, the entry must carry the secret key inside the ciphertext under a new `t: "imported"` type. That is a decision for the client team, the server does not change.

**contacts**

```json
{ "v": 1, "a": "mtst1qz...", "n": "Alice", "pub": true, "aa": 1757000000000, "lu": 1757000000000, "dt": null }
```

| Field | Long name | Meaning |
|---|---|---|
| `v` | version | Schema version |
| `a` | address | Contact address, bech32 |
| `n` | name | Display name |
| `pub` | public | `isPublic` of the contact |
| `aa` | added at | Unix ms when the contact was created |
| `lu` | last updated | Unix ms of the last edit. Latest wins on merge. |
| `dt` | deleted at | Tombstone |

**preferences**

```json
{ "v": 1, "theme": "dark", "locale": "en", "analytics": false, "lu": 1757000000000 }
```

| Field | Meaning |
|---|---|
| `v` | Schema version |
| `theme` | `light`, `dark` or `system` |
| `locale` | Locale code |
| `analytics` | Analytics opt-in |
| `lu` | Last updated. Latest wins on merge, as one unit. |

**Estimated entry size.** About 100 bytes plaintext per account or contact and 70 for preferences. Encryption adds a fixed 44 bytes per entry and base64 adds one third on the wire. A heavy profile with 10 accounts and 50 contacts is about 9 KiB on the server.

### 2.4 Frontend: encryption

The wallet serializes the entry to a string and encrypts it:

```
nonce = random(12 bytes)
ciphertext = AES-256-GCM(encKey, nonce, plaintext)
```

The wallet sends the result as a flat **encrypted entry** object. This is the object that crosses the wire in both directions:

```json
{
"version": 1,
"kdf": "scrypt",
"N": 131072,
"r": 8,
"p": 1,
"dkLen": 32,
"saltLen": 16,
"blob": ""
}
```

| Field | Meaning |
|---|---|
| `version` | Format version of the encrypted entry. `1` in this spec. |
| `kdf` | Key derivation function. Only `scrypt` in v1. |
| `N` | scrypt CPU and memory cost |
| `r` | scrypt block size |
| `p` | scrypt parallelism |
| `dkLen` | Derived key length in bytes |
| `saltLen` | How many leading bytes of the decoded `blob` are the salt |
| `blob` | Salt, then nonce, then ciphertext with the GCM tag, concatenated. Base64 on the wire only. |

The object carries the scrypt parameters so a future client can decrypt an old entry after the parameters change. Base64 exists only for transport. The server decodes it on write and stores bytes.

### 2.5 Frontend: what identifies an entry

The wallet hides the entry key. It hashes the key with the storage key before it builds the path:

```
hashedKey = sha256(entryKey + storageKey)
path = "/"
```

The server sees the feature name in clear text and a 32-byte hash for the entry. The hash cannot be reversed and cannot be linked across profiles because the storage key differs per profile. Because the client can compute the hash for any key it knows, it can fetch or delete one account by `":"` without downloading the feature.

### 2.6 Backend: how an entry is stored

The server keeps one store per `profileId`. One record is one encrypted entry, addressed by `(feature, hashedKey)`. The server stores each field of the encrypted entry as its own typed column and the ciphertext as bytes. Nothing is stored as a JSON string.

| Column | Type | Value | Set by |
|---|---|---|---|
| `feature` | text | `accounts`, `contacts` or `preferences` | client |
| `hashedKey` | bytes, 32 | decoded from the hex in the path | client |
| `version` | integer | encrypted entry format version | client |
| `kdf` | text | `scrypt` | client |
| `N`, `r`, `p`, `dkLen`, `saltLen` | integer | scrypt parameters and salt length | client |
| `blob` | bytes | salt, nonce and ciphertext, decoded from base64 | client |
| `updatedAt` | integer | Unix ms of the last write | server |

Example record:

| feature | hashedKey | version | kdf | N | r | p | dkLen | saltLen | blob | updatedAt |
|---|---|---|---|---|---|---|---|---|---|---|
| `accounts` | `3f9a…c21e` | 1 | `scrypt` | 131072 | 8 | 1 | 32 | 16 | 156 bytes | 1757400000000 |

The server validates on write:

1. `feature` is on the allowlist.
2. `version` is a known version.
3. `kdf` is on the allowlist.
4. `N` is 2^14 to 2^20, `r` is 1 to 16, `p` is 1 to 4, `dkLen` is 16 or 32, `saltLen` is 16 to 32.
5. `blob` decodes from base64, its length is at least `saltLen + 12 + 16`, and under the cap in 2.7.

The server never looks inside `blob`. `updatedAt` is server time and exists for ETags and retention, not for merge. Merge uses the timestamps inside the plaintext, which the server cannot read. A write to an existing `(feature, hashedKey)` replaces it. There is no per-entry concurrency check, the timestamps in the plaintext resolve a race on the client.

On read, the server re-encodes `blob` as base64 and returns the same flat object the client sent.

Alongside the entries, a profile store holds the profile's sync public key, its creation time, its last-seen time, and the open login nonces.

### 2.7 Limits

| Limit | Value |
|---|---|
| Max `blob` size, decoded | 4 KiB |
| Max entries per feature per profile | 1000 |
| Max batch size | 100 entries |
| Max request body | 512 KiB |

The server rejects a request that exceeds a limit with `413`.

## 3. Authentication API

All requests use `https://api..miden.wallet` as the base URL. All bodies are JSON.

### 3.1 `POST /v1/auth/nonce`

Request:

```json
{ "publicKey": "" }
```

Response `200`:

```json
{ "nonce": "<32 random bytes, hex>", "expiresAt": 1757400300000 }
```

The nonce is valid for 5 minutes. The nonce is single use.

### 3.2 `POST /v1/auth/login`

Request:

```json
{
"publicKey": "",
"message": "miden-sync:login::",
"signature": ""
}
```

The server does these checks in order:

1. The nonce exists and is not expired and is not used.
2. The message has the exact format above with that nonce and that public key.
3. The signature verifies for the public key.

Response `200`:

```json
{
"accessToken": "",
"expiresIn": 3600,
"profileId": ""
}
```

The JWT is signed with HS256 with the `JWT_SIGNING_KEY` secret. Claims: `sub` = `profileId`, `pk` = public key, `iat`, `exp`. The client refreshes when 90% of `expiresIn` has passed.

### 3.3 Authorization

Every other endpoint requires:

```
Authorization: Bearer
```

The server reads `profileId` from the token. The client cannot address another profile.

## 4. Storage API

`{feature}` is one of the allowlisted names in 2.3. `{hashedKey}` is a 64-character lowercase hex string. In every body below, `` stands for the flat encrypted entry object from 2.4.

### 4.1 `GET /v1/storage/{feature}`

Returns all entries of the feature for the profile.

Request headers, optional:

```
If-None-Match:
```

Response `200`:

```json
{
"entries": [
{ "hashedKey": "", "entry": , "updatedAt": 1757400000000 }
]
}
```

Response headers:

```
ETag: ""
```

The `ETag` is a hash over every `(hashedKey, updatedAt)` pair in the feature. Response `304` with no body when it equals `If-None-Match`.

### 4.2 `GET /v1/storage/{feature}/{hashedKey}`

Response `200`:

```json
{ "hashedKey": "", "entry": , "updatedAt": 1757400000000 }
```

Response `404` when the entry does not exist.

### 4.3 `PUT /v1/storage/{feature}/{hashedKey}`

Upsert one entry.

Request:

```json
{ "entry": }
```

Response `200`:

```json
{ "hashedKey": "", "updatedAt": 1757400000000 }
```

### 4.4 `PUT /v1/storage/{feature}`

Batch upsert. All entries are written in one transaction.

Request:

```json
{ "entries": { "": , "": } }
```

Response `200`:

```json
{ "written": 2, "updatedAt": 1757400000000 }
```

### 4.5 `POST /v1/storage/{feature}/delete`

Batch delete. All entries are deleted in one transaction.

Request:

```json
{ "hashedKeys": ["", ""] }
```

Response `200`:

```json
{ "deleted": 2 }
```

### 4.6 `DELETE /v1/storage/{feature}/{hashedKey}`

Delete one entry. Response `204`.

### 4.7 `DELETE /v1/storage/{feature}`

Delete all entries of the feature. Response `204`.

### 4.8 `DELETE /v1/profile`

Delete the profile and all its features. The wallet exposes this as "Delete my backup" in settings. Response `204`. A later login creates a new empty profile with the same `profileId`.

## 5. On-ramp API

### 5.1 Provider model

Fiat providers differ in how the integrator binds a wallet address to a widget session. The common shapes are:

- The client builds a widget URL and the integrator returns an HMAC over it with a merchant secret.
- The integrator creates a session with the provider's API using a merchant secret and returns a session id or token that the widget consumes.
- The integrator signs a JSON payload with a merchant secret and the widget receives the signed payload.

All three reduce to: the client sends provider-specific input, the server validates it, stamps the profile's addresses with a merchant secret, and returns provider-specific output. The server treats the input and output as opaque to the shared layer.

Each provider is one module that implements this interface:

| Function | Responsibility |
|---|---|
| `validate(input)` | Reject input that names a host, merchant key, or parameter the provider module does not expect. Extract the wallet addresses the session will pay out to. |
| `stamp(input, secret)` | Produce the provider-specific output, for example a signature, a session token, or a signed payload. |

The shared layer owns authentication, the address attestation in 5.3, rate limiting, and secret lookup. Each provider has one secret, `ONRAMP__SECRET`, and a module never reads a secret that is not its own.

Provider ids are lowercase slugs. The list of enabled providers per environment is a plain variable, `ONRAMP_PROVIDERS`.

### 5.2 `POST /v1/onramp/{provider}/session`

Requires a bearer token. `{provider}` must be in `ONRAMP_PROVIDERS`, otherwise `404`.

Request:

```json
{
"flow": "buy",
"input": { "...": "provider-specific" },
"attestations": [
{ "address": "", "signature": "" }
]
}
```

| Field | Meaning |
|---|---|
| `flow` | `buy` for on-ramp, `sell` for off-ramp |
| `input` | Provider-specific input. The shared layer passes it to the provider module unchanged. |
| `attestations` | One entry per wallet address the session pays out to or from. See 5.3. |

The shared layer does these checks in order:

1. `flow` is `buy` or `sell`.
2. The provider module's `validate(input)` accepts the input and returns the set of addresses in it.
3. Every address is a valid Miden bech32 address.
4. Every address has a valid attestation, see 5.3. Every attestation names an address that is in the input.
5. The provider module's `stamp(input, secret)` succeeds.

Response `200`:

```json
{ "output": { "...": "provider-specific" } }
```

Response `403` when a check fails. The body says which check failed. Response `502` when the provider's API is unreachable or returns an error, for providers whose `stamp` calls out.

### 5.3 Address attestation

The server cannot read the encrypted entries, so it cannot know which addresses belong to a profile. The client proves ownership by signing each address with the sync key:

```
message = "miden-sync:onramp-address:" + profileId + ":" + address
attestation = secp256k1_sign(sync_private_key, sha256(message))
```

The server verifies each attestation against the `pk` claim of the token. Every address the provider module extracted from the input must have a valid attestation. This prevents a caller from creating a session that pays out to an address the caller does not control under our merchant credentials. The check is the same for every provider.

## 6. Estimated sizes

**Per profile.** About 100 bytes plaintext per account or contact and 70 for preferences, plus 44 bytes of encryption overhead per entry.

| Profile | Contents | Stored |
|---|---|---|
| New | 1 account | ~0.2 KiB |
| Typical | 3 accounts, 10 contacts | ~2 KiB |
| Heavy | 10 accounts, 50 contacts | ~9 KiB |

**Per fleet.** 100k users is under 1 GiB. 1M users is under 10 GiB.

**Requests.** The estimate assumes 10 unlocks per day, one `GET` per feature per unlock, a batch `PUT` on one unlock in ten, and one on-ramp session per user per month. Most `GET` calls answer `304` with no body because of `If-None-Match`.

| Users | Requests per month | Average requests per second |
|---|---|---|
| 10k | ~12M | ~5 |
| 100k | ~120M | ~46 |
| 1M | ~1.2B | ~460 |

## 7. Cloudflare Workers vs AWS

Two deployment shapes were compared: Cloudflare Workers with Durable Objects, and a conventional AWS deployment with containers on ECS or EC2, an application load balancer, and RDS Postgres. AWS Lambda is not in the comparison. Prices are public list prices at the time of writing and are estimates. Verify before budgeting.

### 7.1 Monthly cost

| Users | Cloudflare Workers | AWS, minimal | AWS, production |
|---|---|---|---|
| 10k | ~$7 | ~$60 | ~$200 |
| 100k | ~$55 | ~$80 | ~$250 |
| 1M | ~$540 | ~$250 | ~$500 |

How the numbers are built:

- **Cloudflare.** $5 plan. Workers requests at $0.30 per million past 10M. Durable Object requests at $0.15 per million past 1M. Storage and rows are inside the included tiers at every row of the table. No egress charge.
- **AWS, minimal.** One small ARM instance, one single-AZ RDS instance, one load balancer, NAT gateway, secrets manager. This shape has no redundancy and is only for staging.
- **AWS, production.** Two instances across two AZs, multi-AZ RDS, load balancer with LCUs, NAT gateway, egress at $0.09 per GB, backups. At 1M users the egress line alone is about $100 since every `GET` that is not a `304` returns data.

While picking the provider we need to keep in mind that it way easier to manager workers vs aws where you have to scale manually create replicas etc.

cc @Dominik1999 @BrianSeong99

贡献指南

打开贡献指南

调研方向

This is a design specification for a new backend service with encrypted storage and fiat on-ramp integration. Start by reading the architecture overview and API definitions in the issue. The work involves implementing authentication (nonce, JWT), encrypted entry storage with scrypt/AES-GCM, and provider-agnostic on-ramp signing. Look at existing wallet code to understand the codebase structure. 'Done' means a deployed service matching the spec.

由索引模型根据 Issue 内容生成。

评估

技术栈
aws, docker, nginx, nodejs, postgresql, typescript
领域
api, authentication, backend, cloud, databases, security
Issue 类型
功能
难度
5/5
预计耗时
一周以上
活跃度
活跃
描述清晰度
描述清楚
新手友好度
30/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。