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
219 changes: 117 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
103 changes: 0 additions & 103 deletions proto/secrets.proto
Original file line number Diff line number Diff line change
Expand Up @@ -172,106 +172,3 @@ message RecipientInfo {
// When the key was added to the store.
google.protobuf.Timestamp added_at = 5;
}

// ---------------------------------------------------------------------------
// Share envelope — transport-agnostic secret sharing payload
// ---------------------------------------------------------------------------

// The payload sent when sharing one or more secrets with an external
// recipient. Transported over GitHub PR, Nostr DM, etc.
message ShareEnvelope {
// Envelope format version (currently 1).
uint32 version = 1;

// Identifier of the sender (age public key).
string sender_public_key = 2;

// Identifier of the intended recipient (age public key).
string recipient_public_key = 3;

// The individual secret payloads being shared.
repeated SharedSecret secrets = 4;

// When this share was created.
google.protobuf.Timestamp created_at = 5;

// An optional human-readable note from the sender.
string message = 6;

// Transport used to deliver this envelope.
ShareTransport transport = 7;

// Unique nonce / request-id to prevent replay.
string nonce = 8;

// TTL in seconds after which the share should be considered expired.
// 0 means no expiry.
uint32 ttl_seconds = 9;
}

// A single secret inside a share envelope.
message SharedSecret {
// The key name being shared.
string key_name = 1;

// Environment of the shared secret.
string environment = 2;

// The age ciphertext, encrypted to the recipient's public key.
bytes ciphertext = 3;

// Content hash of the plaintext (so the recipient can verify after
// decryption).
string plaintext_hash = 4;
}

// Transport mechanism used for sharing.
enum ShareTransport {
SHARE_TRANSPORT_UNSPECIFIED = 0;
SHARE_TRANSPORT_GITHUB_PR = 1; // GitHub Pull Request inbox
SHARE_TRANSPORT_NOSTR_DM = 2; // Nostr encrypted DM (NIP-04 / NIP-44)
SHARE_TRANSPORT_FILE = 3; // Local file exchange
SHARE_TRANSPORT_QR = 4; // QR code (small secrets only)
}

// ---------------------------------------------------------------------------
// Sync state — tracks convergence between local and remote
// ---------------------------------------------------------------------------

// Per-remote sync state persisted locally to enable efficient diffing.
message SyncState {
// The remote this state tracks.
string remote_id = 1;

// Last successful sync timestamp.
google.protobuf.Timestamp last_sync = 2;

// Per-entry state at last sync (keyed by path).
map<string, SyncEntryState> entries = 3;

// The git commit SHA the store was at during last sync (if applicable).
string last_commit = 4;
}

// State of a single entry at last sync.
message SyncEntryState {
// Content hash at last sync.
string content_hash = 1;

// Timestamp of the entry at last sync.
google.protobuf.Timestamp updated_at = 2;

// Sync disposition.
SyncDisposition disposition = 3;
}

// Outcome of comparing local vs remote state for one entry.
enum SyncDisposition {
SYNC_DISPOSITION_UNSPECIFIED = 0;
SYNC_DISPOSITION_IN_SYNC = 1;
SYNC_DISPOSITION_LOCAL_NEWER = 2;
SYNC_DISPOSITION_REMOTE_NEWER = 3;
SYNC_DISPOSITION_CONFLICT = 4;
SYNC_DISPOSITION_LOCAL_ONLY = 5;
SYNC_DISPOSITION_REMOTE_ONLY = 6;
}
29 changes: 21 additions & 8 deletions rust/src/cli/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ pub fn run(args: CheckArgs, ctx: &Context) -> Result<()> {
///
/// Priority:
/// 1. Explicit `args.store` slug.
/// 2. Slugs referenced in a project config found in the CWD ancestry.
/// 2. Slugs referenced in global or project config.
/// 3. All known stores (`list_remotes()`).
fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result<Vec<String>> {
// 1. Explicit store argument
Expand All @@ -113,34 +113,47 @@ fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result<Vec<String>> {
return Ok(vec![slug.clone()]);
}

// 2. Project config
// 2. Global + project config
let global = config::Config::load(&config::config_path()).unwrap_or_default();
let mut slugs = collect_stores_from_global_config(&global);
if let Some((cfg, _path)) = config::load_project_config() {
let slugs = collect_stores_from_project_config(&cfg);
if !slugs.is_empty() {
return Ok(slugs.into_iter().collect());
}
slugs.extend(collect_stores_from_project_config(&cfg));
}
if !slugs.is_empty() {
return Ok(slugs.into_iter().collect());
}

// 3. All known stores
crate::remote::list_remotes()
}

fn collect_stores_from_global_config(cfg: &config::Config) -> BTreeSet<String> {
collect_store_slugs(cfg.default_store.as_ref(), &cfg.envs)
}

/// Extract unique store slugs referenced in a project config.
///
/// Sources:
/// - `default_store` field
/// - Paths inside `envs` entries that contain an `org/repo` prefix (e.g.
/// `"myorg/secrets/prod/DB_PASS"` → slug `"myorg/secrets"`).
fn collect_stores_from_project_config(cfg: &config::ProjectConfig) -> BTreeSet<String> {
collect_store_slugs(cfg.default_store.as_ref(), &cfg.envs)
}

fn collect_store_slugs(
default_store: Option<&String>,
envs: &std::collections::BTreeMap<String, Vec<config::EnvEntry>>,
) -> BTreeSet<String> {
let mut slugs = BTreeSet::new();

if let Some(ref s) = cfg.default_store {
if let Some(s) = default_store {
if config::validate_remote_slug(s).is_ok() {
slugs.insert(s.clone());
}
}

for entries in cfg.envs.values() {
for entries in envs.values() {
for entry in entries {
// Tag selectors don't carry a path — they expand at resolve time
// against whatever store the caller already chose. They cannot
Expand Down
25 changes: 15 additions & 10 deletions rust/src/cli/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,22 +135,21 @@ fn run_sops(label: &str, output_override: Option<&str>, ctx: &Context) -> Result
// loading any config. `resolve` will also validate — this is defensive.
validate_env_label(label)?;

// Load project config — sops mode needs `cfg.envs` to resolve the label.
let (project_cfg, _cfg_path) = config::load_project_config().ok_or_else(|| {
HimitsuError::ProjectConfigRequired(
"no project config found (himitsu.yaml); codegen <env> needs an `envs:` map".into(),
)
})?;
let envs = config::load_effective_envs()?;

if !project_cfg.envs.contains_key(label) {
if !envs.contains_key(label) {
return Err(HimitsuError::InvalidConfig(format!("unknown env: {label}")));
}

// Enumerate store secrets so the resolver can expand wildcards/globs.
let secrets = crate::remote::store::list_secrets(&ctx.store, None)?;
let identity = ctx.load_identity()?;
let tag_lookup = |path: &str| {
crate::cli::get::get_decoded_with_identity(ctx, path, &identity).map(|decoded| decoded.tags)
};

// Resolve into the nested EnvNode tree.
let tree = env_resolver::resolve(&project_cfg.envs, label, &secrets)?;
let tree = env_resolver::resolve_with_tags(&envs, label, &secrets, &tag_lookup)?;

// Walk the tree and decrypt each Leaf into a plaintext YAML value.
let yaml_tree = materialize_tree(&tree, ctx)?;
Expand Down Expand Up @@ -199,8 +198,9 @@ fn default_sops_output_name(label: &str) -> String {
fn materialize_tree(node: &env_resolver::EnvNode, ctx: &Context) -> Result<serde_yaml::Value> {
match node {
env_resolver::EnvNode::Leaf { secret_path } => {
let bytes = crate::cli::get::get_plaintext(ctx, secret_path)?;
let s = String::from_utf8(bytes).map_err(|e| {
let decoded = crate::cli::get::get_decoded(ctx, secret_path)?;
crate::cli::get::warn_if_expired(secret_path, &decoded);
let s = String::from_utf8(decoded.data).map_err(|e| {
HimitsuError::DecryptionFailed(format!("non-UTF-8 secret at '{secret_path}': {e}"))
})?;
Ok(serde_yaml::Value::String(s))
Expand Down 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
44 changes: 24 additions & 20 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 All @@ -93,26 +93,29 @@ struct ResolvedRef {
fn resolve_ref(ref_str: &str, ctx: &Context) -> Result<Vec<ResolvedRef>> {
// Env labels live in their own namespace and always win when they match
// exactly: project authoring intent beats path coincidence.
if let Some((cfg, _)) = config::load_project_config() {
if cfg.envs.contains_key(ref_str) {
if config::is_wildcard_label(ref_str) {
return Err(HimitsuError::NotSupported(format!(
"exec does not support wildcard env labels ({ref_str:?}); \
let envs = config::load_effective_envs()?;
if envs.contains_key(ref_str) {
if config::is_wildcard_label(ref_str) {
return Err(HimitsuError::NotSupported(format!(
"exec does not support wildcard env labels ({ref_str:?}); \
pass a concrete env or use `himitsu codegen` for templated output"
)));
}
validate_env_label(ref_str)?;
let available = store::list_secrets(&ctx.store, None)?;
let tree = env_resolver::resolve(&cfg.envs, ref_str, &available)?;
let leaves = collect_env_leaves(&tree);
return Ok(leaves
.into_iter()
.map(|(key, secret_path)| ResolvedRef {
secret_path,
explicit_key: Some(key),
})
.collect());
)));
}
validate_env_label(ref_str)?;
let available = store::list_secrets(&ctx.store, None)?;
let identity = ctx.load_identity()?;
let tag_lookup = |path: &str| {
super::get::get_decoded_with_identity(ctx, path, &identity).map(|decoded| decoded.tags)
};
let tree = env_resolver::resolve_with_tags(&envs, ref_str, &available, &tag_lookup)?;
let leaves = collect_env_leaves(&tree);
return Ok(leaves
.into_iter()
.map(|(key, secret_path)| ResolvedRef {
secret_path,
explicit_key: Some(key),
})
.collect());
}

if let Some(prefix) = ref_str.strip_suffix("/*") {
Expand Down Expand Up @@ -179,6 +182,7 @@ fn decrypt_resolved(
refs.into_iter()
.map(|r| {
let decoded = super::get::get_decoded_with_identity(ctx, &r.secret_path, identity)?;
super::get::warn_if_expired(&r.secret_path, &decoded);
Ok((r, decoded))
})
.collect()
Expand Down
9 changes: 5 additions & 4 deletions rust/src/cli/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use clap::Args;

use crate::cli::Context;
use crate::config::{load_project_config, ProjectConfig};
use crate::crypto::age as crypto;
use crate::crypto::{age as crypto, secret_value};
use crate::error::{HimitsuError, Result};
use crate::remote::store;

Expand Down 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 Expand Up @@ -64,8 +64,9 @@ pub fn run(args: ExportArgs, ctx: &Context) -> Result<()> {
let mut secrets: BTreeMap<String, String> = BTreeMap::new();
for path in &matched {
let ciphertext = store::read_secret(&ctx.store, path)?;
let plaintext_bytes = crypto::decrypt(&ciphertext, &identity)?;
let plaintext = String::from_utf8(plaintext_bytes).map_err(|e| {
let decoded = secret_value::decode(&crypto::decrypt(&ciphertext, &identity)?);
super::get::warn_if_expired(path, &decoded);
let plaintext = String::from_utf8(decoded.data).map_err(|e| {
HimitsuError::DecryptionFailed(format!("non-UTF-8 secret at '{path}': {e}"))
})?;
secrets.insert((*path).clone(), plaintext);
Expand Down
Loading
Loading