Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@
desktop/hookinstall/atterm-hook
# wails dev project-local data dir (config/recovery/db/cache/logs)
.atterm-dev/
.claude/
94 changes: 8 additions & 86 deletions internal/e2eeclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
Expand Down
29 changes: 0 additions & 29 deletions internal/e2eeclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
102 changes: 102 additions & 0 deletions internal/e2eecrypto/accountkey.go
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions internal/e2eecrypto/accountkey_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
15 changes: 12 additions & 3 deletions internal/e2eecrypto/sessionkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading