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:
Fredrik Johansson
2026-06-22 21:44:48 +02:00
parent f1498697b6
commit b47f659b7d
7 changed files with 488 additions and 8 deletions

View File

@@ -21,10 +21,12 @@ import (
"time"
"filippo.io/edwards25519"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/nacl/box"
"golang.org/x/crypto/nacl/secretbox"
"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
}
// 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).
func (id *Identity) PeerID() proto.PeerID {
return proto.PeerID(hex.EncodeToString(id.PublicKey))
@@ -263,6 +280,127 @@ func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) {
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 ────────────────────────────────────────────────────
// Session holds the symmetric key for an established peer session.

View File

@@ -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.
func newTestIdentity(t *testing.T) *Identity {
t.Helper()