Skip to content

Commit db0228c

Browse files
UnbreakableMJclaude
andcommitted
feat(core,cli,tui): diceware passphrase generation
Add generate_passphrase to vault-core (EFF large wordlist, 7776 words, CC-BY-3.0, vendored at crates/vault-core/src/wordlist.rs), wired through `vault generate --passphrase` and the TUI generator overlay (Tab switches Password<->Passphrase). Preferences persist via a new [generate] config table; generation stays fully client-side, so no secret touches the agent or network. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4d9780e commit db0228c

13 files changed

Lines changed: 9011 additions & 121 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,26 @@ range may break in any release.
9393
partially-populated environment. See `docs/exec.md` for the grammar and
9494
what env-var injection does and doesn't protect against.
9595

96+
- **Passphrase generation (`vault generate --passphrase` + TUI).** The generator
97+
now produces diceware passphrases — N words drawn from the EFF large wordlist
98+
(7776 words, the list Bitwarden uses) joined by a separator, with optional
99+
capitalization and an appended digit — alongside the existing character
100+
passwords. CLI: `--passphrase` plus `--words` (3–20), `--separator`,
101+
`--[no-]capitalize`, `--[no-]include-number`; `--json` emits the passphrase
102+
shape. TUI generator overlay: `Tab` switches Password⇄Passphrase, `+`/`-`
103+
adjust the count (length or words), and `a` / `n` / `e` toggle capitalize /
104+
number / separator. Defaults match Bitwarden (3 words, `-`, no caps, no
105+
number). New `[generate]` config keys (`generate.mode` / `length` / classes /
106+
`words` / `separator` / `capitalize` / `include_number`) remember your
107+
preferences — the TUI overlay opens with them and `vault generate` uses them as
108+
defaults (explicit flags override). Generation stays fully client-side in
109+
`vault-core` (`generate_passphrase`, unbiased rejection sampling over
110+
`getrandom`), so no secret touches the agent or the network. The wordlist is
111+
the EFF's CC-BY-3.0 data — the project's first third-party-derived in-tree file
112+
— vendored at `crates/vault-core/src/wordlist.rs` with a `REUSE.toml` carve-out,
113+
`LICENSES/CC-BY-3.0.txt`, and a `CREDITS.md` entry; regenerate via
114+
`crates/vault-core/tools/gen_wordlist.sh`.
115+
96116
- **Fingerprint unlock (Linux, off by default).** With `agent.session_keyring`
97117
and the new `agent.fingerprint_unlock` enabled, `vault unlock --fingerprint`
98118
(and a TUI unlock-screen mode) re-unlock the keyring-held session after a

CREDITS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ conceptual basis for Vault appear below.
5252
| Source URL | <https://github.com/ratatui-org/ratatui> |
5353
| Scope | Used as the rendering substrate for `vault-tui`. Listed here because the TUI's structure is shaped by ratatui's widget and layout primitives, beyond routine dependency use. |
5454

55+
## EFF large wordlist — passphrase generation
56+
57+
| Field | Value |
58+
| ---------- | ---------------------------------------------------------------------- |
59+
| Name | EFF Large Wordlist for Passphrases |
60+
| Author(s) | Electronic Frontier Foundation |
61+
| License | CC-BY-3.0 |
62+
| Source URL | <https://www.eff.org/dice> |
63+
| Scope | The 7776-word diceware list embedded at `crates/vault-core/src/wordlist.rs`, used by `generate_passphrase`. Verbatim EFF word data (regenerated from the EFF source via `crates/vault-core/tools/gen_wordlist.sh`); declared CC-BY-3.0 in `REUSE.toml` per Standard §4.2. |
64+
5565
## The Spacecraft Software Standard
5666

5767
| Field | Value |

LICENSES/CC-BY-3.0.txt

Lines changed: 319 additions & 0 deletions
Large diffs are not rendered by default.

REUSE.toml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
version = 1
22

3-
# All of Vault's committed files are GPL-3.0-or-later (software-class, Standard
3+
# All of Vault's own files are GPL-3.0-or-later (software-class, Standard
44
# §4.1.1). `override` precedence so example SPDX tags embedded in docs/prose
55
# (e.g. CLAUDE.md / PRD.md describing the header convention) aren't parsed as
66
# real license info, and supplies the copyright in bulk so no per-file
7-
# `SPDX-FileCopyrightText` is needed. Vault vendors no third-party-derived files
8-
# in-tree (the rbw/ + cruxpass/ references are gitignored), so this one
9-
# annotation covers the whole repository.
7+
# `SPDX-FileCopyrightText` is needed.
108
[[annotations]]
119
path = "**"
1210
precedence = "override"
1311
SPDX-FileCopyrightText = "2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>"
1412
SPDX-License-Identifier = "GPL-3.0-or-later"
13+
14+
# The one third-party-derived file: the EFF large diceware wordlist embedded for
15+
# `generate_passphrase`. Verbatim EFF word data under CC-BY-3.0 (Standard §4.2 —
16+
# preserve the upstream license; credited in CREDITS.md). This more-specific
17+
# annotation overrides the `**` GPL default for this path. Regenerate the file
18+
# with `crates/vault-core/tools/gen_wordlist.sh`.
19+
[[annotations]]
20+
path = "crates/vault-core/src/wordlist.rs"
21+
precedence = "override"
22+
SPDX-FileCopyrightText = "Electronic Frontier Foundation"
23+
SPDX-License-Identifier = "CC-BY-3.0"

crates/vault-cli/src/main.rs

Lines changed: 146 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -340,12 +340,21 @@ enum Cmd {
340340
#[arg(long)]
341341
json: bool,
342342
},
343-
/// Generate a password locally (no agent or server interaction).
343+
/// Generate a password or passphrase locally (no agent or server).
344+
///
345+
/// With no flags, the `[generate]` config defaults apply (built-in defaults
346+
/// when unset); flags override per-invocation.
344347
Generate {
345-
/// Password length in characters.
346-
#[arg(long, short = 'l', default_value_t = 20)]
347-
length: usize,
348-
/// Include symbols (`!@#$%^&*`). Off by default.
348+
/// Generate a diceware passphrase instead of a character password.
349+
#[arg(long)]
350+
passphrase: bool,
351+
/// Force character-password mode (overrides a configured passphrase default).
352+
#[arg(long)]
353+
password: bool,
354+
/// Password length in characters (default 20).
355+
#[arg(long, short = 'l')]
356+
length: Option<usize>,
357+
/// Include symbols (`!@#$%^&*`).
349358
#[arg(long, short = 's')]
350359
symbols: bool,
351360
/// Exclude lowercase letters.
@@ -357,7 +366,25 @@ enum Cmd {
357366
/// Exclude digits.
358367
#[arg(long)]
359368
no_digits: bool,
360-
/// Emit JSON instead of the raw password.
369+
/// Passphrase: number of words, 3–20 (default 3).
370+
#[arg(long, short = 'w')]
371+
words: Option<usize>,
372+
/// Passphrase: word separator (default "-").
373+
#[arg(long)]
374+
separator: Option<String>,
375+
/// Passphrase: capitalize the first letter of each word.
376+
#[arg(long)]
377+
capitalize: bool,
378+
/// Passphrase: do not capitalize (overrides a configured default).
379+
#[arg(long)]
380+
no_capitalize: bool,
381+
/// Passphrase: append a digit to one word.
382+
#[arg(long)]
383+
include_number: bool,
384+
/// Passphrase: omit the digit (overrides a configured default).
385+
#[arg(long)]
386+
no_include_number: bool,
387+
/// Emit JSON instead of the raw value.
361388
#[arg(long)]
362389
json: bool,
363390
},
@@ -870,31 +897,95 @@ async fn run(cmd: Cmd, ep: Endpoint<'_>) -> Result<(), u8> {
870897
Cmd::Exec { profile, command } => cmd_exec(ep, profile, command).await,
871898
Cmd::Purge { force, json } => cmd_purge(ep, force, json).await,
872899
Cmd::Generate {
900+
passphrase,
901+
password,
873902
length,
874903
symbols,
875904
no_lowercase,
876905
no_uppercase,
877906
no_digits,
907+
words,
908+
separator,
909+
capitalize,
910+
no_capitalize,
911+
include_number,
912+
no_include_number,
878913
json,
879-
} => cmd_generate(length, symbols, no_lowercase, no_uppercase, no_digits, json),
914+
} => cmd_generate(&GenFlags {
915+
passphrase,
916+
password,
917+
length,
918+
symbols,
919+
no_lowercase,
920+
no_uppercase,
921+
no_digits,
922+
words,
923+
separator,
924+
capitalize,
925+
no_capitalize,
926+
include_number,
927+
no_include_number,
928+
json,
929+
}),
880930
}
881931
}
882932

883-
#[allow(clippy::fn_params_excessive_bools)] // each flag mirrors a `vault generate` CLI switch
884-
fn cmd_generate(
885-
length: usize,
933+
/// Parsed `vault generate` switches.
934+
///
935+
/// Resolved against `[generate]` config in [`cmd_generate`]; each field mirrors
936+
/// one CLI flag.
937+
#[allow(clippy::struct_excessive_bools)] // one bool per CLI switch
938+
struct GenFlags {
939+
passphrase: bool,
940+
password: bool,
941+
length: Option<usize>,
886942
symbols: bool,
887943
no_lowercase: bool,
888944
no_uppercase: bool,
889945
no_digits: bool,
946+
words: Option<usize>,
947+
separator: Option<String>,
948+
capitalize: bool,
949+
no_capitalize: bool,
950+
include_number: bool,
951+
no_include_number: bool,
890952
json: bool,
891-
) -> Result<(), u8> {
953+
}
954+
955+
fn cmd_generate(f: &GenFlags) -> Result<(), u8> {
956+
// Config supplies defaults; explicit flags override. A missing/unreadable
957+
// config falls back to built-in defaults rather than failing generation.
958+
let cfg = config::load().unwrap_or_default();
959+
let g = cfg.generate();
960+
961+
// Mode: explicit --password wins, then --passphrase, then the configured
962+
// default, else character-password.
963+
let passphrase_mode = if f.password {
964+
false
965+
} else if f.passphrase {
966+
true
967+
} else {
968+
g.mode.as_deref() == Some("passphrase")
969+
};
970+
971+
if passphrase_mode {
972+
cmd_generate_passphrase(f, g)
973+
} else {
974+
cmd_generate_password(f, g)
975+
}
976+
}
977+
978+
fn cmd_generate_password(f: &GenFlags, g: &config::GenerateCfg) -> Result<(), u8> {
892979
let opts = vault_core::GenerateOptions {
893-
length,
894-
lowercase: !no_lowercase,
895-
uppercase: !no_uppercase,
896-
digits: !no_digits,
897-
symbols,
980+
length: f
981+
.length
982+
.or_else(|| g.length.and_then(|l| usize::try_from(l).ok()))
983+
.unwrap_or(20),
984+
// `--no-*` forces a class off; config (else built-in default) is the baseline.
985+
lowercase: g.lowercase.unwrap_or(true) && !f.no_lowercase,
986+
uppercase: g.uppercase.unwrap_or(true) && !f.no_uppercase,
987+
digits: g.digits.unwrap_or(true) && !f.no_digits,
988+
symbols: f.symbols || g.symbols.unwrap_or(false),
898989
};
899990
let pw = match vault_core::generate_password(&opts) {
900991
Ok(p) => p,
@@ -903,7 +994,7 @@ fn cmd_generate(
903994
return Err(2);
904995
}
905996
};
906-
if json {
997+
if f.json {
907998
let v = serde_json::json!({
908999
"password": pw.as_str(),
9091000
"length": opts.length,
@@ -921,6 +1012,44 @@ fn cmd_generate(
9211012
Ok(())
9221013
}
9231014

1015+
fn cmd_generate_passphrase(f: &GenFlags, g: &config::GenerateCfg) -> Result<(), u8> {
1016+
let opts = vault_core::PassphraseOptions {
1017+
words: f
1018+
.words
1019+
.or_else(|| g.words.and_then(|w| usize::try_from(w).ok()))
1020+
.unwrap_or(3),
1021+
separator: f
1022+
.separator
1023+
.clone()
1024+
.or_else(|| g.separator.clone())
1025+
.unwrap_or_else(|| "-".to_owned()),
1026+
// A positive flag (or config) turns it on; a `--no-*` flag forces it off.
1027+
capitalize: (f.capitalize || g.capitalize.unwrap_or(false)) && !f.no_capitalize,
1028+
include_number: (f.include_number || g.include_number.unwrap_or(false))
1029+
&& !f.no_include_number,
1030+
};
1031+
let phrase = match vault_core::generate_passphrase(&opts) {
1032+
Ok(p) => p,
1033+
Err(e) => {
1034+
eprintln!("vault: {e}");
1035+
return Err(2);
1036+
}
1037+
};
1038+
if f.json {
1039+
let v = serde_json::json!({
1040+
"passphrase": phrase.as_str(),
1041+
"words": opts.words,
1042+
"separator": opts.separator,
1043+
"capitalize": opts.capitalize,
1044+
"include_number": opts.include_number,
1045+
});
1046+
println!("{v}");
1047+
} else {
1048+
println!("{}", phrase.as_str());
1049+
}
1050+
Ok(())
1051+
}
1052+
9241053
async fn cmd_status(ep: Endpoint<'_>, json: bool) -> Result<(), u8> {
9251054
let mut stream = connect(ep).await?;
9261055
let resp = exchange(&mut stream, &Request::Status).await?;

0 commit comments

Comments
 (0)