diff --git a/CHANGELOG.md b/CHANGELOG.md index 44de70f..2fa081c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG + +**v0.1.2 - 09-07-2026** + +- Passphrases and ChalResp hardening +- Improved import identity +- Fixed id removal & hw key delete gate + + **v0.1.1 - 09-05-2026** - Cross-platform keychain fix diff --git a/cloud/enckey_store.go b/cloud/enckey_store.go index a001da8..e402c58 100644 --- a/cloud/enckey_store.go +++ b/cloud/enckey_store.go @@ -106,7 +106,8 @@ func (s fileEncKeyStore) Save(email string, encKey []byte) error { if err != nil { return err } - ct, err := crypto.EncryptWithPassphrase(encKey, pass) + defer crypto.Zero(pass) + ct, err := crypto.EncryptWithPassphraseBytes(encKey, pass) if err != nil { return fmt.Errorf("encrypting cloud key: %w", err) } @@ -128,7 +129,8 @@ func (s fileEncKeyStore) Load(email string) ([]byte, error) { if err != nil { return nil, err } - key, err := crypto.DecryptWithPassphrase(ct, pass) + defer crypto.Zero(pass) + key, err := crypto.DecryptWithPassphraseBytes(ct, pass) if err != nil { return nil, fmt.Errorf("decrypting cloud key (wrong keystore passphrase?): %w", err) } diff --git a/cloud/enckey_store_test.go b/cloud/enckey_store_test.go index d40e598..9a5702f 100644 --- a/cloud/enckey_store_test.go +++ b/cloud/enckey_store_test.go @@ -7,7 +7,7 @@ import ( func TestFileEncKeyStoreRoundTrip(t *testing.T) { dir := t.TempDir() - store := NewFileEncKeyStore(dir, func() (string, error) { return "keystore-pass", nil }) + store := NewFileEncKeyStore(dir, func() ([]byte, error) { return []byte("keystore-pass"), nil }) key := []byte("0123456789abcdef0123456789abcdef") if err := store.Save("alice@example.com", key); err != nil { @@ -40,11 +40,11 @@ func TestFileEncKeyStoreRoundTrip(t *testing.T) { func TestFileEncKeyStoreWrongPassphrase(t *testing.T) { dir := t.TempDir() - saver := NewFileEncKeyStore(dir, func() (string, error) { return "right-pass", nil }) + saver := NewFileEncKeyStore(dir, func() ([]byte, error) { return []byte("right-pass"), nil }) if err := saver.Save("a@b.c", []byte("secret-key-material")); err != nil { t.Fatal(err) } - loader := NewFileEncKeyStore(dir, func() (string, error) { return "wrong-pass", nil }) + loader := NewFileEncKeyStore(dir, func() ([]byte, error) { return []byte("wrong-pass"), nil }) if _, err := loader.Load("a@b.c"); err == nil { t.Fatal("wrong passphrase must fail to decrypt") } diff --git a/cloud/identities.go b/cloud/identities.go index 39e4b23..dc378f1 100644 --- a/cloud/identities.go +++ b/cloud/identities.go @@ -9,11 +9,14 @@ import ( "github.com/instacryptio/icfx/profile" ) -// encKeyPassphrase renders the raw account encryption key as the passphrase -// string profile export/import expect. The key is already high-entropy (the -// scrypt pass is redundant but harmless), matching the profile-bundle pattern. -func encKeyPassphrase(encKey []byte) string { - return base64.StdEncoding.EncodeToString(encKey) +// encKeyPassphrase renders the raw account encryption key as the wipeable +// passphrase bytes profile export/import expect. The base64 encoding (its +// ASCII bytes, not the raw key) is the on-the-wire passphrase and MUST be +// preserved for ciphertext compatibility with existing roaming blobs. The +// callee (profile) takes ownership of the returned slice and zeroes it. The key +// is already high-entropy (the scrypt pass is redundant but harmless). +func encKeyPassphrase(encKey []byte) []byte { + return []byte(base64.StdEncoding.EncodeToString(encKey)) } // sessionOrStoredEncKey resolves the account encryption key: the in-memory diff --git a/cloud/runsync_test.go b/cloud/runsync_test.go index 6a323d7..93695e2 100644 --- a/cloud/runsync_test.go +++ b/cloud/runsync_test.go @@ -15,7 +15,7 @@ func TestRunSyncGuards(t *testing.T) { } // Store but no account email → clear error. - c.SetSessionStore(DefaultSessionStore(t.TempDir(), false, func() (string, error) { return "p", nil }, nil)) + c.SetSessionStore(DefaultSessionStore(t.TempDir(), false, func() ([]byte, error) { return []byte("p"), nil }, nil)) if _, err := c.RunSync(context.Background(), nil, SyncOptions{}); err == nil || !strings.Contains(err.Error(), "account") { t.Fatalf("want account guard, got %v", err) diff --git a/cloud/session_store.go b/cloud/session_store.go index 8763e81..d556f45 100644 --- a/cloud/session_store.go +++ b/cloud/session_store.go @@ -255,7 +255,8 @@ func (s fileTokenBackend) SaveTokens(email string, t *Tokens) error { if err != nil { return err } - ct, err := crypto.EncryptWithPassphrase(raw, pass) + defer crypto.Zero(pass) + ct, err := crypto.EncryptWithPassphraseBytes(raw, pass) if err != nil { return fmt.Errorf("encrypting session tokens: %w", err) } @@ -277,7 +278,8 @@ func (s fileTokenBackend) LoadTokens(email string) (*Tokens, error) { if err != nil { return nil, err } - raw, err := crypto.DecryptWithPassphrase(ct, pass) + defer crypto.Zero(pass) + raw, err := crypto.DecryptWithPassphraseBytes(ct, pass) if err != nil { return nil, fmt.Errorf("decrypting session tokens (wrong keystore passphrase?): %w", err) } diff --git a/cloud/session_store_test.go b/cloud/session_store_test.go index 3b891a9..d3b27d5 100644 --- a/cloud/session_store_test.go +++ b/cloud/session_store_test.go @@ -17,7 +17,7 @@ func testTokens() *Tokens { func TestFileSessionStoreTokenRoundTrip(t *testing.T) { dir := t.TempDir() - store := DefaultSessionStore(dir, false, func() (string, error) { return "keystore-pass", nil }, nil) + store := DefaultSessionStore(dir, false, func() ([]byte, error) { return []byte("keystore-pass"), nil }, nil) if store.HasSession("alice@example.com") { t.Fatal("no session should exist yet") @@ -59,11 +59,11 @@ func TestFileSessionStoreTokenRoundTrip(t *testing.T) { func TestFileSessionStoreWrongPassphrase(t *testing.T) { dir := t.TempDir() - saver := DefaultSessionStore(dir, false, func() (string, error) { return "right", nil }, nil) + saver := DefaultSessionStore(dir, false, func() ([]byte, error) { return []byte("right"), nil }, nil) if err := saver.SaveTokens("a@b.c", testTokens()); err != nil { t.Fatal(err) } - loader := DefaultSessionStore(dir, false, func() (string, error) { return "wrong", nil }, nil) + loader := DefaultSessionStore(dir, false, func() ([]byte, error) { return []byte("wrong"), nil }, nil) // Wrong passphrase can't decrypt the tokens... if _, err := loader.LoadTokens("a@b.c"); err == nil { t.Fatal("wrong passphrase must fail to decrypt tokens") @@ -77,8 +77,8 @@ func TestFileSessionStoreWrongPassphrase(t *testing.T) { func TestSessionStorePositionsAreNotSecret(t *testing.T) { dir := t.TempDir() // Deliberately give a failing passFn: positions must be readable WITHOUT it. - store := DefaultSessionStore(dir, false, func() (string, error) { - return "", errors.New("locked") + store := DefaultSessionStore(dir, false, func() ([]byte, error) { + return nil, errors.New("locked") }, nil) pos := SyncPositions{ @@ -107,7 +107,7 @@ func TestSessionStorePositionsAreNotSecret(t *testing.T) { func TestClientSessionStoreAutoPersistAndRestore(t *testing.T) { dir := t.TempDir() - store := DefaultSessionStore(dir, false, func() (string, error) { return "kp", nil }, nil) + store := DefaultSessionStore(dir, false, func() ([]byte, error) { return []byte("kp"), nil }, nil) // A client that "logs in": email + SetTokens should auto-persist. c, _ := New("http://localhost:0") diff --git a/identity/handoff/handoff.go b/identity/handoff/handoff.go index 90d4fa9..830351d 100644 --- a/identity/handoff/handoff.go +++ b/identity/handoff/handoff.go @@ -26,8 +26,11 @@ type Host interface { // identities may prompt a tap). The host owns its lifecycle. OpenIdentity(name string) (cloud.SelfCrypter, error) // ClearKeys wipes an identity's key material (and HW challenge) from the - // keystore — the backend/HW-aware part the client already implements. - ClearKeys(name string) error + // keystore. It receives the already-resolved index entry (backend/HW config) + // and MUST NOT re-read the identity index: HandoffAndDelete removes the entry + // from the index BEFORE calling this (crash-safety ordering), so a re-read + // would not find it. Only the keystore construction is the client's job. + ClearKeys(idx identity.IdentityIndex) error GroupStore() (*groups.Store, error) NotificationStore() *cloud.NotificationStore // nil when the client has no drawer (ic-cli) ResourceIO() cloud.ResourceIO @@ -122,16 +125,36 @@ func HandoffAndDelete(ctx context.Context, c *cloud.Client, cfg *config.Config, // The reverse partial — index removed, keys still present — is the // recoverable "orphaned keys" state (`import --reconcile`), a strictly // better failure mode. + // Authorization gate: you may only delete an identity you can currently + // unlock — proving control before destroying it. For a HW identity this + // forces a challenge-response touch; for a passphrase identity, its + // passphrase; a plain keychain identity opens silently. Placed after the + // successor/re-key resolution so the successor-required and invalid-successor + // paths return WITHOUT a wasted touch; for a default-with-cloud delete the + // re-key above already opened the victim, so the host's cached challenge + // response makes this a no-op (no double touch). --force does NOT skip this — + // it only bypasses the last-identity/cloud-abandonment block; skipping the + // unlock would turn --force into a free hardware-key bypass. A key that can't + // be unlocked (lost/corrupted) has no CLI delete path by design — remove it + // manually. + if _, err := host.OpenIdentity(victim); err != nil { + return fmt.Errorf("cannot delete %q — unlock it first (touch your hardware key / enter its passphrase): %w", victim, err) + } + + var victimIdx identity.IdentityIndex filtered := make([]identity.IdentityIndex, 0, len(entries)-1) for _, e := range entries { - if e.Name != victim { - filtered = append(filtered, e) + if e.Name == victim { + victimIdx = e + continue } + filtered = append(filtered, e) } if err := idStore.SaveIndex(filtered); err != nil { return fmt.Errorf("saving identity index: %w", err) } - if err := host.ClearKeys(victim); err != nil { + // Pass the captured entry — the index no longer has it (SaveIndex above). + if err := host.ClearKeys(victimIdx); err != nil { return fmt.Errorf("clearing keys for %q: %w", victim, err) } if err := idStore.RemoveMeta(victim); err != nil { diff --git a/identity/handoff/handoff_test.go b/identity/handoff/handoff_test.go index 6684911..141785e 100644 --- a/identity/handoff/handoff_test.go +++ b/identity/handoff/handoff_test.go @@ -22,14 +22,28 @@ import ( "github.com/instacryptio/icfx/identity/handoff" ) -// fakeHost implements handoff.Host. With c == nil only ClearKeys is invoked. -type fakeHost struct{ cleared []string } +// fakeHost implements handoff.Host. With c == nil the cloud re-key path is +// skipped, so the delete flow reaches exactly two host methods: OpenIdentity +// (the unlock-to-delete gate) and ClearKeys (the delete primitive). openErr +// simulates a victim that can't be unlocked (lost/wrong key); cleared records +// the full index entry ClearKeys received. +type fakeHost struct { + cleared []identity.IdentityIndex + opened []string + openErr error +} -func (h *fakeHost) OpenIdentity(string) (cloud.SelfCrypter, error) { return nil, nil } -func (h *fakeHost) ClearKeys(name string) error { h.cleared = append(h.cleared, name); return nil } -func (h *fakeHost) GroupStore() (*groups.Store, error) { return nil, nil } -func (h *fakeHost) NotificationStore() *cloud.NotificationStore { return nil } -func (h *fakeHost) ResourceIO() cloud.ResourceIO { return nil } +func (h *fakeHost) OpenIdentity(name string) (cloud.SelfCrypter, error) { + h.opened = append(h.opened, name) + return nil, h.openErr +} +func (h *fakeHost) ClearKeys(idx identity.IdentityIndex) error { + h.cleared = append(h.cleared, idx) + return nil +} +func (h *fakeHost) GroupStore() (*groups.Store, error) { return nil, nil } +func (h *fakeHost) NotificationStore() *cloud.NotificationStore { return nil } +func (h *fakeHost) ResourceIO() cloud.ResourceIO { return nil } // useEnv points the global icfx dirs at an isolated temp tree. func useEnv(t *testing.T, dir string) { @@ -120,7 +134,7 @@ func TestHandoffAndDelete_LastIdentityForced(t *testing.T) { if err := handoff.HandoffAndDelete(context.Background(), nil, cfg, "alice", "", h, true); err != nil { t.Fatalf("forced last-identity delete: %v", err) } - if len(h.cleared) != 1 || h.cleared[0] != "alice" { + if len(h.cleared) != 1 || h.cleared[0].Name != "alice" { t.Errorf("cleared = %v, want [alice]", h.cleared) } if indexNames(t)["alice"] || len(indexNames(t)) != 0 { @@ -157,9 +171,13 @@ func TestHandoffAndDelete_NonDefaultDeletes(t *testing.T) { if err := handoff.HandoffAndDelete(context.Background(), nil, cfg, "bob", "", h, false); err != nil { t.Fatalf("delete non-default: %v", err) } - if len(h.cleared) != 1 || h.cleared[0] != "bob" { + if len(h.cleared) != 1 || h.cleared[0].Name != "bob" { t.Errorf("cleared = %v, want [bob]", h.cleared) } + // ClearKeys must receive the resolved index entry (backend/HW), not just a name. + if h.cleared[0].Backend != identity.BackendFile { + t.Errorf("ClearKeys got backend %q, want %q from the resolved entry", h.cleared[0].Backend, identity.BackendFile) + } names := indexNames(t) if names["bob"] || !names["alice"] || len(names) != 1 { t.Errorf("index = %v, want only alice", names) @@ -181,7 +199,7 @@ func TestHandoffAndDelete_DefaultWithSuccessor(t *testing.T) { if cfg.DefaultIdentity != "bob" { t.Errorf("default = %q, want bob", cfg.DefaultIdentity) } - if len(h.cleared) != 1 || h.cleared[0] != "alice" { + if len(h.cleared) != 1 || h.cleared[0].Name != "alice" { t.Errorf("cleared = %v, want [alice]", h.cleared) } names := indexNames(t) @@ -214,6 +232,43 @@ func TestHandoffAndDelete_VictimNotFound(t *testing.T) { } } +func TestHandoffAndDelete_UnlockGateBlocks(t *testing.T) { + useEnv(t, t.TempDir()) + seedIdentity(t, "alice") + seedIdentity(t, "bob") + // bob is non-default, so the successor path is skipped and the delete goes + // straight to the unlock gate. A victim that can't be unlocked aborts. + h := &fakeHost{openErr: errors.New("no hardware key / wrong passphrase")} + err := handoff.HandoffAndDelete(context.Background(), nil, defaultCfg("alice"), "bob", "", h, false) + if err == nil { + t.Fatal("expected delete to abort when the victim can't be unlocked") + } + if len(h.cleared) != 0 { + t.Errorf("keys cleared despite the unlock gate failing: %v", h.cleared) + } + if !indexNames(t)["bob"] { + t.Error("bob was removed from the index despite the unlock gate failing") + } +} + +func TestHandoffAndDelete_ForceDoesNotSkipUnlockGate(t *testing.T) { + useEnv(t, t.TempDir()) + seedIdentity(t, "alice") + // Only identity + force bypasses the last-identity block, but the unlock gate + // still applies — force must never be a hardware-key bypass. + h := &fakeHost{openErr: errors.New("locked")} + err := handoff.HandoffAndDelete(context.Background(), nil, defaultCfg("alice"), "alice", "", h, true) + if err == nil { + t.Fatal("expected forced delete to still require unlocking the victim") + } + if len(h.opened) == 0 { + t.Error("the unlock gate was not invoked on a forced delete") + } + if len(h.cleared) != 0 || !indexNames(t)["alice"] { + t.Error("forced delete proceeded despite the unlock gate failing") + } +} + func TestSetDefault(t *testing.T) { useEnv(t, t.TempDir()) seedIdentity(t, "alice") diff --git a/identity/unlocked_test.go b/identity/unlocked_test.go index dcf0fa0..c412010 100644 --- a/identity/unlocked_test.go +++ b/identity/unlocked_test.go @@ -266,7 +266,7 @@ func makeHWUnlocked(t *testing.T, name, passphrase string, hwSecret []byte) (*id } dir := t.TempDir() hw := &stubHardwareKey{secret: hwSecret} - passFn := func() (string, error) { return passphrase, nil } + passFn := func() ([]byte, error) { return []byte(passphrase), nil } dec := keystore.NewHardwareKeyDecorator(newMemKeystore(), hw, dir, passFn) if err := dec.StoreEncryptionIdentity(name, kp.EncryptionIdentity); err != nil { @@ -306,7 +306,7 @@ func TestExportImport_PreservesHW(t *testing.T) { dstKS := newMemKeystore() dstDir := t.TempDir() dstHW := &stubHardwareKey{secret: hwSecret} // same secret = same device class - dstPass := func() (string, error) { return "user-pass", nil } + dstPass := func() ([]byte, error) { return []byte("user-pass"), nil } var dstDec *keystore.HardwareKeyDecorator imported, err := identity.Import(bundle, "export-pass", dstKS, func(challenge []byte) (keystore.Keystore, error) { diff --git a/keystore/file.go b/keystore/file.go index 742a3e6..e728be3 100644 --- a/keystore/file.go +++ b/keystore/file.go @@ -15,7 +15,14 @@ import ( ) // PassphraseFunc is a callback that returns a passphrase for encrypting/decrypting key files. -type PassphraseFunc func() (string, error) +// +// It returns the passphrase as a wipeable []byte rather than an immutable Go +// string: the consumer (this keystore) takes ownership of the returned slice +// and zeroes it after use, so the master passphrase never lingers in an +// un-zeroable string on the heap. Producers that cache a prompt must return a +// FRESH copy each call (the consumer wipes what it receives). An empty/nil +// slice means "no passphrase" (plaintext mode). +type PassphraseFunc func() ([]byte, error) // FileStore stores keys as files in the keys directory. // When a PassphraseFunc is set, keys are encrypted at rest using age scrypt. @@ -71,12 +78,14 @@ func NewEncryptedFileStoreWithDir(dir string, fn PassphraseFunc) *FileStore { return &FileStore{dir: dir, passphraseFunc: fn} } -// resolvePassphraseForName returns the passphrase/KEK string used to en/decrypt -// the given identity's key files. Caches the result in memguard so repeated -// reads/writes don't re-trigger callbacks. The name parameter is currently -// unused (single-cache-slot mode) but is kept for symmetry with read/write -// call sites and future per-identity key paths. -func (f *FileStore) resolvePassphraseForName(_ string) (string, error) { +// resolvePassphraseForName returns a FRESH, caller-owned copy of the +// passphrase/KEK bytes used to en/decrypt the given identity's key files. The +// caller MUST wipe the returned slice (crypto.Zero) after use. A nil slice +// means plaintext mode (no passphrase). Caches the result in memguard so +// repeated reads/writes don't re-trigger callbacks. The name parameter is +// currently unused (single-cache-slot mode) but is kept for symmetry with +// read/write call sites and future per-identity key paths. +func (f *FileStore) resolvePassphraseForName(_ string) ([]byte, error) { cacheKey := "" f.mu.Lock() @@ -86,35 +95,38 @@ func (f *FileStore) resolvePassphraseForName(_ string) (string, error) { if resolved { if !ok || cached == nil { - return "", nil + return nil, nil } buf, err := cached.Open() if err != nil { - return "", fmt.Errorf("opening cached passphrase: %w", err) + return nil, fmt.Errorf("opening cached passphrase: %w", err) } - pass := string(buf.Bytes()) + out := make([]byte, len(buf.Bytes())) + copy(out, buf.Bytes()) buf.Destroy() - return pass, nil + return out, nil } pass, err := f.computePassphraseForName("") if err != nil { - return "", err + return nil, err } - f.cacheStore(cacheKey, pass) + f.cacheStore(cacheKey, pass) // seals a copy; leaves pass owned by the caller return pass, nil } // computePassphraseForName invokes the configured callback to derive the -// passphrase for the given identity. No callback set → empty (plaintext mode). -func (f *FileStore) computePassphraseForName(_ string) (string, error) { +// passphrase for the given identity. No callback set → nil (plaintext mode). +func (f *FileStore) computePassphraseForName(_ string) ([]byte, error) { if f.passphraseFunc != nil { return f.passphraseFunc() } - return "", nil + return nil, nil } -func (f *FileStore) cacheStore(key string, pass string) { +// cacheStore seals a COPY of pass into the single-slot memguard cache. It does +// not consume pass — the caller retains and wipes the original. +func (f *FileStore) cacheStore(key string, pass []byte) { f.mu.Lock() defer f.mu.Unlock() if f.cache == nil { @@ -122,11 +134,13 @@ func (f *FileStore) cacheStore(key string, pass string) { f.resolved = make(map[string]bool) } f.resolved[key] = true - if pass == "" { + if len(pass) == 0 { f.cache[key] = nil return } - buf := memguard.NewBufferFromBytes([]byte(pass)) + cp := make([]byte, len(pass)) + copy(cp, pass) + buf := memguard.NewBufferFromBytes(cp) // wipes cp, not pass f.cache[key] = buf.Seal() } @@ -217,9 +231,10 @@ func (f *FileStore) writeFile(filename string, data []byte) error { if err != nil { return fmt.Errorf("resolving passphrase: %w", err) } + defer crypto.Zero(pass) - if pass != "" { - data, err = crypto.EncryptWithPassphrase(data, pass) + if len(pass) != 0 { + data, err = crypto.EncryptWithPassphraseBytes(data, pass) if err != nil { return fmt.Errorf("encrypting key file %s: %w", filename, err) } @@ -240,10 +255,11 @@ func (f *FileStore) readFile(filename string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("resolving passphrase: %w", err) } + defer crypto.Zero(pass) - if pass != "" { + if len(pass) != 0 { if isAgeEncrypted(data) { - decrypted, err := crypto.DecryptWithPassphrase(data, pass) + decrypted, err := crypto.DecryptWithPassphraseBytes(data, pass) if err != nil { return nil, fmt.Errorf("decrypting key file %s (wrong passphrase?): %w", filename, err) } diff --git a/keystore/hardware.go b/keystore/hardware.go index 7de13c5..fd37f98 100644 --- a/keystore/hardware.go +++ b/keystore/hardware.go @@ -168,37 +168,49 @@ func (d *HardwareKeyDecorator) loadOrCreateChallengeLocked(name string) ([]byte, // derivePassphrase computes the age-scrypt passphrase used to encrypt this // identity's stored bytes: hex(HKDF-SHA256(passphrase, hwResponse)). -func (d *HardwareKeyDecorator) derivePassphrase(name string) (string, error) { +// +// It returns the hex-encoded KEK as a FRESH, caller-owned []byte (never a Go +// string) so the derived per-identity secret can be wiped after use; every +// caller MUST crypto.Zero the result. The master passphrase, the hardware +// response, and the raw KEK are all wiped here, keeping their lifetimes as +// short as possible. The hex encoding (not the raw KEK) is preserved as the +// scrypt passphrase for on-disk compatibility with keystores written before +// this change. +func (d *HardwareKeyDecorator) derivePassphrase(name string) ([]byte, error) { if d.passFn == nil { - return "", fmt.Errorf("hardware decorator requires a passphrase function") + return nil, fmt.Errorf("hardware decorator requires a passphrase function") } d.mu.Lock() challenge, err := d.loadOrCreateChallengeLocked(name) d.mu.Unlock() if err != nil { - return "", err + return nil, err } pass, err := d.passFn() if err != nil { - return "", fmt.Errorf("getting passphrase: %w", err) + return nil, fmt.Errorf("getting passphrase: %w", err) } + defer crypto.Zero(pass) response, err := d.hwKey.Challenge(challenge) if err != nil { - return "", fmt.Errorf("hardware challenge-response (%s): %w", d.hwKey.Type(), err) + return nil, fmt.Errorf("hardware challenge-response (%s): %w", d.hwKey.Type(), err) } defer crypto.Zero(response) - kek, err := crypto.DeriveHardwareKEK(pass, response) + kek, err := crypto.DeriveHardwareKEKBytes(pass, response) if err != nil { - return "", err + return nil, err } defer crypto.Zero(kek) - // NOTE: the returned hex string can't be zeroed (Go strings are immutable); - // the underlying KEK bytes are wiped here, minimizing the secret's lifetime. - return hex.EncodeToString(kek), nil + + // Hex-encode into a []byte (not hex.EncodeToString, which would create an + // un-zeroable string); this hex passphrase is what the caller wipes. + hexKEK := make([]byte, hex.EncodedLen(len(kek))) + hex.Encode(hexKEK, kek) + return hexKEK, nil } // StoreEncryptionIdentity encrypts the given age secret key string with the @@ -209,7 +221,8 @@ func (d *HardwareKeyDecorator) StoreEncryptionIdentity(name string, identity str if err != nil { return err } - ciphertext, err := crypto.EncryptWithPassphrase([]byte(identity), pass) + defer crypto.Zero(pass) + ciphertext, err := crypto.EncryptWithPassphraseBytes([]byte(identity), pass) if err != nil { return fmt.Errorf("encrypting encryption identity: %w", err) } @@ -231,7 +244,8 @@ func (d *HardwareKeyDecorator) LoadEncryptionIdentity(name string) (string, erro if err != nil { return "", err } - plaintext, err := crypto.DecryptWithPassphrase(ciphertext, pass) + defer crypto.Zero(pass) + plaintext, err := crypto.DecryptWithPassphraseBytes(ciphertext, pass) if err != nil { return "", fmt.Errorf("decrypting encryption identity (wrong passphrase or hardware key?): %w", err) } @@ -245,7 +259,8 @@ func (d *HardwareKeyDecorator) StoreSigningKey(name string, key []byte) error { if err != nil { return err } - ciphertext, err := crypto.EncryptWithPassphrase(key, pass) + defer crypto.Zero(pass) + ciphertext, err := crypto.EncryptWithPassphraseBytes(key, pass) if err != nil { return fmt.Errorf("encrypting signing key: %w", err) } @@ -262,7 +277,8 @@ func (d *HardwareKeyDecorator) LoadSigningKey(name string) ([]byte, error) { if err != nil { return nil, err } - plaintext, err := crypto.DecryptWithPassphrase(ciphertext, pass) + defer crypto.Zero(pass) + plaintext, err := crypto.DecryptWithPassphraseBytes(ciphertext, pass) if err != nil { return nil, fmt.Errorf("decrypting signing key (wrong passphrase or hardware key?): %w", err) } diff --git a/keystore/hardware_test.go b/keystore/hardware_test.go index f879429..400157a 100644 --- a/keystore/hardware_test.go +++ b/keystore/hardware_test.go @@ -42,7 +42,7 @@ func TestHardwareKeyDecorator_RoundTrip(t *testing.T) { hwSecret := []byte("device-secret-12345678901") hw := newMockHardwareKey(hwSecret) - passFn := func() (string, error) { return "user-passphrase", nil } + passFn := func() ([]byte, error) { return []byte("user-passphrase"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) identity := "AGE-SECRET-KEY-PQ-1FAKEFAKEFAKE" @@ -66,7 +66,7 @@ func TestHardwareKeyDecorator_RoundTrip(t *testing.T) { func TestHardwareKeyDecorator_SigningKeyRoundTrip(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("hw-secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) signKey := []byte{0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, 0x56, 0x78} @@ -85,7 +85,7 @@ func TestHardwareKeyDecorator_SigningKeyRoundTrip(t *testing.T) { func TestHardwareKeyDecorator_DifferentDeviceFails(t *testing.T) { dir := t.TempDir() hw1 := newMockHardwareKey([]byte("device-A-secret")) - passFn := func() (string, error) { return "samepass", nil } + passFn := func() ([]byte, error) { return []byte("samepass"), nil } dec1 := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw1, dir, passFn) if err := dec1.StoreEncryptionIdentity("alice", "AGE-SECRET-KEY-PQ-1XYZ"); err != nil { @@ -104,13 +104,13 @@ func TestHardwareKeyDecorator_WrongPassphraseFails(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("hw-secret")) - passOK := func() (string, error) { return "right-pass", nil } + passOK := func() ([]byte, error) { return []byte("right-pass"), nil } dec1 := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passOK) if err := dec1.StoreEncryptionIdentity("alice", "AGE-SECRET-KEY-PQ-1XYZ"); err != nil { t.Fatalf("StoreEncryptionIdentity: %v", err) } - passWrong := func() (string, error) { return "wrong-pass", nil } + passWrong := func() ([]byte, error) { return []byte("wrong-pass"), nil } dec2 := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passWrong) _, err := dec2.LoadEncryptionIdentity("alice") if err == nil { @@ -121,7 +121,7 @@ func TestHardwareKeyDecorator_WrongPassphraseFails(t *testing.T) { func TestHardwareKeyDecorator_ChallengePersistsAcrossOps(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) if err := dec.StoreEncryptionIdentity("alice", "id-1"); err != nil { @@ -154,7 +154,7 @@ func TestHardwareKeyDecorator_ChallengePersistsAcrossOps(t *testing.T) { func TestHardwareKeyDecorator_DistinctChallengesPerIdentity(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) if err := dec.StoreEncryptionIdentity("alice", "id-alice"); err != nil { @@ -197,7 +197,7 @@ func TestHardwareKeyDecorator_DistinctChallengesPerIdentity(t *testing.T) { func TestHardwareKeyDecorator_RemoveChallenge(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) if err := dec.StoreEncryptionIdentity("alice", "id-1"); err != nil { @@ -223,7 +223,7 @@ func TestHardwareKeyDecorator_RemoveChallenge(t *testing.T) { func TestHardwareKeyDecorator_ListNamesFiltersChallengeFiles(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) if err := dec.StoreEncryptionIdentity("alice", "id-1"); err != nil { @@ -326,7 +326,7 @@ var _ Keystore = (*memKeystore)(nil) func TestHardwareKeyDecorator_KeychainShapedInner(t *testing.T) { challengeDir := t.TempDir() // challenge file always lives on disk hw := newMockHardwareKey([]byte("hw-secret")) - passFn := func() (string, error) { return "user-pass", nil } + passFn := func() ([]byte, error) { return []byte("user-pass"), nil } inner := newMemKeystore() dec := NewHardwareKeyDecorator(inner, hw, challengeDir, passFn) @@ -371,7 +371,7 @@ func TestHardwareKeyDecorator_KeychainShapedInner(t *testing.T) { func TestHardwareKeyDecorator_ChallengeProviderInterface(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) var p HWChallengeProvider = dec // compile-time interface check @@ -387,7 +387,7 @@ func TestHardwareKeyDecorator_ChallengeProviderInterface(t *testing.T) { func TestHardwareKeyDecorator_EnsureChallenge(t *testing.T) { dir := t.TempDir() hw := newMockHardwareKey([]byte("secret")) - passFn := func() (string, error) { return "pass", nil } + passFn := func() ([]byte, error) { return []byte("pass"), nil } dec := NewHardwareKeyDecorator(NewFileStoreWithDir(dir), hw, dir, passFn) c1, err := dec.EnsureChallenge("alice") diff --git a/keystore/keystore_test.go b/keystore/keystore_test.go index 3c96295..212b65b 100644 --- a/keystore/keystore_test.go +++ b/keystore/keystore_test.go @@ -1,10 +1,14 @@ package keystore import ( + "bytes" + "encoding/base64" "os" "path/filepath" "strings" "testing" + + "github.com/instacryptio/icfx/crypto" ) func TestFileStore(t *testing.T) { @@ -77,8 +81,8 @@ func TestEncryptedFileStore(t *testing.T) { defer os.RemoveAll(dir) passphrase := "test-passphrase-123" - fs := NewEncryptedFileStoreWithDir(dir, func() (string, error) { - return passphrase, nil + fs := NewEncryptedFileStoreWithDir(dir, func() ([]byte, error) { + return []byte(passphrase), nil }) // Store and load encryption identity @@ -129,8 +133,8 @@ func TestEncryptedFileStore(t *testing.T) { } // Verify wrong passphrase fails - wrongFS := NewEncryptedFileStoreWithDir(dir, func() (string, error) { - return "wrong-passphrase", nil + wrongFS := NewEncryptedFileStoreWithDir(dir, func() ([]byte, error) { + return []byte("wrong-passphrase"), nil }) _, err = wrongFS.LoadEncryptionIdentity("test") if err == nil { @@ -159,8 +163,8 @@ func TestEncryptedFileStoreMigration(t *testing.T) { // Read with encrypted store — should handle plaintext files gracefully when // the caller opts into migration (default is to reject a plaintext key). - encFS := NewEncryptedFileStoreWithDir(dir, func() (string, error) { - return "some-passphrase", nil + encFS := NewEncryptedFileStoreWithDir(dir, func() ([]byte, error) { + return []byte("some-passphrase"), nil }) encFS.AllowPlaintextMigration() @@ -180,3 +184,53 @@ func TestEncryptedFileStoreMigration(t *testing.T) { t.Errorf("LoadSigningKey mismatch after migration") } } + +// TestEncryptedFileStoreByteCallbackBackwardCompat pins the S0 invariant: the +// switch from a string PassphraseFunc to a []byte one did NOT change the +// on-disk format. A key file written by the pre-migration string API +// (crypto.EncryptWithPassphrase) must still decrypt through the current +// []byte-based FileStore given the same passphrase. If this ever fails, every +// keystore written by an older build has become unreadable. +func TestEncryptedFileStoreByteCallbackBackwardCompat(t *testing.T) { + dir := t.TempDir() + const pass = "legacy-passphrase-123" + const secret = "AGE-SECRET-KEY-PQ-1LEGACY" + signKey := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01} + + // Synthesize the exact bytes the old string-based keystore wrote to disk: + // .enc = Encrypt(identityString) + // .sign = Encrypt(base64(signingKey)) + oldEnc, err := crypto.EncryptWithPassphrase([]byte(secret), pass) + if err != nil { + t.Fatalf("legacy .enc encrypt: %v", err) + } + oldSign, err := crypto.EncryptWithPassphrase([]byte(base64.StdEncoding.EncodeToString(signKey)), pass) + if err != nil { + t.Fatalf("legacy .sign encrypt: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "legacy.enc"), oldEnc, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "legacy.sign"), oldSign, 0600); err != nil { + t.Fatal(err) + } + + // The current []byte-callback store must read both back unchanged. + fs := NewEncryptedFileStoreWithDir(dir, func() ([]byte, error) { return []byte(pass), nil }) + + gotID, err := fs.LoadEncryptionIdentity("legacy") + if err != nil { + t.Fatalf("new store failed to load legacy-format .enc: %v", err) + } + if gotID != secret { + t.Fatalf("decrypted encryption identity %q, want %q", gotID, secret) + } + + gotSign, err := fs.LoadSigningKey("legacy") + if err != nil { + t.Fatalf("new store failed to load legacy-format .sign: %v", err) + } + if !bytes.Equal(gotSign, signKey) { + t.Fatalf("decrypted signing key %x, want %x", gotSign, signKey) + } +} diff --git a/keystore/plaintext_gate_test.go b/keystore/plaintext_gate_test.go index 8d3ba45..b85fa37 100644 --- a/keystore/plaintext_gate_test.go +++ b/keystore/plaintext_gate_test.go @@ -14,7 +14,7 @@ func TestEncryptedStore_RejectsPlaintextByDefault(t *testing.T) { t.Fatalf("seed plaintext key: %v", err) } - enc := NewEncryptedFileStoreWithDir(dir, func() (string, error) { return "pw", nil }) + enc := NewEncryptedFileStoreWithDir(dir, func() ([]byte, error) { return []byte("pw"), nil }) // No AllowPlaintextMigration() → must reject. if _, err := enc.LoadEncryptionIdentity("test"); err == nil { t.Fatal("encrypted-mode store loaded a plaintext key without opting into migration; want rejection") diff --git a/profile/identities.go b/profile/identities.go index b04f69d..01058c5 100644 --- a/profile/identities.go +++ b/profile/identities.go @@ -90,10 +90,11 @@ func RoamingDigest(exportFn IdentityExportFn) (string, error) { // ExportIdentitiesToBytes packs every local identity's at-rest transport form // (via exportFn) into a bundle wrapped under outerKey. It does NOT decrypt // file/HW keys — export is prompt-free; the secret is needed only to *use* a key. -func ExportIdentitiesToBytes(outerKey string, exportFn IdentityExportFn) ([]byte, error) { - if outerKey == "" { +func ExportIdentitiesToBytes(outerKey []byte, exportFn IdentityExportFn) ([]byte, error) { + if len(outerKey) == 0 { return nil, fmt.Errorf("outer key cannot be empty") } + defer crypto.Zero(outerKey) if exportFn == nil { return nil, fmt.Errorf("exportFn callback is required") } @@ -167,7 +168,7 @@ func ExportIdentitiesToBytes(outerKey string, exportFn IdentityExportFn) ([]byte if err := gw.Close(); err != nil { return nil, fmt.Errorf("closing gzip: %w", err) } - return crypto.EncryptWithPassphrase(buf.Bytes(), outerKey) + return crypto.EncryptWithPassphraseBytes(buf.Bytes(), outerKey) } // ImportIdentitiesFromBytes unwraps the bundle with outerKey and MERGES each @@ -180,11 +181,12 @@ func ExportIdentitiesToBytes(outerKey string, exportFn IdentityExportFn) ([]byte // before the roam applies it. A wrong-default device can still read this (the // blob is encKey-wrapped, not self-locked), which is what makes cross-device // convergence work. -func PeekRoamingManifest(data []byte, outerKey string) (Manifest, error) { - if outerKey == "" { +func PeekRoamingManifest(data []byte, outerKey []byte) (Manifest, error) { + if len(outerKey) == 0 { return Manifest{}, fmt.Errorf("outer key cannot be empty") } - plain, err := crypto.DecryptWithPassphrase(data, outerKey) + defer crypto.Zero(outerKey) + plain, err := crypto.DecryptWithPassphraseBytes(data, outerKey) if err != nil { return Manifest{}, fmt.Errorf("decrypting identities bundle (wrong cloud password?): %w", err) } @@ -203,15 +205,16 @@ func PeekRoamingManifest(data []byte, outerKey string) (Manifest, error) { return manifest, nil } -func ImportIdentitiesFromBytes(data []byte, outerKey string, importFn IdentityImportFn) error { - if outerKey == "" { +func ImportIdentitiesFromBytes(data []byte, outerKey []byte, importFn IdentityImportFn) error { + if len(outerKey) == 0 { return fmt.Errorf("outer key cannot be empty") } if importFn == nil { return fmt.Errorf("importFn callback is required") } + defer crypto.Zero(outerKey) - plain, err := crypto.DecryptWithPassphrase(data, outerKey) + plain, err := crypto.DecryptWithPassphraseBytes(data, outerKey) if err != nil { return fmt.Errorf("decrypting identities bundle (wrong cloud password?): %w", err) } diff --git a/profile/identities_roundtrip_test.go b/profile/identities_roundtrip_test.go index 32aa375..9a50919 100644 --- a/profile/identities_roundtrip_test.go +++ b/profile/identities_roundtrip_test.go @@ -14,10 +14,12 @@ import ( "github.com/instacryptio/icfx/profile" ) -const ( - testKSPass = "keystore-at-rest-pass" - testExportPass = "cloud-enc-key-passphrase" -) +const testKSPass = "keystore-at-rest-pass" + +// testExportPass returns a FRESH copy of the roaming outer-key each call: the +// export/import/peek functions now take ownership of the []byte and wipe it, so +// a shared slice reused across calls would be zeroed after the first. +func testExportPass() []byte { return []byte("cloud-enc-key-passphrase") } // useEnv points the global icfx dirs at an isolated temp tree (a "device") and // returns an encrypted file keystore rooted there. @@ -33,7 +35,7 @@ func useEnv(t *testing.T, dir string) *keystore.FileStore { if err != nil { t.Fatalf("KeysDir: %v", err) } - return keystore.NewEncryptedFileStoreWithDir(keysDir, func() (string, error) { return testKSPass, nil }) + return keystore.NewEncryptedFileStoreWithDir(keysDir, func() ([]byte, error) { return []byte(testKSPass), nil }) } // createLocalIdentity mirrors `icc identity create`: generate a keypair, store @@ -156,7 +158,7 @@ func TestExportImportIdentitiesRoaming(t *testing.T) { kpAlice := createLocalIdentity(t, ksA, "alice") kpBob := createLocalIdentity(t, ksA, "bob") - blob, err := profile.ExportIdentitiesToBytes(testExportPass, fileExportFn(t)) + blob, err := profile.ExportIdentitiesToBytes(testExportPass(), fileExportFn(t)) if err != nil { t.Fatalf("ExportIdentitiesToBytes: %v", err) } @@ -174,7 +176,7 @@ func TestExportImportIdentitiesRoaming(t *testing.T) { createLocalIdentity(t, ksB, "carol") createLocalIdentity(t, ksB, "bob") // will be upserted to device A's bob - if err := profile.ImportIdentitiesFromBytes(blob, testExportPass, fileImportFn(t)); err != nil { + if err := profile.ImportIdentitiesFromBytes(blob, testExportPass(), fileImportFn(t)); err != nil { t.Fatalf("ImportIdentitiesFromBytes: %v", err) } diff --git a/profile/import.go b/profile/import.go index 92ff24c..58fd8e5 100644 --- a/profile/import.go +++ b/profile/import.go @@ -152,7 +152,7 @@ func Import(inputPath string, opts ImportOptions, destKsFn DestinationKeystoreFn } // ImportFromBytes is Import but takes the encrypted bytes directly. -func ImportFromBytes(data []byte, opts ImportOptions, destKsFn DestinationKeystoreFn) error { +func ImportFromBytes(data []byte, opts ImportOptions, destKsFn DestinationKeystoreFn) (err error) { pass := opts.PassphraseBytes if len(pass) == 0 { pass = []byte(opts.Passphrase) @@ -189,6 +189,26 @@ func ImportFromBytes(data []byte, opts ImportOptions, destKsFn DestinationKeysto return fmt.Errorf("unsupported bundle version %d (this build supports %d)", manifest.Version, bundleVersion) } + // Everything above is non-destructive (pre-validation). Before the first + // destructive write, snapshot the existing install so ANY failure in the + // steps below rolls back to the user's previous profile. Without this, a + // mid-import error (bad destKsFn, a HW ceremony that fails, a disk error on + // identity N of M) leaves the keys already wiped and the profile + // half-installed, with NO recovery — permanent key loss. + backup, berr := snapshotInstall() + if berr != nil { + return fmt.Errorf("backing up the existing profile before import: %w", berr) + } + defer func() { + if err != nil { + if rerr := backup.restore(); rerr != nil { + err = fmt.Errorf("%w; the previous profile could NOT be fully restored (%v) — your original data is preserved at %s", err, rerr, backup.root) + return + } + } + backup.discard() + }() + // Step 1: apply config first so destKsFn / KeysDir / etc. observe the // imported preferences during the rest of the import. if opts.IncludeSettings || opts.IncludePaths { @@ -293,6 +313,195 @@ func ImportFromBytes(data []byte, opts ImportOptions, destKsFn DestinationKeysto return nil } +// installBackup is a rollback snapshot of the on-disk identity install, taken +// before ImportFromBytes's destructive phase so a failed import can restore the +// user's previous profile instead of leaving wiped keys and a half-install. +// It captures exactly what the import overwrites: the keys dir, the per-identity +// meta dir, identities.json, contacts.json, and config.toml. +type installBackup struct { + root string // temp dir holding the archived copies + items []backupEntry // one per install path, in restore order +} + +// backupEntry records one live path and its archived copy. existed=false means +// the live path was absent at snapshot time, so restore removes it (undoing any +// file the partial import created). +type backupEntry struct { + live string // absolute live path, resolved BEFORE any config/path mutation + stored string // archived copy under root (unset when existed=false) + isDir bool + existed bool +} + +// snapshotInstall archives the current install into a fresh temp dir. Paths are +// resolved up front (before applyImportedConfig can change KeysDir/DataDir) so +// restore always targets the original locations. +func snapshotInstall() (*installBackup, error) { + keysDir, err := config.KeysDir() + if err != nil { + return nil, fmt.Errorf("resolving keys dir: %w", err) + } + metaDir, err := config.IdentityMetaDir() + if err != nil { + return nil, fmt.Errorf("resolving identity meta dir: %w", err) + } + idxPath, err := config.IdentitiesFilePath() + if err != nil { + return nil, fmt.Errorf("resolving identities path: %w", err) + } + contactsPath, err := config.ContactsFilePath() + if err != nil { + return nil, fmt.Errorf("resolving contacts path: %w", err) + } + cfgPath, err := config.ConfigFilePath() + if err != nil { + return nil, fmt.Errorf("resolving config path: %w", err) + } + + if err := os.MkdirAll(config.TempDir(), 0o700); err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + root, err := os.MkdirTemp(config.TempDir(), "icfx-import-rollback-") + if err != nil { + return nil, fmt.Errorf("creating rollback dir: %w", err) + } + + b := &installBackup{root: root} + specs := []struct { + live string + isDir bool + name string + }{ + {keysDir, true, "keys"}, + {metaDir, true, "meta"}, + {idxPath, false, "identities.json"}, + {contactsPath, false, "contacts.json"}, + {cfgPath, false, "config.toml"}, + } + for _, s := range specs { + entry := backupEntry{live: s.live, isDir: s.isDir} + info, statErr := os.Stat(s.live) + switch { + case statErr == nil: + entry.existed = true + entry.stored = filepath.Join(root, s.name) + if cErr := copyPath(s.live, entry.stored, s.isDir, info.Mode()); cErr != nil { + b.discard() + return nil, fmt.Errorf("archiving %s: %w", s.live, cErr) + } + case !os.IsNotExist(statErr): + b.discard() + return nil, fmt.Errorf("inspecting %s: %w", s.live, statErr) + } + b.items = append(b.items, entry) + } + return b, nil +} + +// restore reverts the install to the snapshot: directories are cleared and +// repopulated, files are rewritten or (if absent at snapshot time) removed, so +// any partial state the failed import wrote is undone. Best-effort across all +// items; the collected errors are returned together so the caller can tell the +// user the archive was kept. +func (b *installBackup) restore() error { + var errs []string + for _, e := range b.items { + if err := e.restore(); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return nil +} + +// restore reverts a single install path to its snapshot state. +func (e backupEntry) restore() error { + if e.isDir { + if err := os.RemoveAll(e.live); err != nil { + return fmt.Errorf("clearing %s: %w", e.live, err) + } + if !e.existed { + return nil + } + if err := copyTree(e.stored, e.live); err != nil { + return fmt.Errorf("restoring %s: %w", e.live, err) + } + return nil + } + if e.existed { + if err := copyFile(e.stored, e.live, 0o600); err != nil { + return fmt.Errorf("restoring %s: %w", e.live, err) + } + return nil + } + if err := os.Remove(e.live); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s: %w", e.live, err) + } + return nil +} + +// discard deletes the archive. Called on success, or after a successful restore. +func (b *installBackup) discard() { + if b == nil || b.root == "" { + return + } + _ = os.RemoveAll(b.root) +} + +// copyPath copies a file or directory tree from src to dst. +func copyPath(src, dst string, isDir bool, mode os.FileMode) error { + if isDir { + return copyTree(src, dst) + } + return copyFile(src, dst, mode) +} + +// copyFile copies src to dst (creating dst's parent), preserving the given mode. +func copyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return err + } + data, err := os.ReadFile(src) + if err != nil { + return err + } + if mode == 0 { + mode = 0o600 + } + return os.WriteFile(dst, data, mode) +} + +// copyTree recursively copies the directory src to dst. +func copyTree(src, dst string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + if err := os.MkdirAll(dst, 0o700); err != nil { + return err + } + for _, e := range entries { + s := filepath.Join(src, e.Name()) + d := filepath.Join(dst, e.Name()) + if e.IsDir() { + if err := copyTree(s, d); err != nil { + return err + } + continue + } + info, err := e.Info() + if err != nil { + return err + } + if err := copyFile(s, d, info.Mode()); err != nil { + return err + } + } + return nil +} + // applyImportedConfig overlays the user-selected fields from the bundled // config.toml onto the local config and saves it. Called before the wipe // + install so subsequent steps observe the imported Keystore preference diff --git a/profile/import_rollback_test.go b/profile/import_rollback_test.go new file mode 100644 index 0000000..f621d5f --- /dev/null +++ b/profile/import_rollback_test.go @@ -0,0 +1,80 @@ +package profile_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/instacryptio/icfx/config" + "github.com/instacryptio/icfx/keystore" + "github.com/instacryptio/icfx/profile" +) + +// TestImportFromBytesRollsBackOnFailure pins the S0b safety property: when +// ImportFromBytes fails AFTER its point-of-no-return (wipeLocalState runs, then +// destKsFn is invoked), the snapshot/restore must put the user's original +// install back — no wiped keys, no half-install. Before the fix this test would +// fail: alice's keys would be gone. +func TestImportFromBytesRollsBackOnFailure(t *testing.T) { + dir := t.TempDir() + ks := useEnv(t, dir) + createLocalIdentity(t, ks, "alice") + + // A valid bundle to import (any valid bundle gets past manifest validation + // and into the destructive phase). + bundle, err := profile.ExportToBytesWithPass([]byte("bundle-pass"), openFnFor(ks)) + if err != nil { + t.Fatalf("export: %v", err) + } + + // Capture the original on-disk install. + keysDir, err := config.KeysDir() + if err != nil { + t.Fatalf("KeysDir: %v", err) + } + idxPath, err := config.IdentitiesFilePath() + if err != nil { + t.Fatalf("IdentitiesFilePath: %v", err) + } + origEnc := mustRead(t, filepath.Join(keysDir, "alice.enc")) + origSign := mustRead(t, filepath.Join(keysDir, "alice.sign")) + origIdx := mustRead(t, idxPath) + + // destKsFn is called AFTER wipeLocalState, so failing here means the wipe + // already ran — the classic data-loss window this fix closes. + failing := func() (keystore.Keystore, string, error) { + return nil, "", fmt.Errorf("simulated destination-keystore failure") + } + err = profile.ImportFromBytes(bundle, profile.ImportOptions{PassphraseBytes: []byte("bundle-pass")}, failing) + if err == nil { + t.Fatal("expected import to fail, got nil") + } + + // The original install must be intact, byte-for-byte. + if got := mustRead(t, filepath.Join(keysDir, "alice.enc")); !bytes.Equal(got, origEnc) { + t.Error("alice.enc not restored to its original contents after failed import") + } + if got := mustRead(t, filepath.Join(keysDir, "alice.sign")); !bytes.Equal(got, origSign) { + t.Error("alice.sign not restored to its original contents after failed import") + } + if got := mustRead(t, idxPath); !bytes.Equal(got, origIdx) { + t.Error("identities.json not restored to its original contents after failed import") + } + + // The rollback archive must be cleaned up (no icfx-import-rollback-* left). + leftovers, _ := filepath.Glob(filepath.Join(config.TempDir(), "icfx-import-rollback-*")) + if len(leftovers) != 0 { + t.Errorf("rollback archive not discarded after successful restore: %v", leftovers) + } +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s (rollback likely failed): %v", path, err) + } + return data +} diff --git a/profile/manifest_default_test.go b/profile/manifest_default_test.go index 8c08dde..37463ad 100644 --- a/profile/manifest_default_test.go +++ b/profile/manifest_default_test.go @@ -34,13 +34,13 @@ func TestManifestRoundTripsDefaultAndFingerprint(t *testing.T) { t.Fatalf("cfg.Save: %v", err) } - blob, err := profile.ExportIdentitiesToBytes(testExportPass, fileExportFn(t)) + blob, err := profile.ExportIdentitiesToBytes(testExportPass(), fileExportFn(t)) if err != nil { t.Fatalf("ExportIdentitiesToBytes: %v", err) } // PeekRoamingManifest reads the pointer + fingerprints with NO key import. - m, err := profile.PeekRoamingManifest(blob, testExportPass) + m, err := profile.PeekRoamingManifest(blob, testExportPass()) if err != nil { t.Fatalf("PeekRoamingManifest: %v", err) } @@ -61,7 +61,7 @@ func TestManifestRoundTripsDefaultAndFingerprint(t *testing.T) { // Device B (clean): import populates the local index Fingerprint from the // manifest (identities.go), so B's key-swap gate has a value to compare. useEnv(t, t.TempDir()) - if err := profile.ImportIdentitiesFromBytes(blob, testExportPass, fileImportFn(t)); err != nil { + if err := profile.ImportIdentitiesFromBytes(blob, testExportPass(), fileImportFn(t)); err != nil { t.Fatalf("ImportIdentitiesFromBytes: %v", err) } store, err := identity.NewStore() diff --git a/profile/roaming_fns.go b/profile/roaming_fns.go index f44c5e7..b8daa88 100644 --- a/profile/roaming_fns.go +++ b/profile/roaming_fns.go @@ -105,12 +105,14 @@ type RoamingImportOptions struct { // PromptExisting is called to obtain the ORIGIN passphrase of a // passphrase-protected key that must be decrypted to move into this device's // keychain. Required only when DestBackend is keychain and a passphrase key - // is imported; may be nil otherwise. - PromptExisting func(name string) (string, error) + // is imported; may be nil otherwise. It returns the passphrase as a wipeable + // []byte; the callee takes ownership and zeroes it after use. + PromptExisting func(name string) ([]byte, error) // PromptNew is called to obtain a FRESH passphrase to protect a raw key that // lands on a file device. Required only when DestBackend is file and a raw - // (cloud) key is imported; may be nil otherwise. - PromptNew func(name string) (string, error) + // (cloud) key is imported; may be nil otherwise. It returns the passphrase as + // a wipeable []byte; the callee takes ownership and zeroes it after use. + PromptNew func(name string) ([]byte, error) // Notify optionally receives human-readable info messages (e.g. "set a // passphrase to protect this key at rest"). Nil discards them. Notify func(msg string) @@ -211,11 +213,12 @@ func decryptWithPrompt(opts RoamingImportOptions, name string, enc, sign []byte) if err != nil { return nil, nil, err } - encPlain, err = crypto.DecryptWithPassphrase(enc, pass) + defer crypto.Zero(pass) + encPlain, err = crypto.DecryptWithPassphraseBytes(enc, pass) if err != nil { return nil, nil, fmt.Errorf("decrypting %q (wrong passphrase?): %w", name, err) } - signPlain, err = crypto.DecryptWithPassphrase(sign, pass) + signPlain, err = crypto.DecryptWithPassphraseBytes(sign, pass) if err != nil { return nil, nil, fmt.Errorf("decrypting %q (wrong passphrase?): %w", name, err) } @@ -236,11 +239,12 @@ func encryptRawToFile(opts RoamingImportOptions, name string, enc, sign []byte) if err != nil { return err } - encCt, err := crypto.EncryptWithPassphrase(enc, pass) + defer crypto.Zero(pass) + encCt, err := crypto.EncryptWithPassphraseBytes(enc, pass) if err != nil { return err } - signCt, err := crypto.EncryptWithPassphrase(sign, pass) + signCt, err := crypto.EncryptWithPassphraseBytes(sign, pass) if err != nil { return err }