diff --git a/.gitignore b/.gitignore index 10f5ae20..40210674 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ desktop/hookinstall/atterm-hook # wails dev project-local data dir (config/recovery/db/cache/logs) .atterm-dev/ +.claude/ diff --git a/internal/e2eeclient/client.go b/internal/e2eeclient/client.go index 892d6d57..7e85d520 100644 --- a/internal/e2eeclient/client.go +++ b/internal/e2eeclient/client.go @@ -2,12 +2,11 @@ // register, log in, and unlock their account_key against an atterm-relay // running the OPAQUE-based E2EE auth flow added in M1a/M1b. // -// The SDK owns the cryptographic plumbing: OPAQUE protocol round-trips -// (via github.com/bytemare/opaque), Argon2id derivation of the wrap key -// from the user's password, and XChaCha20-Poly1305 AEAD wrap/unwrap of -// the per-account account_key. Wire-format helpers stay in -// internal/relay/opaque_auth.go; this package speaks JSON to the same -// endpoints. +// The SDK owns the OPAQUE protocol round-trips (via +// github.com/bytemare/opaque) and speaks JSON to the relay's +// /api/opaque/* endpoints. Wire-format helpers live in +// internal/opaquesuite; the account_key wrap/unwrap crypto (Argon2id +// derivation + XChaCha20-Poly1305 AEAD) lives in internal/e2eecrypto. // // The relay never sees plaintext password, plaintext account_key, or the // wrap key. See docs/superpowers/specs/2026-06-15-relay-e2ee-design.md §4. @@ -24,45 +23,15 @@ import ( "net/http" "strings" + "github.com/attson/atterm/internal/e2eecrypto" "github.com/attson/atterm/internal/opaquesuite" "github.com/bytemare/opaque" - "golang.org/x/crypto/argon2" - "golang.org/x/crypto/chacha20poly1305" ) // Server identity bound into the AKE transcript. Shared with the relay server // and the browser WASM client via internal/opaquesuite. const serverIdentity = opaquesuite.ServerIdentity -// Argon2id parameters used to derive the account_key wrap key from the -// user's password. Tuned for laptop CPUs; mobile may want lower memory. -// The relay echoes these back in kdf_params at login so a future -// rotation of parameters survives a password change on a single device. -type KDFParams struct { - Alg string `json:"alg"` // always "argon2id" in v1 - MemKiB uint32 `json:"m"` // memory in KiB - Time uint32 `json:"t"` // iterations - Threads uint8 `json:"p"` // parallelism -} - -// DefaultKDFParams returns the v1 baseline parameters: 64 MiB memory, -// 3 iterations, 1 thread. -func DefaultKDFParams() KDFParams { - return KDFParams{ - Alg: "argon2id", - MemKiB: 64 * 1024, - Time: 3, - Threads: 1, - } -} - -// Marshal renders kp as the JSON string the relay stores in -// user_account_key_wraps.kdf_params. -func (kp KDFParams) Marshal() string { - b, _ := json.Marshal(kp) - return string(b) -} - // AccountKeyWrap is the on-wire wrap envelope shared with the relay. The // struct lives in internal/opaquesuite so the relay and this SDK cannot // silently drift apart. Re-exported here as an alias so external callers can @@ -147,7 +116,7 @@ func (c *Client) Register(ctx context.Context, email, password, claimToken strin if _, err := rand.Read(accountKey); err != nil { return nil, fmt.Errorf("rand account_key: %w", err) } - wrap, err := wrapAccountKey(password, accountKey, DefaultKDFParams()) + wrap, err := e2eecrypto.WrapAccountKey(password, accountKey, e2eecrypto.DefaultKDFParams()) if err != nil { return nil, fmt.Errorf("wrap account_key: %w", err) } @@ -224,7 +193,7 @@ func (c *Client) Login(ctx context.Context, email, password string) (*LoginResul return nil, errors.New("login finalize: empty session_token or user_id") } - accountKey, err := unwrapAccountKey(password, finResp.AccountKeyWrap) + accountKey, err := e2eecrypto.UnwrapAccountKey(password, finResp.AccountKeyWrap) if err != nil { return nil, fmt.Errorf("unwrap account_key: %w", err) } @@ -248,53 +217,6 @@ func defaultOpaqueConfig() *opaque.Configuration { return opaquesuite.Config() } -// wrapAccountKey derives wrap_key = Argon2id(password, salt, params), -// generates a fresh 24-byte nonce, and seals account_key into the -// envelope with XChaCha20-Poly1305. -func wrapAccountKey(password string, accountKey []byte, kp KDFParams) (AccountKeyWrap, error) { - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return AccountKeyWrap{}, fmt.Errorf("rand salt: %w", err) - } - wrapKey := argon2.IDKey([]byte(password), salt, kp.Time, kp.MemKiB, kp.Threads, chacha20poly1305.KeySize) - aead, err := chacha20poly1305.NewX(wrapKey) - if err != nil { - return AccountKeyWrap{}, fmt.Errorf("aead: %w", err) - } - nonce := make([]byte, chacha20poly1305.NonceSizeX) - if _, err := rand.Read(nonce); err != nil { - return AccountKeyWrap{}, fmt.Errorf("rand nonce: %w", err) - } - ciphertext := aead.Seal(nil, nonce, accountKey, []byte("atterm-account-key-v1")) - return AccountKeyWrap{ - Method: "password", - Wrapped: ciphertext, - Nonce: nonce, - Salt: salt, - KDFParams: kp.Marshal(), - }, nil -} - -func unwrapAccountKey(password string, w AccountKeyWrap) ([]byte, error) { - var kp KDFParams - if err := json.Unmarshal([]byte(w.KDFParams), &kp); err != nil { - return nil, fmt.Errorf("kdf_params: %w", err) - } - if kp.Alg != "argon2id" { - return nil, fmt.Errorf("unsupported kdf alg: %q", kp.Alg) - } - wrapKey := argon2.IDKey([]byte(password), w.Salt, kp.Time, kp.MemKiB, kp.Threads, chacha20poly1305.KeySize) - aead, err := chacha20poly1305.NewX(wrapKey) - if err != nil { - return nil, fmt.Errorf("aead: %w", err) - } - plaintext, err := aead.Open(nil, w.Nonce, w.Wrapped, []byte("atterm-account-key-v1")) - if err != nil { - return nil, errors.New("e2eeclient: invalid password") - } - return plaintext, nil -} - func (c *Client) do(ctx context.Context, method, path string, body []byte, out any) error { base := strings.TrimRight(c.BaseURL, "/") req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(body)) diff --git a/internal/e2eeclient/client_test.go b/internal/e2eeclient/client_test.go index f846f0cb..67d4f2e8 100644 --- a/internal/e2eeclient/client_test.go +++ b/internal/e2eeclient/client_test.go @@ -26,35 +26,6 @@ func newRelay(t *testing.T) (*httptest.Server, *userstore.DBStore) { return ts, store } -func TestWrapUnwrap_RoundTrip(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) - } - wrap, err := wrapAccountKey("hunter2", key, DefaultKDFParams()) - if err != nil { - t.Fatalf("wrap: %v", err) - } - if len(wrap.Wrapped) == 0 || len(wrap.Nonce) != 24 || len(wrap.Salt) != 16 { - t.Fatalf("wrap envelope shape wrong: %+v", wrap) - } - got, err := unwrapAccountKey("hunter2", wrap) - if err != nil { - t.Fatalf("unwrap: %v", err) - } - if string(got) != string(key) { - t.Fatalf("round-trip mismatch") - } -} - -func TestUnwrap_WrongPassword(t *testing.T) { - key := make([]byte, 32) - wrap, _ := wrapAccountKey("hunter2", key, DefaultKDFParams()) - if _, err := unwrapAccountKey("not-the-password", wrap); err == nil { - t.Fatalf("expected error on wrong password, got nil") - } -} - func TestClient_RegisterAndLogin(t *testing.T) { ts, _ := newRelay(t) c := &Client{BaseURL: ts.URL} diff --git a/internal/e2eecrypto/accountkey.go b/internal/e2eecrypto/accountkey.go new file mode 100644 index 00000000..aa4bc0e1 --- /dev/null +++ b/internal/e2eecrypto/accountkey.go @@ -0,0 +1,102 @@ +package e2eecrypto + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + + "github.com/attson/atterm/internal/opaquesuite" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/chacha20poly1305" +) + +// accountKeyAAD is the additional-data string bound into the AEAD seal +// so a wrap envelope from a future protocol (e.g. an account_key_v2) +// cannot be opened by v1 code, even if the ciphertext bytes happen to +// line up. Any bump here is a one-way migration. +const accountKeyAAD = "atterm-account-key-v1" + +// KDFParams tunes Argon2id when deriving a wrap_key from the user's +// password. Tuned for laptop CPUs; mobile may want lower memory. The +// relay echoes these back in kdf_params at login so a future rotation +// of parameters survives a password change on a single device. +type KDFParams struct { + Alg string `json:"alg"` // always "argon2id" in v1 + MemKiB uint32 `json:"m"` // memory in KiB + Time uint32 `json:"t"` // iterations + Threads uint8 `json:"p"` // parallelism +} + +// DefaultKDFParams returns the v1 baseline parameters: 64 MiB memory, +// 3 iterations, 1 thread. +func DefaultKDFParams() KDFParams { + return KDFParams{ + Alg: "argon2id", + MemKiB: 64 * 1024, + Time: 3, + Threads: 1, + } +} + +// Marshal renders kp as the JSON string the relay stores in +// user_account_key_wraps.kdf_params. +func (kp KDFParams) Marshal() string { + b, _ := json.Marshal(kp) + return string(b) +} + +// WrapAccountKey derives wrap_key = Argon2id(password, salt, kp), +// generates a fresh 24-byte nonce, and seals accountKey into an +// AccountKeyWrap envelope using XChaCha20-Poly1305 with a versioned AAD. +// +// The account_key bytes never touch the relay in plaintext form; the +// relay only ever sees this envelope + the kdf_params echo needed for +// the next login on a fresh device. +func WrapAccountKey(password string, accountKey []byte, kp KDFParams) (opaquesuite.AccountKeyWrap, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return opaquesuite.AccountKeyWrap{}, fmt.Errorf("rand salt: %w", err) + } + wrapKey := argon2.IDKey([]byte(password), salt, kp.Time, kp.MemKiB, kp.Threads, chacha20poly1305.KeySize) + aead, err := chacha20poly1305.NewX(wrapKey) + if err != nil { + return opaquesuite.AccountKeyWrap{}, fmt.Errorf("aead: %w", err) + } + nonce := make([]byte, chacha20poly1305.NonceSizeX) + if _, err := rand.Read(nonce); err != nil { + return opaquesuite.AccountKeyWrap{}, fmt.Errorf("rand nonce: %w", err) + } + ciphertext := aead.Seal(nil, nonce, accountKey, []byte(accountKeyAAD)) + return opaquesuite.AccountKeyWrap{ + Method: "password", + Wrapped: ciphertext, + Nonce: nonce, + Salt: salt, + KDFParams: kp.Marshal(), + }, nil +} + +// UnwrapAccountKey recovers the raw account_key from a wrap envelope +// using the user's password. Returns the sentinel error message +// "e2eecrypto: invalid password" on AEAD open failure — callers rely on +// this to distinguish a wrong password from a transport-level fault. +func UnwrapAccountKey(password string, w opaquesuite.AccountKeyWrap) ([]byte, error) { + var kp KDFParams + if err := json.Unmarshal([]byte(w.KDFParams), &kp); err != nil { + return nil, fmt.Errorf("kdf_params: %w", err) + } + if kp.Alg != "argon2id" { + return nil, fmt.Errorf("unsupported kdf alg: %q", kp.Alg) + } + wrapKey := argon2.IDKey([]byte(password), w.Salt, kp.Time, kp.MemKiB, kp.Threads, chacha20poly1305.KeySize) + aead, err := chacha20poly1305.NewX(wrapKey) + if err != nil { + return nil, fmt.Errorf("aead: %w", err) + } + plaintext, err := aead.Open(nil, w.Nonce, w.Wrapped, []byte(accountKeyAAD)) + if err != nil { + return nil, errors.New("e2eecrypto: invalid password") + } + return plaintext, nil +} diff --git a/internal/e2eecrypto/accountkey_test.go b/internal/e2eecrypto/accountkey_test.go new file mode 100644 index 00000000..99180281 --- /dev/null +++ b/internal/e2eecrypto/accountkey_test.go @@ -0,0 +1,32 @@ +package e2eecrypto + +import "testing" + +func TestWrapUnwrap_RoundTrip(t *testing.T) { + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + wrap, err := WrapAccountKey("hunter2", key, DefaultKDFParams()) + if err != nil { + t.Fatalf("wrap: %v", err) + } + if len(wrap.Wrapped) == 0 || len(wrap.Nonce) != 24 || len(wrap.Salt) != 16 { + t.Fatalf("wrap envelope shape wrong: %+v", wrap) + } + got, err := UnwrapAccountKey("hunter2", wrap) + if err != nil { + t.Fatalf("unwrap: %v", err) + } + if string(got) != string(key) { + t.Fatalf("round-trip mismatch") + } +} + +func TestUnwrap_WrongPassword(t *testing.T) { + key := make([]byte, 32) + wrap, _ := WrapAccountKey("hunter2", key, DefaultKDFParams()) + if _, err := UnwrapAccountKey("not-the-password", wrap); err == nil { + t.Fatalf("expected error on wrong password, got nil") + } +} diff --git a/internal/e2eecrypto/sessionkey.go b/internal/e2eecrypto/sessionkey.go index 6f0cc8f0..8e78a296 100644 --- a/internal/e2eecrypto/sessionkey.go +++ b/internal/e2eecrypto/sessionkey.go @@ -3,9 +3,18 @@ // frames) so the relay carries only opaque bytes. See the design spec // at docs/superpowers/specs/2026-06-15-relay-e2ee-design.md §§5-7. // -// Account-level key (account_key) handling lives in internal/e2eeclient; -// this package consumes account_key + session_uuid to derive per-session -// AEAD keys and to seal/open frame payloads. +// Split into three files by responsibility: +// - accountkey.go — Argon2id + XChaCha20-Poly1305 wrap/unwrap of the +// per-account account_key by the user's password (AccountKeyWrap +// envelope + KDFParams). +// - sessionkey.go — HKDF-SHA256 derivation of per-session AEAD keys +// from account_key + session_uuid. +// - envelope.go — the on-wire cipher_id + nonce + AEAD framing that +// seals/opens session-level payloads. +// +// The HTTP client that speaks OPAQUE to the relay (Register / Login / +// wire-format helpers) lives in internal/e2eeclient — it now imports +// this package for wrap/unwrap rather than re-implementing it. package e2eecrypto import (