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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user