This document defines an execution plan for the vNext architecture:
- centralized store (
~/.himitsu) age-only secret model (vars/<env>/<KEY>.age)- transport-agnostic sharing protocol
- GitHub PR inbox + Nostr send/receive
- full Rust rewrite of current shell implementation
- Phase 1 complete
- Create Rust crate and executable
himitsu. - Implement command parsing and logging framework.
Cargo.toml # Single crate, all dependencies
rust/src/
├── main.rs # Entry point, tracing init, clap dispatch
├── error.rs # HimitsuError enum (thiserror)
└── cli/
├── mod.rs # Top-level Cli enum + subcommand dispatch
├── init.rs # Stub
├── set.rs # Stub
├── get.rs # Stub
├── ls.rs # Stub
├── encrypt.rs # Stub
├── decrypt.rs # Stub
├── sync.rs # Stub
├── search.rs # Stub
├── recipient.rs # Stub
├── group.rs # Stub
├── remote.rs # Stub
├── share.rs # Stub
├── inbox.rs # Stub
├── schema.rs # Stub
├── codegen.rs # Stub
└── import.rs # Stub
flake.nix # Updated: build Rust binary alongside shell
.github/workflows/rust.yml # CI: fmt, clippy, test on macOS + Linux
cargo test # All unit tests
cargo clippy -- -D warnings # Lint
cargo fmt -- --check # Format check-
himitsu --helpprints full command tree with all subcommands -
himitsu --versionprints version string -
himitsu <subcommand> --helpworks for every subcommand stub - Binary builds on macOS (aarch64-apple-darwin)
- Binary builds on Linux (x86_64-unknown-linux-gnu)
-
nix buildproduces both shell and Rust binaries
-
himitsu --helpworks with planned command tree. - Project builds on macOS and Linux.
- Slow startup due to heavy crate selection.
- Over-designing module boundaries too early.
- Phase 2 complete
- Implement config loading and mode detection.
- Implement remote resolution and local secret operations.
- Add optional macOS Keychain storage for generated age private keys.
- Add
SOPS_AGE_KEY_CMDkey resolution that checks Keychain first, then file fallback.
rust/src/
├── config/
│ ├── mod.rs # detect_mode() → ProjectMode | UserMode
│ ├── global.rs # GlobalConfig: parse ~/.himitsu/config.yaml
│ ├── project.rs # ProjectConfig: parse <repo>/.himitsu.yaml
│ └── remote.rs # RemoteConfig: parse remote himitsu.yaml
├── keyring/
│ ├── mod.rs # KeyProvider trait + scope/fingerprint mapping
│ └── macos.rs # macOS Keychain adapter via `security` CLI
├── remote/
│ ├── mod.rs # Remote discovery, resolution, list known remotes
│ └── store.rs # Secret file I/O: read/write vars/<env>/<KEY>.age
├── git.rs # Git CLI wrapper: clone, commit, push, pull, status
├── crypto/
│ ├── mod.rs # Trait defs: Encryptor, Decryptor
│ └── age.rs # age crate: keygen, encrypt, decrypt, parse recipients
├── index/
│ ├── mod.rs # SecretIndex: open db, query, upsert
│ └── schema.sql # CREATE TABLE statements (embedded via include_str!)
├── cli/
│ ├── init.rs # Full implementation
│ ├── set.rs # Full implementation
│ ├── get.rs # Full implementation
│ ├── ls.rs # Full implementation
│ ├── encrypt.rs # Full implementation
│ ├── decrypt.rs # Full implementation
│ ├── search.rs # Full implementation
│ ├── recipient.rs # Full implementation
│ ├── group.rs # Full implementation
│ └── remote.rs # Full implementation (add/push/pull/status)
src/lib/
├── common.sh # `SOPS_AGE_KEY_CMD` keychain-first lookup helper
└── init.sh # Optional keychain save when generating age key
tests/
├── integration/
│ ├── init_test.rs # CLI integration tests for init
│ ├── set_get_test.rs # set → get roundtrip
│ ├── ls_test.rs # ls output format
│ ├── encrypt_decrypt_test.rs # encrypt → decrypt roundtrip
│ ├── recipient_test.rs # recipient add/rm/ls
│ ├── group_test.rs # group add/rm/ls
│ ├── remote_test.rs # remote add/push/pull/status
│ └── search_test.rs # cross-remote search
└── fixtures/ # (from Phase 0)
- Scope pointer item:
service=io.darkmatter.himitsu.agekey.scope.v1,account=gh:<org>:<repo>:<group>with value<fingerprint>. - Key material item:
service=io.darkmatter.himitsu.agekey.byfp.v1,account=<fingerprint>with valueAGE-SECRET-KEY-.... - Resolution order for
SOPS_AGE_KEY_CMD: scope pointer → fingerprint key item →SOPS_AGE_KEY_FILEfallback. - Scope values are normalized (
org/repolowercase,groupescaped) to ensure unique matching across all org/repo/group combos.
cargo test # Unit + integration
cargo test --test '*' # Integration tests only-
config::detect_modereturnsProjectModewhen.git+.himitsu.yamlexist -
config::detect_modereturnsUserModewhen.gitexists without.himitsu.yaml -
config::detect_modereturnsUserModewhen no.gitfound -
config::global::parseloads valid config.yaml -
config::global::parserejects malformed YAML with clear error -
config::project::parsereadsremote:field -
config::remote::parseloads policies, identity_sources -
crypto::age::keygenproduces valid x25519 keypair -
crypto::age::encrypt→decryptroundtrip preserves plaintext -
crypto::age::encryptwith multiple recipients succeeds -
crypto::age::decryptwith wrong key fails with clear error -
keyring::scope::account_fornormalizesorg/repo/groupand yields deterministic account ids -
keyring::scope::account_foravoids collisions across similar org/repo/group combos -
keyring::mapping::scope_to_fingerprintstores and reads pointer values correctly -
keyring::mapping::scope_to_fingerprintupdates cleanly on key rotation -
keyring::macos::store_private_keyandload_private_keyroundtrip via mockedsecurityCLI -
crypto::age::resolve_private_keyprefers keychain when enabled and falls back to file key -
remote::store::write_secretcreatesvars/<env>/<KEY>.age -
remote::store::read_secretreads and decrypts.agefile -
remote::store::list_secretsreturns all keys for an env -
remote::store::list_secretshandles nested subdirectories -
git::runexecutes git commands and captures output -
git::runreturns error for non-zero exit codes -
index::SecretIndex::upsertinserts new entry -
index::SecretIndex::upsertupdates existing entry (same remote+path) -
index::SecretIndex::searchmatches partial key names -
index::SecretIndex::searchreturns results across multiple remotes
-
initcreates~/.himitsu/with keys/, config.yaml, state/ -
initis idempotent (running twice doesn't error or overwrite keys) -
initwith keychain enabled stores generated private key in Keychain - keychain scope pointer is unique for every
<org>/<repo>/<group>combination -
SOPS_AGE_KEY_CMDresolves keychain key for scope before checkingSOPS_AGE_KEY_FILE -
SOPS_AGE_KEY_CMDfalls back to file-based key when keychain item is missing -
set prod API_KEY "secret"createsvars/prod/API_KEY.age -
get prod API_KEYreturns"secret"after set -
setthengetwith multiline values preserves newlines -
setthengetwith special characters (quotes, backslashes, unicode) -
lswith no args lists all envs -
ls prodlists keys in prod env -
encryptre-encrypts all secrets for current recipients -
decryptis not implemented / errors (no plaintext at rest) -
recipient add --self --group teamwrites pubkey file to recipients/team/ -
recipient addwith explicit--age-keywrites correct .pub file -
recipient rmremoves the key file -
recipient lsshows all recipients, optionally filtered by group -
group add mygroupcreates directory + updates data.json -
group rm mygroupremoves directory + updates data.json -
group rm commonis rejected (reserved) -
group lslists groups with recipient counts -
remote add <org/repo>clones repo into~/.himitsu/data/ -
remote pushcommits and pushes changes -
remote pullfetches latest from origin -
remote statusshows clean/dirty state -
search <query>matches key names across remotes -
searchwith no matches returns empty output, exit 0 - Golden fixture parity: outputs match captured shell fixtures
- Core local commands produce expected filesystem results.
- Equivalent flows succeed on baseline fixtures.
-
himitsu searchreturns results across multiple remotes. - Keychain mode stores generated age keys and decrypts via
SOPS_AGE_KEY_CMDwithout plaintext key files required. - Key lookup remains uniquely addressable for all
<org>/<repo>/<group>scopes.
- Edge cases with path expansion and symlinked directories.
- Value quoting/newline handling in
set. - Keychain access prompts/ACL behavior may break CI or non-interactive sessions.
- Scope normalization bugs could cause key lookup misses.
- Phase 3 complete
- Replace env-only recipient model with path policy resolution.
- Support local groups and remote refs in one normalized pipeline.
rust/src/
├── policy/
│ ├── mod.rs # PolicyEngine: load policies, resolve for path
│ └── resolver.rs # RecipientRef parsing, expansion, dedup, ordering
cargo test policy # Run policy module tests only- Longest
path_prefixwins:vars/prod/beatsvars/ -
include: [group:admins]expands to all keys inrecipients/admins/ -
exclude: [group:contractors]removes matching recipients from result -
include+excludeon same policy: exclude takes precedence - Multiple policies: each path resolves to correct recipient set
- No matching policy: falls back to all recipients (or error, TBD)
-
remote:github:org/keys#team=securityparses correctly -
email:user@domain.comparses correctly -
ens:name.ethparses correctly -
nostr:npub1...parses correctly - Duplicate recipients across groups are deduplicated
- Recipient ordering is deterministic regardless of input order
- Empty groups produce no recipients (not an error)
- Recipient resolution snapshots are deterministic.
- Policy tests cover include/exclude precedence.
- Policy complexity creep.
- Ambiguous behavior at path boundaries.
- Phase 4 complete
- Implement signed envelope protocol.
- Ship first complete external sharing path via GitHub PR inbox.
rust/src/
├── crypto/
│ └── signing.rs # Ed25519 keygen, sign, verify
├── protocol/
│ ├── mod.rs # Shared types, canonical JSON helpers
│ ├── envelope.rs # Envelope struct, serde, sign/verify methods
│ ├── payload.rs # Payload struct, serde
│ └── profile.rs # Profile struct, serde
├── transport/
│ ├── mod.rs # Transport trait: send, list, fetch
│ └── github_pr.rs # GitHub PR inbox: create PR, list inbox files
├── inbox/
│ ├── mod.rs # Accept/reject pipeline orchestration
│ └── replay.rs # Replay DB: SQLite envelope-id tracking
├── cli/
│ ├── share.rs # Full implementation
│ └── inbox.rs # Full implementation
cargo test protocol # Protocol struct tests
cargo test transport # Transport adapter tests
cargo test inbox # Inbox pipeline tests-
Envelope::newcreates valid envelope with UUIDv7 id -
Envelope::signproduces Ed25519 signature over JCS-canonicalized body -
Envelope::verifysucceeds with correct key -
Envelope::verifyfails with wrong key -
Envelope::verifyfails if any field is tampered after signing - JCS canonicalization is deterministic (same input → same bytes)
- JCS canonicalization handles unicode, nested objects, arrays
-
Payloadserializes/deserializes with secrets array -
Payloadsupports both utf8 and base64 encoding -
Profileround-trips through JSON -
Profilerejects missing required fields (ref, age_recipients) -
replay::ReplayDb::recordinserts envelope id -
replay::ReplayDb::is_seenreturns true for recorded id -
replay::ReplayDb::is_seenreturns false for unseen id
- Full send flow:
share send --to github:org/repo --path ... --value ...creates PR with envelope JSON (mocked GitHub API via wiremock) - Envelope JSON in PR body is valid and parseable
- Full receive flow:
inbox listshows pending envelopes (mocked) -
inbox accept <id>verifies signature, decrypts, writes.agefile -
inbox acceptwith duplicate envelope id is rejected -
inbox acceptwith expired envelope is rejected -
inbox reject <id>records envelope as processed without writing secret
- Sender can share to external inbox repo via PR.
- Receiver can verify, decrypt, and apply encrypted output.
- Duplicate envelope IDs are rejected.
- GitHub auth/token scope complexity.
- Canonicalization bugs causing signature mismatch.
- Phase 5 complete
- Add full Nostr roundtrip delivery.
rust/src/
├── transport/
│ └── nostr.rs # Nostr relay adapter: publish, subscribe, parse
cargo test transport::nostr # Nostr transport tests-
NostrTransport::sendpublishes kind 30420 event with correct tags - Event content is valid JSON envelope
-
ptag contains recipient npub hex -
dtag contains envelope id -
ttag ishimitsu-envelope -
expirationtag mirrorsmeta.expires_atwhen present -
NostrTransport::listsubscribes and returns matching envelopes - Received events are parsed into valid
Envelopestructs - HSP signature is verified (not just Nostr event signature)
- Full roundtrip: send via nostr → list → accept (requires local relay)
-
share send --to nostr:...publishes valid event. -
inbox list --transport nostrreturns envelopes. -
inbox acceptsucceeds end-to-end.
- Relay reliability and event propagation latency.
- Metadata inconsistencies between relays.
- Phase 6 complete
- Implement external identity resolution beyond GitHub.
rust/src/
├── identity/
│ ├── mod.rs # Resolver trait, ResolvedProfile, cache layer
│ ├── github.rs # GitHub keys repo: fetch team keys, parse .pub files
│ ├── email.rs # HTTP fetch /.well-known/himitsu.json, parse profile
│ ├── ens.rs # ENS text record lookup (feature-gated)
│ └── nostr.rs # npub normalization, optional profile metadata
cargo test identity # All resolver tests-
GithubResolver::resolvefetches keys fromorg/keysrepo structure -
GithubResolver::resolvecorrectly parses#team=<name>fragment -
EmailResolver::resolvefetcheshttps://domain/.well-known/himitsu.json -
EmailResolver::resolveparses profile with age_recipients and inbox -
EmailResolver::resolvereturns error for 404 / malformed JSON -
EnsResolver::resolvereadshimitsu_public_keytext record -
EnsResolver::resolvereadshimitsu_inboxtext record -
NostrResolver::resolvenormalizes npub to hex pubkey - Cache layer stores resolved profiles in
~/.himitsu/cache/remote-identities/ - Cache hit returns stored profile without network call
- Cache miss triggers network fetch and stores result
- Lockfile pinning: resolver verifies fingerprints against
sources.lock.json - Lockfile mismatch produces warning/error (not silent substitution)
All network tests use wiremock for HTTP mocking. ENS tests mock the RPC
endpoint.
-
share send --to email:...resolves profile and sends. -
share send --to ens:...resolves profile and sends. - Resolver output is cached and reproducible.
- Resolver trust and TOFU pitfalls.
- Network timeout behavior degrading CLI UX.
- Phase 7 complete
- Add schema-backed validation and autocomplete support.
rust/src/
├── schema/
│ ├── mod.rs # Orchestration: generate static + dynamic schemas
│ ├── static_schema.rs # Generate schemas/himitsu.schema.json
│ └── dynamic.rs # Generate schemas/recipients.schema.json from live data
├── cli/
│ └── schema.rs # Full implementation
cargo test schema # Schema generation tests- Static schema is valid JSON Schema draft 2020-12
- Static schema validates a correct
himitsu.yaml - Static schema rejects missing required fields
- Dynamic schema includes local group names as enum values
- Dynamic schema includes remote team refs as enum values
-
schema refreshregenerates dynamic schema from current state - Config validation errors include path + field + clear message
- YAML editors can autocomplete groups/remote refs.
- Invalid config points to path+field with clear message.
- Dynamic schema becoming stale without refresh.
- Large remote identity sets impacting schema size.
- Phase 8 complete
- Implement sync destinations for project-level encrypted secret delivery.
- Implement typed codegen for downstream consumers.
- Implement import from external secret stores.
rust/src/
├── cli/
│ ├── sync.rs # Full implementation (sync destinations + autosync)
│ ├── codegen.rs # Full implementation
│ └── import.rs # Full implementation
├── codegen/
│ ├── mod.rs # Orchestration: detect lang, merge envs, write output
│ ├── typescript.rs # TypeScript interface + const generation
│ ├── golang.rs # Go struct generation
│ └── python.rs # Python dataclass generation
├── import/
│ ├── mod.rs # Import trait, dispatch by source type
│ ├── sops.rs # SOPS YAML/JSON parser: decrypt via sops binary, extract keys
│ └── onepassword.rs # 1Password: shell out to `op`, parse item fields
cargo test codegen # Codegen output tests
cargo test import # Import source tests
cargo test --test sync_test # Sync integration tests-
syncwrites encrypted.agefiles to project directory -
syncdoes not write plaintext anywhere -
syncis idempotent (running twice produces same result) -
autosync_on: settriggers sync afterhimitsu set -
autosync_on: pushtriggers sync afterhimitsu remote push - Context isolation:
setin project mode only writes to project's remote
- TypeScript output is syntactically valid (parseable by tsc)
- Go output is syntactically valid (parseable by go vet)
- Python output is syntactically valid (parseable by python -c)
- Codegen merges common env with specific env (common first, env overrides)
- Codegen respects app-scoped extraction from data.json
- Snapshot tests: output matches expected for each language
-
import --sops file.sops.yaml --env prodextracts all keys - Each extracted key is written as
vars/prod/<KEY>.age -
import --sopshandles nested YAML keys (flattens with_separator) -
import --sopswith--overwritereplaces existing secrets -
import --sopswithout--overwriteskips existing secrets -
import --op "op://vault/item/field" --env prod --key TOKENwrites one secret -
import --op "op://vault/item" --env prodwrites all fields from item -
import --opfails gracefully whenopCLI is not installed
-
syncwrites encrypted files to project without plaintext. - Autosync triggers correctly based on configured event.
- Codegen produces valid typed output for each supported language.
- SOPS import decrypts and re-encrypts all keys into
vars/<env>/<KEY>.age. - 1Password import fetches and encrypts items into remote's format.
- SOPS format variations (YAML vs JSON, nested vs flat keys).
- 1Password CLI version/auth differences across platforms.
- Autosync timing edge cases (concurrent mutations from multiple devices).
# All tests (unit + integration)
cargo test
# Unit tests only (fast, no I/O)
cargo test --lib
# Integration tests only
cargo test --test '*'
# Specific module tests
cargo test config
cargo test crypto
cargo test policy
cargo test protocol
cargo test index
# With output (see println! in tests)
cargo test -- --nocapture
# Snapshot tests (update snapshots after intentional changes)
cargo insta test
cargo insta review
# Lint and format
cargo clippy -- -D warnings
cargo fmt -- --checkjobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo fmt -- --check
- run: cargo clippy -- -D warnings
- run: cargo test| Crate | Purpose |
|---|---|
assert_cmd |
Run CLI binary, assert stdout/stderr/exit code |
predicates |
Fluent assertions for CLI output matching |
tempfile |
Isolated temp directories per test |
insta |
Snapshot testing for output format stability |
wiremock |
HTTP mocking for GitHub API, .well-known, ENS RPC |
rusqlite |
Already a runtime dep; used directly in index tests |
tests/
├── integration/
│ ├── helpers/
│ │ └── mod.rs # Shared setup: temp dir, init himitsu, create remote
│ ├── init_test.rs
│ ├── set_get_test.rs
│ ├── ls_test.rs
│ ├── encrypt_decrypt_test.rs
│ ├── recipient_test.rs
│ ├── group_test.rs
│ ├── remote_test.rs
│ ├── search_test.rs
│ ├── policy_test.rs
│ ├── envelope_test.rs
│ ├── inbox_test.rs
│ ├── sync_test.rs
│ ├── codegen_test.rs
│ └── import_test.rs
├── fixtures/
│ ├── golden/ # Captured shell outputs for parity
│ ├── configs/ # Sample YAML configs
│ ├── remotes/ # Sample remote directory layouts
│ └── envelopes/ # Sample signed envelope JSON files
Every integration test follows this pattern:
use assert_cmd::Command;
use tempfile::TempDir;
fn himitsu() -> Command {
Command::cargo_bin("himitsu").unwrap()
}
#[test]
fn set_get_roundtrip() {
let home = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
// init
himitsu()
.env("HOME", home.path())
.arg("init")
.assert()
.success();
// set
himitsu()
.env("HOME", home.path())
.current_dir(project.path())
.args(["set", "prod", "API_KEY", "secret123"])
.assert()
.success();
// get
himitsu()
.env("HOME", home.path())
.current_dir(project.path())
.args(["get", "prod", "API_KEY"])
.assert()
.success()
.stdout("secret123\n");
}- Envelope signature verification tests.
- Replay DB integrity checks.
- Sender allowlist enforcement.
- Secret redaction in logs and errors.
- Structured logs for send/accept operations.
- Debug mode with trace IDs per envelope.
- Cache remote identity lookups.
- Batch decrypt/encrypt where safe.
- Avoid blocking relay/network calls on unrelated commands.
- M1: Rust scaffold builds,
--helpworks (Phase 1) - M2: Local secret parity: init/set/get/ls/encrypt/decrypt/sync/remote/search (Phase 2)
- M3: Recipient policy engine with include/exclude (Phase 3)
- M4: GitHub PR inbox send/receive end-to-end (Phase 4)
- M5: Nostr send/receive end-to-end (Phase 5)
- M6: Email/ENS/Nostr identity resolvers with caching (Phase 6)
- M7: Schema validation and autocomplete (Phase 7)
- M8: Sync + codegen + import (Phase 8)
The rewrite is complete when:
- 1. Rust CLI fully replaces shell runtime.