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
206 changes: 104 additions & 102 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,14 @@ that takes precedence over the file.
# Default store when neither -s nor -r is given.
default_store: myorg/secrets # env: HIMITSU_DEFAULT_STORE

# Where age private keys live: "disk" or "macos-keychain".
# Where the age private key lives:
# "disk" — `<data_dir>/key` (the default).
# "macos-keychain" — macOS Keychain entry under
# `io.darkmatter.himitsu.agekey.byfp.v1`. Switching
# to keychain on an already-initialized machine
# auto-migrates the on-disk secret into the keychain
# and removes `<data_dir>/key`. The pubkey file
# always stays on disk for fingerprint discovery.
key_provider: disk # env: HIMITSU_KEY_PROVIDER

# When true, every store-touching command first runs `git fetch` and
Expand Down
5 changes: 5 additions & 0 deletions rust/src/cli/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store,
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -983,6 +984,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store,
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -1011,6 +1013,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store,
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -1041,6 +1044,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store,
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};

let args = CodegenArgs {
Expand Down Expand Up @@ -1177,6 +1181,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store: project.clone(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};
let result = run_sops("ghost", None, &ctx);

Expand Down
4 changes: 2 additions & 2 deletions rust/src/cli/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use clap::Args;

use super::Context;
use crate::config::{self, env_resolver, validate_env_label};
use crate::crypto::{age, secret_value, tags as tag_grammar};
use crate::crypto::{secret_value, tags as tag_grammar};
use crate::error::{HimitsuError, Result};
use crate::reference::SecretRef;
use crate::remote::store;
Expand Down Expand Up @@ -74,7 +74,7 @@ pub fn run(args: ExecArgs, ctx: &Context) -> Result<()> {
// Load the age identity once so we don't re-parse the key file per
// resolved secret. `exec` is the first hot loop of decrypts and the
// win is real.
let identity = age::read_identity(&ctx.key_path())?;
let identity = ctx.load_identity()?;
let decrypted = decrypt_resolved(ctx, &identity, resolved)?;
let env_map = build_env_map(decrypted, &args.tags)?;

Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub struct ExportArgs {
}

pub fn run(args: ExportArgs, ctx: &Context) -> Result<()> {
let identity = crypto::read_identity(&ctx.key_path())?;
let identity = ctx.load_identity()?;

// List all secrets in the store.
let all_paths = store::list_secrets(&ctx.store, None)?;
Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub fn run(args: GenerateArgs, ctx: &Context) -> Result<()> {
}

// Load age identity for decryption.
let identity = crypto::read_identity(&ctx.key_path())?;
let identity = ctx.load_identity()?;

// Determine which envs to generate.
let env_names: Vec<String> = if let Some(ref env_name) = args.env {
Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn get_plaintext(ctx: &Context, path: &str) -> Result<Vec<u8>> {

/// Decrypt and return the full decoded SecretValue for a secret reference.
fn get_decoded(ctx: &Context, path: &str) -> Result<secret_value::Decoded> {
let identity = age::read_identity(&ctx.key_path())?;
let identity = ctx.load_identity()?;
get_decoded_with_identity(ctx, path, &identity)
}

Expand Down
3 changes: 3 additions & 0 deletions rust/src/cli/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ mod tests {
state_dir: std::path::PathBuf::from("/tmp"),
store: std::path::PathBuf::new(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};
let err = run(args, &ctx).unwrap_err();
assert!(
Expand All @@ -765,6 +766,7 @@ mod tests {
state_dir: std::path::PathBuf::from("/tmp"),
store: std::path::PathBuf::new(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};
let err = run(args, &ctx).unwrap_err();
assert!(
Expand Down Expand Up @@ -792,6 +794,7 @@ mod tests {
state_dir: std::path::PathBuf::from("/tmp"),
store: std::path::PathBuf::new(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};
let err = run(args, &ctx).unwrap_err();
assert!(
Expand Down
55 changes: 33 additions & 22 deletions rust/src/cli/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub fn run(args: InitArgs, ctx: &Context) -> Result<()> {
state_dir: config::state_dir(),
store: ctx.store.clone(),
recipients_path: ctx.recipients_path.clone(),
key_provider: ctx.key_provider.clone(),
};
return run_init(args, &patched_ctx);
}
Expand All @@ -83,41 +84,51 @@ pub(crate) fn run_init(args: InitArgs, ctx: &Context) -> Result<()> {
let data_dir = &ctx.data_dir;
let state_dir = &ctx.state_dir;

// ── 1. Ensure data_dir exists (keys, config) ──────────────────────────
let key_existed = data_dir.join("key").exists();

// ── 1. Ensure config exists, then resolve the active provider ────────
// The provider must be settled BEFORE we write any key material, since
// it decides whether the secret lands on disk or in the keychain. With
// the old order (write key → set provider), `--key-provider macos-keychain`
// produced a config that pointed at the keychain while the secret sat
// in `data_dir/key` — fingers-crossed that no one read it. (Bug fix.)
std::fs::create_dir_all(data_dir)?;

let key_path = data_dir.join("key");
let pubkey_path = data_dir.join("key.pub");

let pubkey = if !key_path.exists() {
let (secret, public) = age::keygen();
std::fs::write(
&key_path,
format!(
"# created: {}\n# public key: {public}\n{secret}\n",
timestamp()
),
)?;
std::fs::write(&pubkey_path, format!("{public}\n"))?;
public
} else {
read_public_key(data_dir)?
};

let config_path = config::config_path();
if !config_path.exists() {
config::Config::write_default(&config_path)?;
}

// ── 2. Handle --key-provider ──────────────────────────────────────────
if let Some(ref provider_str) = args.key_provider {
let provider: KeyProvider = provider_str.parse()?;
let mut cfg = config::Config::load(&config_path)?;
cfg.key_provider = provider;
cfg.save(&config_path)?;
}
let active_provider = config::Config::load(&config_path)?.key_provider;

// If the user just switched to keychain on an already-initialized
// machine, move the existing on-disk secret into the keychain. The
// pubkey file stays in place — it's the provider-agnostic "is
// initialized" probe.
if crate::crypto::keystore::needs_disk_to_keychain_migration(&active_provider, data_dir)? {
crate::crypto::keystore::migrate_disk_to_keychain(data_dir)?;
eprintln!("✓ Migrated age key from disk to macOS Keychain");
}

// ── 2. Generate a fresh keypair if none exists yet ───────────────────
let key_existed = crate::crypto::keystore::is_initialized(data_dir);
Comment on lines +108 to +118
let pubkey = if !key_existed {
let (secret, public) = age::keygen();
crate::crypto::keystore::store_new_key(
&active_provider,
data_dir,
&secret,
&public,
&timestamp(),
)?;
public
} else {
read_public_key(data_dir)?
};

// ── 3. Ensure state_dir exists (stores subdir) ────────────────────────
std::fs::create_dir_all(state_dir.join("stores"))?;
Expand Down
1 change: 1 addition & 0 deletions rust/src/cli/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store,
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
}
}

Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/ls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pub fn run(args: LsArgs, ctx: &Context) -> Result<()> {
let identity = if args.tag.is_empty() {
None
} else {
age::read_identity(&ctx.key_path()).ok()
ctx.load_identity().ok()
};

// ── Resolve qualified references ──────────────────────────────────────
Expand Down
33 changes: 28 additions & 5 deletions rust/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,31 @@ pub struct Context {
/// from the project-level `himitsu.yaml` `store.recipients_path` field.
/// When `None`, the default `.himitsu/recipients/` layout is used.
pub recipients_path: Option<String>,
/// Where the age private key lives. Resolved from `Config.key_provider`
/// at dispatcher boot so callers don't each re-read the config.
pub key_provider: crate::config::KeyProvider,
}

impl Context {
/// Path to the age private key file.
/// Path to the age private key file. Only valid for the
/// [`Disk`](crate::config::KeyProvider::Disk) provider — with the
/// keychain provider this path doesn't exist, so callers should
/// reach the secret through [`Self::load_identity`] instead of
/// reading the path directly.
Comment on lines +60 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use key.pub for self-recipient flows

The new keychain provider explicitly makes ctx.key_path() absent, but not all non-decryption callers were moved off it: join::read_own_pubkey() and recipient add --self still parse the private-key file to discover the public key. After himitsu init --key-provider macos-keychain, only key.pub is written, so himitsu join and himitsu recipient add --self ... fail even though the user is initialized. These callers should read ctx.pubkey_path() instead of the disk secret path.

Useful? React with 👍 / 👎.

pub fn key_path(&self) -> PathBuf {
self.data_dir.join("key")
crate::crypto::keystore::disk_secret_path(&self.data_dir)
}

/// Path to the age public key file.
/// Path to the age public key file. Always written (provider-agnostic).
#[allow(dead_code)]
pub fn pubkey_path(&self) -> PathBuf {
self.data_dir.join("key.pub")
crate::crypto::keystore::pubkey_path(&self.data_dir)
}

/// Load the user's age identity through the active provider. This is
/// the chokepoint: every command that decrypts goes through it.
pub fn load_identity(&self) -> Result<::age::x25519::Identity> {
crate::crypto::keystore::load_identity(&self.key_provider, &self.data_dir)
}

/// Directory containing managed store checkouts.
Expand Down Expand Up @@ -377,14 +390,15 @@ impl Cli {
&& !is_docs
&& !is_completions
&& !is_complete_paths
&& !data_dir.join("key").exists()
&& !crate::crypto::keystore::is_initialized(&data_dir)
{
eprintln!("First run — initializing himitsu...");
let ctx = Context {
data_dir: data_dir.clone(),
state_dir: state_dir.clone(),
store: PathBuf::new(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};
init::run(
init::InitArgs {
Expand Down Expand Up @@ -463,11 +477,15 @@ impl Cli {
}

let recipients_path = load_recipients_path_override(&store);
let key_provider = crate::config::Config::load(&crate::config::config_path())
.map(|c| c.key_provider)
.unwrap_or_default();
Comment on lines +480 to +482
let ctx = Context {
data_dir,
state_dir,
store,
recipients_path,
key_provider,
};

// Pre-dispatch: when `auto_pull` is on, fetch + fast-forward the
Expand Down Expand Up @@ -568,12 +586,16 @@ impl Cli {
// than erroring out.
let store = crate::config::resolve_store(None).unwrap_or_default();
let recipients_path = load_recipients_path_override(&store);
let key_provider = crate::config::Config::load(&crate::config::config_path())
.map(|c| c.key_provider)
.unwrap_or_default();
Comment on lines +589 to +591

let ctx = Context {
data_dir,
state_dir,
store,
recipients_path,
key_provider,
};
crate::tui::run(&ctx)
}
Expand Down Expand Up @@ -857,6 +879,7 @@ mod tests {
state_dir: tmp,
store: store.to_path_buf(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
}
}

Expand Down
1 change: 1 addition & 0 deletions rust/src/cli/recipient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store,
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};
(tmp, ctx)
}
Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/rekey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub struct RekeyArgs {
/// Note: `args.force` is accepted for forward-compat (future no-op detection)
/// but currently all matched secrets are always re-encrypted.
pub fn rekey_store(ctx: &Context, path_prefix: Option<&str>) -> Result<usize> {
let identity = age::read_identity(&ctx.key_path())?;
let identity = ctx.load_identity()?;
let recipients = age::collect_recipients(&ctx.store, ctx.recipients_path.as_deref())?;
if recipients.is_empty() {
return Err(HimitsuError::Recipient("no recipients found".into()));
Expand Down
1 change: 1 addition & 0 deletions rust/src/cli/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ mod tests {
state_dir: tmp.path().join("state"),
store: store.clone(),
recipients_path: None,
key_provider: crate::config::KeyProvider::default(),
};

cmd_refresh(&ctx).unwrap();
Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pub fn search_core(ctx: &Context, query: &str, tag_filter: &[String]) -> Result<
// description from each secret's encrypted payload. If the identity
// isn't available (fresh install, CI test fixture, missing key file)
// we still return search results — just without descriptions.
let identity = age::read_identity(&ctx.key_path()).ok();
let identity = ctx.load_identity().ok();

for (slug, store_path) in collect_stores(ctx)? {
let paths = store::list_secrets(&store_path, None).unwrap_or_default();
Expand Down
1 change: 1 addition & 0 deletions rust/src/cli/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub fn run(args: SyncArgs, ctx: &Context) -> Result<()> {
state_dir: ctx.state_dir.clone(),
store: store_path.clone(),
recipients_path: None,
key_provider: ctx.key_provider.clone(),
};

// Commit any pre-existing pending changes (e.g. from a prior sync that
Expand Down
2 changes: 1 addition & 1 deletion rust/src/cli/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub fn run(args: TagArgs, ctx: &Context) -> Result<()> {
};

let ciphertext = store::read_secret(&effective_store, &secret_path)?;
let identity = age::read_identity(&ctx.key_path())?;
let identity = ctx.load_identity()?;
let plaintext = age::decrypt(&ciphertext, &identity)?;
let mut decoded = secret_value::decode(&plaintext);

Expand Down
Loading
Loading