feat: identity export/import (yaw-key-backup-1)
Portable encrypted identity backup compatible with the sister project's format — argon2id KDF + nacl secretbox, peer ID visible in plaintext for pre-import verification. - crypto: ExportIdentity, ImportIdentity, SaveIdentity - proto: export_identity / import_identity IPC commands + events - ipc: export_identity returns encrypted blob; import_identity validates and decrypts (read-only — on-disk write requires daemon restart) - netmgr: MasterIdentity() accessor - daemon: --import-identity / --import-passphrase flags write identity.json and exit, enabling scripted account migration - tests: roundtrip and wrong-passphrase rejection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
237
WEBUI.md
Normal file
237
WEBUI.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Web UI — Design Notes
|
||||||
|
|
||||||
|
## Invite URL pattern
|
||||||
|
|
||||||
|
The invite URL path segment is the truncated network hash — already computed
|
||||||
|
by the daemon as the first 8 bytes of `SHA-256("yaw2-net:" + name)`.
|
||||||
|
|
||||||
|
```
|
||||||
|
https://waste.dev.xplwd.com/78aa5621196bf200/
|
||||||
|
```
|
||||||
|
|
||||||
|
The web UI reads `window.location.pathname`, pre-fills:
|
||||||
|
- WebSocket URL: `wss://waste.dev.xplwd.com/ws` (or `/<hash>/signal`)
|
||||||
|
- Network ID: extracted from the path — no separate field needed
|
||||||
|
|
||||||
|
### Fragment vs path
|
||||||
|
|
||||||
|
Using `/#78aa5621196bf200` instead of a path means the network ID never
|
||||||
|
reaches the anchor's access logs. The anchor cannot distinguish an invite
|
||||||
|
visit from a regular visit. Slightly more private — worth deciding before
|
||||||
|
building the React routing layer.
|
||||||
|
|
||||||
|
### Per-network signal path
|
||||||
|
|
||||||
|
Moving the WebSocket endpoint to `/<nethash>/signal` enables nginx to route
|
||||||
|
`/<hash>/signal` to the anchor and `/<hash>/` to a CDN or static host.
|
||||||
|
The anchor never has to serve HTML. Keeps concerns cleanly separated.
|
||||||
|
|
||||||
|
### Anchor changes needed (if it serves the UI)
|
||||||
|
|
||||||
|
Right now `cmd/anchor` only handles WebSocket on `/ws`. To support the
|
||||||
|
invite URL pattern it would also need to serve static files (the React
|
||||||
|
bundle) for any other path, and optionally move the WebSocket endpoint to
|
||||||
|
`/<nethash>/signal` for per-network isolation.
|
||||||
|
|
||||||
|
### Open decision: does the anchor serve the UI at all?
|
||||||
|
|
||||||
|
Two options — decide before building the React routing layer:
|
||||||
|
|
||||||
|
**A) Anchor is purely a signaling relay**
|
||||||
|
Static files served from a CDN or separate host. Anchor only handles
|
||||||
|
WebSocket. Simpler, easier to scale, no Go HTTP file serving code.
|
||||||
|
nginx routes `/<hash>/signal` → anchor, everything else → static host.
|
||||||
|
|
||||||
|
**B) Anchor serves the React bundle**
|
||||||
|
Single deployment, one domain. Anchor handles both WebSocket and static
|
||||||
|
file serving. More convenient but mixes concerns and means deploying a
|
||||||
|
new anchor binary every time the UI changes.
|
||||||
|
|
||||||
|
### Invite expiry
|
||||||
|
|
||||||
|
Encode a TTL in the invite (e.g. 72h). The anchor rejects join attempts on
|
||||||
|
expired tokens. Permanent invites are a liability — a leaked link stays open
|
||||||
|
forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & safety — URL invites / anchor
|
||||||
|
|
||||||
|
- **Use the network hash in the URL, not the name.** A base64'd name is
|
||||||
|
trivially reversible. The hash reveals nothing about the network or its
|
||||||
|
members.
|
||||||
|
- **Link previews will betray you.** iMessage, Slack, WhatsApp etc.
|
||||||
|
pre-fetch `https://` links for preview generation. That pre-fetch hits
|
||||||
|
the anchor and effectively probes the network. Serve a generic preview
|
||||||
|
(no network info in og:tags), or use a `#fragment` — fragments never
|
||||||
|
leave the browser.
|
||||||
|
- **The anchor is a metadata oracle.** It can't read content but sees who
|
||||||
|
connects, when, and how often. Log as little as possible — no IPs beyond
|
||||||
|
what's needed to route, no persistent connection records. stderr only,
|
||||||
|
no disk writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & safety — identity / contact cards
|
||||||
|
|
||||||
|
- **Private key never leaves the device.** In Tauri, store in OS keychain
|
||||||
|
via Tauri's secure storage — not localStorage.
|
||||||
|
- **Make public vs private explicit in the UI.** The card is a public
|
||||||
|
address. Never show the private key, not even "for backup."
|
||||||
|
- **Aliases are not authenticated — say so.** Anyone can claim any alias,
|
||||||
|
including yours. The peer ID is the real identity. Make the short 4-group
|
||||||
|
hex ID glanceable so users build the habit of verifying it.
|
||||||
|
- **Contact cards expose your anchor URL.** If Alice shares her card and
|
||||||
|
later wants to cut someone off, they still know her anchor. Consider
|
||||||
|
supporting anchor rotation or anchor-less cards for LAN scenarios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & safety — trust model
|
||||||
|
|
||||||
|
- **Default-deny inbound connections.** Unknown peers get `bye` before any
|
||||||
|
data flows. The pending prompt should show the peer ID, not just the
|
||||||
|
claimed alias.
|
||||||
|
- **Mutual acceptance before any messages.** Don't buffer messages from
|
||||||
|
unaccepted peers. Nothing stored until both sides have accepted.
|
||||||
|
- **Removal is immediate.** Close the DataChannel, remove from accepted
|
||||||
|
list, send `bye`. Don't wait for reconnect.
|
||||||
|
- **Block list separate from accept list.** Removing a contact means
|
||||||
|
"not accepted." Blocking should actively refuse — important if they
|
||||||
|
still know the anchor URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tauri / local daemon
|
||||||
|
|
||||||
|
- **IPC binds localhost only.** Already the case — keep it. In Tauri,
|
||||||
|
use a random port chosen at startup (written to a local socket file)
|
||||||
|
rather than a fixed port.
|
||||||
|
- **No auto-join on startup.** Invites are processed only when the UI is
|
||||||
|
open and the user confirms.
|
||||||
|
- **Clear data means clear data.** Uninstall / "delete account" must wipe
|
||||||
|
the SQLite store, the identity key, and all cached peer data. Don't rely
|
||||||
|
on the OS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding flow (contact card model)
|
||||||
|
|
||||||
|
Inspired by the Friends app pattern:
|
||||||
|
|
||||||
|
1. App generates an identity on first launch.
|
||||||
|
2. User picks a nickname — advisory only, not authenticated.
|
||||||
|
3. User copies their contact card (`yaw:<peerid>?n=alias&a=wss://anchor`).
|
||||||
|
UI makes clear: *this is your public address, not a password.*
|
||||||
|
4. User pastes a friend's card into an Accept box, optionally sets a local
|
||||||
|
nickname for them.
|
||||||
|
5. Trust is mutual — connection completes only once both sides have
|
||||||
|
accepted each other's card.
|
||||||
|
6. Pending inbound connections show peer ID + claimed alias; user
|
||||||
|
approves or blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daemon changes needed
|
||||||
|
|
||||||
|
- `accepted_peers` table in SQLite
|
||||||
|
- `accept_peer` / `remove_peer` / `block_peer` IPC commands
|
||||||
|
- After hello verification: check allowlist — send `bye` and close if
|
||||||
|
not accepted; emit `pending_peer` event if unknown
|
||||||
|
- Network concept may simplify to "your contact list" for the personal
|
||||||
|
use case; named group networks remain as a separate concept for group
|
||||||
|
chats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## End-game stack
|
||||||
|
|
||||||
|
- **React + Tauri** standalone desktop application
|
||||||
|
- Go daemon runs as a Tauri sidecar
|
||||||
|
- React talks to daemon via existing IPC (local TCP, bridged through
|
||||||
|
Tauri's invoke API)
|
||||||
|
- Anchor stays as a lightweight relay — no content, minimal metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anchor host as onboarding hub
|
||||||
|
|
||||||
|
The anchor host serves a web UI regardless of which client the user ends
|
||||||
|
up on. It is the universal entry point:
|
||||||
|
|
||||||
|
- New user follows an invite link → lands on the web UI → creates an
|
||||||
|
identity → joins the network
|
||||||
|
- Existing TUI user wants to switch to Tauri client → exports identity
|
||||||
|
from current client → imports into new one
|
||||||
|
- Mobile user with no install → uses the web UI directly
|
||||||
|
|
||||||
|
nginx serves the static React bundle at `/`. The anchor handles WebSocket
|
||||||
|
only. No Go HTTP file serving needed — clean separation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity portability
|
||||||
|
|
||||||
|
The identity (Ed25519 keypair + alias) is the one thing that ties all
|
||||||
|
clients together. It must be portable, stable, and independently
|
||||||
|
documented.
|
||||||
|
|
||||||
|
### Portable identity format
|
||||||
|
|
||||||
|
Use the same format as the sister project for interoperability:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"yaw": "yaw-key-backup-1",
|
||||||
|
"id": "<hex peer id>",
|
||||||
|
"alg": "argon2id-secretbox",
|
||||||
|
"ops": 2,
|
||||||
|
"mem": 67108864,
|
||||||
|
"salt": "<base64>",
|
||||||
|
"nonce": "<base64>",
|
||||||
|
"ct": "<base64 ciphertext>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `yaw` is the format version tag
|
||||||
|
- `id` is the public peer ID (hex) — visible without decrypting, useful
|
||||||
|
for confirming you're importing the right identity
|
||||||
|
- `alg` signals argon2id KDF + nacl secretbox encryption
|
||||||
|
- `ops`/`mem` are argon2id parameters
|
||||||
|
- `ct` unseals to the raw Ed25519 private key + alias
|
||||||
|
|
||||||
|
The passphrase is the only secret — the file itself is safe to copy
|
||||||
|
anywhere. Same format means credentials backed up via the sister project
|
||||||
|
can be imported directly into waste and vice versa.
|
||||||
|
|
||||||
|
### Migration flows
|
||||||
|
|
||||||
|
**TUI → Tauri client**
|
||||||
|
1. `waste-daemon export-identity --out identity.enc` (or IPC command)
|
||||||
|
2. Copy file to new machine, import in Tauri onboarding screen
|
||||||
|
|
||||||
|
**Web UI → any client**
|
||||||
|
1. Web UI shows "export your identity" → downloads the encrypted file
|
||||||
|
2. User imports into TUI or Tauri with passphrase
|
||||||
|
|
||||||
|
**New user via web UI, later installs Tauri**
|
||||||
|
1. Creates identity in browser (stored in secure browser storage)
|
||||||
|
2. Exports encrypted file at any point
|
||||||
|
3. Imports into Tauri — same peer ID, same contacts, history syncs
|
||||||
|
via peers (not server)
|
||||||
|
|
||||||
|
**QR code transfer (mobile / LAN)**
|
||||||
|
- Encrypted identity blob encoded as QR
|
||||||
|
- Scan on new device, enter passphrase
|
||||||
|
- No file transfer needed
|
||||||
|
|
||||||
|
### Open decisions
|
||||||
|
|
||||||
|
- Does the web UI generate and hold the private key in-browser, or does
|
||||||
|
it proxy through a server-side session? (In-browser is safer — key
|
||||||
|
never leaves the device even via the anchor host.)
|
||||||
|
- Browser storage for the key: IndexedDB + WebCrypto non-extractable key,
|
||||||
|
or just the encrypted blob with passphrase re-entry on each session?
|
||||||
|
- History portability: messages are local-only today. Cross-client sync
|
||||||
|
would require either exporting the SQLite file or accepting that history
|
||||||
|
starts fresh on each new client.
|
||||||
@@ -4,6 +4,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -20,10 +21,35 @@ func main() {
|
|||||||
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws")
|
||||||
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
shareDir := flag.String("share-dir", "", "directory to share with peers on the network")
|
||||||
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup")
|
||||||
|
importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import")
|
||||||
|
importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
dir := expandHome(*dataDir)
|
dir := expandHome(*dataDir)
|
||||||
|
|
||||||
|
// --import-identity: decrypt backup and write identity.json, then exit.
|
||||||
|
if *importBackup != "" {
|
||||||
|
if *importPassword == "" {
|
||||||
|
log.Fatal("--import-passphrase is required with --import-identity")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(*importBackup)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("import-identity: read file: %v", err)
|
||||||
|
}
|
||||||
|
imported, err := crypto.ImportIdentity(raw, *importPassword)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("import-identity: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
log.Fatalf("import-identity: mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := crypto.SaveIdentity(dir, imported); err != nil {
|
||||||
|
log.Fatalf("import-identity: save: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("identity imported: %s\n", imported.PeerID())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id, err := crypto.LoadOrCreate(dir, *alias)
|
id, err := crypto.LoadOrCreate(dir, *alias)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("identity: %v", err)
|
log.Fatalf("identity: %v", err)
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/edwards25519"
|
"filippo.io/edwards25519"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/chacha20poly1305"
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
"golang.org/x/crypto/curve25519"
|
"golang.org/x/crypto/curve25519"
|
||||||
"golang.org/x/crypto/hkdf"
|
"golang.org/x/crypto/hkdf"
|
||||||
"golang.org/x/crypto/nacl/box"
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
"golang.org/x/crypto/nacl/secretbox"
|
||||||
|
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
)
|
)
|
||||||
@@ -99,6 +101,21 @@ func LoadOrCreate(dataDir, alias string) (*Identity, error) {
|
|||||||
return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil
|
return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveIdentity writes the identity to dataDir/identity.json, overwriting any existing file.
|
||||||
|
// Used by --import-identity to commit an imported backup to disk.
|
||||||
|
func SaveIdentity(dataDir string, id *Identity) error {
|
||||||
|
f := identityFile{
|
||||||
|
PrivateKeyB64: b64.EncodeToString(id.privateKey),
|
||||||
|
Alias: id.Alias,
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(f, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dataDir, "identity.json")
|
||||||
|
return os.WriteFile(path, raw, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2).
|
// PeerID returns the lowercase hex encoding of the 32-byte Ed25519 public key (YAW/2 §2).
|
||||||
func (id *Identity) PeerID() proto.PeerID {
|
func (id *Identity) PeerID() proto.PeerID {
|
||||||
return proto.PeerID(hex.EncodeToString(id.PublicKey))
|
return proto.PeerID(hex.EncodeToString(id.PublicKey))
|
||||||
@@ -263,6 +280,127 @@ func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Identity backup / restore ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// keyBackup is the on-disk / exportable format, matching the sister project's
|
||||||
|
// yaw-key-backup-1 schema for cross-app portability.
|
||||||
|
type keyBackup struct {
|
||||||
|
Yaw string `json:"yaw"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
Ops uint32 `json:"ops"`
|
||||||
|
Mem uint32 `json:"mem"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Ct string `json:"ct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupPlaintext is what gets sealed inside the backup.
|
||||||
|
type backupPlaintext struct {
|
||||||
|
PrivateKey string `json:"priv"` // base64url Ed25519 private key (64 bytes)
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
backupArgonOps = 2
|
||||||
|
backupArgonMem = 64 * 1024 * 1024 // 64 MiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportIdentity encrypts the identity with passphrase and returns a JSON
|
||||||
|
// backup blob in the yaw-key-backup-1 format.
|
||||||
|
func ExportIdentity(id *Identity, passphrase string) ([]byte, error) {
|
||||||
|
var salt [16]byte
|
||||||
|
if _, err := rand.Read(salt[:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("generating salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := deriveKey(passphrase, salt[:])
|
||||||
|
|
||||||
|
pt, err := json.Marshal(backupPlaintext{
|
||||||
|
PrivateKey: base64.StdEncoding.EncodeToString(id.privateKey),
|
||||||
|
Alias: id.Alias,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonce [24]byte
|
||||||
|
if _, err := rand.Read(nonce[:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("generating nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := secretbox.Seal(nil, pt, &nonce, &key)
|
||||||
|
|
||||||
|
backup := keyBackup{
|
||||||
|
Yaw: "yaw-key-backup-1",
|
||||||
|
ID: string(id.PeerID()),
|
||||||
|
Alg: "argon2id-secretbox",
|
||||||
|
Ops: backupArgonOps,
|
||||||
|
Mem: backupArgonMem,
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(salt[:]),
|
||||||
|
Nonce: base64.StdEncoding.EncodeToString(nonce[:]),
|
||||||
|
Ct: base64.StdEncoding.EncodeToString(ct),
|
||||||
|
}
|
||||||
|
return json.MarshalIndent(backup, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportIdentity decrypts a yaw-key-backup-1 blob and returns the Identity.
|
||||||
|
func ImportIdentity(data []byte, passphrase string) (*Identity, error) {
|
||||||
|
var backup keyBackup
|
||||||
|
if err := json.Unmarshal(data, &backup); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing backup: %w", err)
|
||||||
|
}
|
||||||
|
if backup.Yaw != "yaw-key-backup-1" {
|
||||||
|
return nil, fmt.Errorf("unsupported backup format %q", backup.Yaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(backup.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding salt: %w", err)
|
||||||
|
}
|
||||||
|
nonceBytes, err := base64.StdEncoding.DecodeString(backup.Nonce)
|
||||||
|
if err != nil || len(nonceBytes) != 24 {
|
||||||
|
return nil, fmt.Errorf("decoding nonce: %w", err)
|
||||||
|
}
|
||||||
|
ct, err := base64.StdEncoding.DecodeString(backup.Ct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding ciphertext: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := deriveKey(passphrase, salt)
|
||||||
|
|
||||||
|
var nonce [24]byte
|
||||||
|
copy(nonce[:], nonceBytes)
|
||||||
|
pt, ok := secretbox.Open(nil, ct, &nonce, &key)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("decryption failed — wrong passphrase?")
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain backupPlaintext
|
||||||
|
if err := json.Unmarshal(pt, &plain); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing backup plaintext: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privBytes, err := base64.StdEncoding.DecodeString(plain.PrivateKey)
|
||||||
|
if err != nil || len(privBytes) != ed25519.PrivateKeySize {
|
||||||
|
return nil, fmt.Errorf("invalid private key in backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
priv := ed25519.PrivateKey(privBytes)
|
||||||
|
return &Identity{
|
||||||
|
privateKey: priv,
|
||||||
|
PublicKey: priv.Public().(ed25519.PublicKey),
|
||||||
|
Alias: plain.Alias,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveKey(passphrase string, salt []byte) [32]byte {
|
||||||
|
raw := argon2.IDKey([]byte(passphrase), salt, backupArgonOps, backupArgonMem/1024, 1, 32)
|
||||||
|
var key [32]byte
|
||||||
|
copy(key[:], raw)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
// ── ChaCha20-Poly1305 AEAD ────────────────────────────────────────────────────
|
// ── ChaCha20-Poly1305 AEAD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Session holds the symmetric key for an established peer session.
|
// Session holds the symmetric key for an established peer session.
|
||||||
|
|||||||
@@ -60,6 +60,44 @@ func TestSignalingBoxTamperedFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportImportRoundtrip(t *testing.T) {
|
||||||
|
id := newTestIdentity(t)
|
||||||
|
passphrase := "correct horse battery staple"
|
||||||
|
|
||||||
|
blob, err := ExportIdentity(id, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportIdentity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported blob must contain the public peer ID in plaintext.
|
||||||
|
if !strings.Contains(string(blob), string(id.PeerID())) {
|
||||||
|
t.Fatal("exported blob does not contain peer ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
imported, err := ImportIdentity(blob, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportIdentity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if imported.PeerID() != id.PeerID() {
|
||||||
|
t.Fatalf("peer ID mismatch: got %s, want %s", imported.PeerID(), id.PeerID())
|
||||||
|
}
|
||||||
|
if imported.Alias != id.Alias {
|
||||||
|
t.Fatalf("alias mismatch: got %q, want %q", imported.Alias, id.Alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportWrongPassphraseFails(t *testing.T) {
|
||||||
|
id := newTestIdentity(t)
|
||||||
|
blob, err := ExportIdentity(id, "correct")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportIdentity: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ImportIdentity(blob, "wrong"); err == nil {
|
||||||
|
t.Fatal("expected error with wrong passphrase, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newTestIdentity creates a fresh in-memory identity for testing.
|
// newTestIdentity creates a fresh in-memory identity for testing.
|
||||||
func newTestIdentity(t *testing.T) *Identity {
|
func newTestIdentity(t *testing.T) *Identity {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/waste-go/internal/crypto"
|
||||||
"github.com/waste-go/internal/invite"
|
"github.com/waste-go/internal/invite"
|
||||||
"github.com/waste-go/internal/netmgr"
|
"github.com/waste-go/internal/netmgr"
|
||||||
"github.com/waste-go/internal/proto"
|
"github.com/waste-go/internal/proto"
|
||||||
@@ -275,6 +276,36 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
|
|||||||
send(errMsg(fmt.Sprintf("send_file: %v", err)))
|
send(errMsg(fmt.Sprintf("send_file: %v", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case proto.CmdExportIdentity:
|
||||||
|
if cmd.Passphrase == "" {
|
||||||
|
send(errMsg("export_identity: passphrase is required"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blob, err := crypto.ExportIdentity(mgr.MasterIdentity(), cmd.Passphrase)
|
||||||
|
if err != nil {
|
||||||
|
send(errMsg(fmt.Sprintf("export_identity: %v", err)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
send(proto.IpcMessage{
|
||||||
|
Type: proto.EvtIdentityExported,
|
||||||
|
Backup: string(blob),
|
||||||
|
})
|
||||||
|
|
||||||
|
case proto.CmdImportIdentity:
|
||||||
|
if cmd.Passphrase == "" || cmd.Backup == "" {
|
||||||
|
send(errMsg("import_identity: passphrase and backup are required"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := crypto.ImportIdentity([]byte(cmd.Backup), cmd.Passphrase)
|
||||||
|
if err != nil {
|
||||||
|
send(errMsg(fmt.Sprintf("import_identity: %v", err)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Import is intentionally read-only here: returns the decrypted identity
|
||||||
|
// for the caller to verify before committing. Actual on-disk replacement
|
||||||
|
// requires a daemon restart with --import flag (see cmd/daemon).
|
||||||
|
send(proto.IpcMessage{Type: proto.EvtIdentityImported})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
send(errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
send(errMsg(fmt.Sprintf("unknown command: %s", cmd.Type)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ func (mgr *Manager) All() []*Network {
|
|||||||
// AnchorURL returns the configured anchor URL.
|
// AnchorURL returns the configured anchor URL.
|
||||||
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }
|
func (mgr *Manager) AnchorURL() string { return mgr.cfg.AnchorURL }
|
||||||
|
|
||||||
|
// MasterIdentity returns the master identity (not network-derived).
|
||||||
|
func (mgr *Manager) MasterIdentity() *crypto.Identity { return mgr.cfg.MasterIdentity }
|
||||||
|
|
||||||
// Subscribe returns a channel that receives tagged events from all networks.
|
// Subscribe returns a channel that receives tagged events from all networks.
|
||||||
func (mgr *Manager) Subscribe() <-chan proto.IpcMessage {
|
func (mgr *Manager) Subscribe() <-chan proto.IpcMessage {
|
||||||
ch := make(chan proto.IpcMessage, 128)
|
ch := make(chan proto.IpcMessage, 128)
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ const (
|
|||||||
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
|
CmdSetShareDir IpcMsgType = "set_share_dir" // set per-network share directory at runtime
|
||||||
CmdGenerateInvite IpcMsgType = "generate_invite"
|
CmdGenerateInvite IpcMsgType = "generate_invite"
|
||||||
CmdGetFileList IpcMsgType = "get_file_list"
|
CmdGetFileList IpcMsgType = "get_file_list"
|
||||||
|
CmdExportIdentity IpcMsgType = "export_identity" // returns encrypted backup blob
|
||||||
|
CmdImportIdentity IpcMsgType = "import_identity" // replaces identity from backup blob
|
||||||
|
|
||||||
// Events (daemon → UI)
|
// Events (daemon → UI)
|
||||||
EvtMessageReceived IpcMsgType = "message_received"
|
EvtMessageReceived IpcMsgType = "message_received"
|
||||||
@@ -235,6 +237,8 @@ const (
|
|||||||
EvtFileComplete IpcMsgType = "file_complete"
|
EvtFileComplete IpcMsgType = "file_complete"
|
||||||
EvtNetworkJoined IpcMsgType = "network_joined"
|
EvtNetworkJoined IpcMsgType = "network_joined"
|
||||||
EvtNetworkLeft IpcMsgType = "network_left"
|
EvtNetworkLeft IpcMsgType = "network_left"
|
||||||
|
EvtIdentityExported IpcMsgType = "identity_exported"
|
||||||
|
EvtIdentityImported IpcMsgType = "identity_imported"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
|
||||||
@@ -284,4 +288,7 @@ type IpcMessage struct {
|
|||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
InviteString string `json:"invite,omitempty"`
|
InviteString string `json:"invite,omitempty"`
|
||||||
Files []FileEntry `json:"files,omitempty"`
|
Files []FileEntry `json:"files,omitempty"`
|
||||||
|
// export_identity / import_identity
|
||||||
|
Passphrase string `json:"passphrase,omitempty"` // import only; never echoed back
|
||||||
|
Backup string `json:"backup,omitempty"` // JSON backup blob
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user