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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 4 additions & 2 deletions cloud/enckey_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions cloud/enckey_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down
13 changes: 8 additions & 5 deletions cloud/identities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cloud/runsync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions cloud/session_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
12 changes: 6 additions & 6 deletions cloud/session_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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{
Expand Down Expand Up @@ -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")
Expand Down
33 changes: 28 additions & 5 deletions identity/handoff/handoff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 65 additions & 10 deletions identity/handoff/handoff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions identity/unlocked_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading