Skip to content

Commit 23b00f7

Browse files
Merge pull request #7 from Spacecraft-Software/register-login
feat(vault): vault register + vault login (account profile)
2 parents 39a8403 + 6b3f319 commit 23b00f7

10 files changed

Lines changed: 355 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,34 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **`vault register` + `vault login` — account profile (PRD §7.1).** The last
14+
two unimplemented verbs, as an account-profile flow (not the Bitwarden
15+
personal API-key model, which stays a tracked follow-up).
16+
- **`register`** records the account — server, email (lower-cased), and a
17+
freshly minted stable `device_id` — into a new `[account]` table in
18+
`config.toml`. No agent or network (a light `http(s)://` check is the only
19+
validation; real errors surface at first `login`). Re-registering keeps the
20+
existing `device_id`.
21+
- **`login`** authenticates against the registered account (master password →
22+
`Request::Unlock`) and ends on a sync summary
23+
(`logged in as <email> · <n> items · synced <ts>`, or `--json`). It
24+
auto-spawns the agent, so a cold `vault login` brings everything up.
25+
- **`unlock`** now resolves `server`/`email` from the profile too, so its
26+
flags (and `$VAULT_SERVER`/`$VAULT_EMAIL`) are optional once registered;
27+
precedence is explicit flag/env → profile → error. `login` and `unlock`
28+
share one `Request::Unlock` — the difference is resolution and `login`'s
29+
verbose, status-backed success.
30+
- **Stable device id.** `Request::Unlock` gains a serde-defaulted
31+
`device_id: Option<String>`; the agent uses it as the Bitwarden
32+
`deviceIdentifier` instead of minting a fresh UUID each unlock, so the
33+
account stops accumulating a new device per session. Old `Unlock` frames
34+
without the field still decode (regression-tested).
35+
- Tests: `[account]` round-trip + "no empty table until set" + device-id
36+
mint/preserve + email lower-casing; `resolve_account` precedence
37+
(flag → profile → error); the `Unlock` device-id round-trip and
38+
old-frame-decode regression. New `uuid` dep on `vault-cli` (already in the
39+
workspace tree).
40+
1341
- **`vault config` + `vault purge` (PRD §7.1).** A persistent, typed config
1442
file and the two remaining settings/maintenance verbs.
1543
- **Config file** at `$XDG_CONFIG_HOME/vault/config.toml`, modeled as a

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ sibling of the `vault` binary, then `$PATH` (override with
4444
`$VAULT_AGENT_BIN`; opt out per-call with `--no-auto-spawn`). A spawned
4545
agent logs to `agent.log` beside the socket.
4646

47+
## Getting started
48+
49+
```sh
50+
vault register --server https://vault.example.org --email me@example.org
51+
vault login # master password → authenticate + confirm a sync
52+
vault list # browse; server/email come from the registered profile
53+
```
54+
55+
`register` records the account (server, email, and a stable device id) in the
56+
config file; `login` and `unlock` then resolve those from the profile, so their
57+
`--server`/`--email` flags (and `$VAULT_SERVER`/`$VAULT_EMAIL`) are optional
58+
once registered. `login` is the first-time "authenticate and verify sync";
59+
`unlock` is the routine "hand the agent my key again".
60+
4761
## Configuration
4862

4963
Persistent settings live at `$XDG_CONFIG_HOME/vault/config.toml`, managed with

crates/vault-agent/src/server.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,12 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
8080
server,
8181
email,
8282
password,
83+
device_id,
8384
} => {
8485
// Wrap the password so it is zeroised on drop no matter how
8586
// perform_unlock fares; deref coercion hands it to the API as &[u8].
8687
let password = zeroize::Zeroizing::new(password);
87-
let unlock_res = perform_unlock(&server, &email, &password).await;
88+
let unlock_res = perform_unlock(&server, &email, &password, device_id.as_deref()).await;
8889
match unlock_res {
8990
Ok(vault) => {
9091
let mut s = state.lock().await;

crates/vault-agent/src/unlock.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,20 @@ use crate::state::Vault;
1616

1717
/// Lock-step the unlock sequence:
1818
/// prelogin → derive master key → login → decrypt user key → sync → assemble.
19-
pub async fn perform_unlock(server: &str, email: &str, password: &[u8]) -> Result<Vault, IpcError> {
19+
pub async fn perform_unlock(
20+
server: &str,
21+
email: &str,
22+
password: &[u8],
23+
device_id: Option<&str>,
24+
) -> Result<Vault, IpcError> {
2025
let email_lower = email.trim().to_lowercase();
2126
let urls = BaseUrls::self_hosted(server).map_err(|e| IpcError::Internal(e.to_string()))?;
22-
let mut client = BitwardenClient::new(urls, Uuid::new_v4(), "vault-agent").map_err(api_err)?;
27+
// Prefer the account profile's stable device id; fall back to a fresh one
28+
// so an unregistered unlock still works (it just registers a new device).
29+
let device = device_id
30+
.and_then(|s| Uuid::parse_str(s).ok())
31+
.unwrap_or_else(Uuid::new_v4);
32+
let mut client = BitwardenClient::new(urls, device, "vault-agent").map_err(api_err)?;
2333

2434
let prelogin = client.prelogin(&email_lower).await.map_err(api_err)?;
2535
let params = prelogin.into_kdf_params().map_err(crypto_err)?;

crates/vault-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ serde_json = { workspace = true }
3737
tempfile = { workspace = true }
3838
tokio = { workspace = true }
3939
toml = { workspace = true }
40+
uuid = { workspace = true }
4041
zeroize = { workspace = true }
4142
vault-core = { path = "../vault-core" }
4243
vault-ipc = { path = "../vault-ipc" }

crates/vault-cli/src/config.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ pub struct Config {
3333
pub clipboard: ClipboardCfg,
3434
/// Agent settings.
3535
pub agent: AgentCfg,
36+
/// Registered account profile (written by `vault register`). Skipped from
37+
/// the file until something is set, so an unregistered config carries no
38+
/// empty `[account]` table.
39+
#[serde(skip_serializing_if = "AccountCfg::is_empty")]
40+
pub account: AccountCfg,
3641
}
3742

3843
/// `[clipboard]` table.
@@ -51,6 +56,31 @@ pub struct AgentCfg {
5156
pub idle_lock_secs: Option<u64>,
5257
}
5358

59+
/// `[account]` table — the registered account, written by `vault register`
60+
/// and read by `login`/`unlock` to default `server`/`email`/`device_id`.
61+
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
62+
#[serde(default, deny_unknown_fields)]
63+
pub struct AccountCfg {
64+
/// Server origin (`https://vault.example.org`).
65+
#[serde(skip_serializing_if = "Option::is_none")]
66+
pub server: Option<String>,
67+
/// Account email (lower-cased on write).
68+
#[serde(skip_serializing_if = "Option::is_none")]
69+
pub email: Option<String>,
70+
/// Stable per-account device identifier (uuid v4), minted once at register
71+
/// time so the agent stops registering a fresh device each unlock.
72+
#[serde(skip_serializing_if = "Option::is_none")]
73+
pub device_id: Option<String>,
74+
}
75+
76+
impl AccountCfg {
77+
/// Whether no account field is set (drives skipping the table on write).
78+
#[must_use]
79+
pub const fn is_empty(&self) -> bool {
80+
self.server.is_none() && self.email.is_none() && self.device_id.is_none()
81+
}
82+
}
83+
5484
impl Config {
5585
/// Effective `clipboard.clear_secs`, if set.
5686
#[must_use]
@@ -64,6 +94,23 @@ impl Config {
6494
self.agent.idle_lock_secs
6595
}
6696

97+
/// The registered account profile.
98+
#[must_use]
99+
pub const fn account(&self) -> &AccountCfg {
100+
&self.account
101+
}
102+
103+
/// Record the account `server` + `email`, lower-casing the email and
104+
/// minting a `device_id` once (a pre-existing id is preserved so the
105+
/// account keeps its identity across re-registration).
106+
pub fn set_account(&mut self, server: &str, email: &str) {
107+
self.account.server = Some(server.to_owned());
108+
self.account.email = Some(email.trim().to_lowercase());
109+
if self.account.device_id.is_none() {
110+
self.account.device_id = Some(uuid::Uuid::new_v4().to_string());
111+
}
112+
}
113+
67114
/// Current value of `key` as a display string, `None` when unset. Errors
68115
/// (as the offending key) when `key` is not recognised.
69116
pub fn get(&self, key: &str) -> Result<Option<String>, String> {
@@ -262,6 +309,39 @@ mod tests {
262309
assert_eq!(back, c);
263310
}
264311

312+
#[test]
313+
fn set_account_lowercases_email_and_mints_device_id_once() {
314+
let mut c = Config::default();
315+
c.set_account("https://vault.example.org", "Me@Example.org");
316+
assert_eq!(
317+
c.account().server.as_deref(),
318+
Some("https://vault.example.org")
319+
);
320+
assert_eq!(c.account().email.as_deref(), Some("me@example.org"));
321+
let id = c.account().device_id.clone().expect("device_id minted");
322+
// Re-registering a different server/email keeps the same device id.
323+
c.set_account("https://other.example.org", "Other@Example.org");
324+
assert_eq!(c.account().device_id.as_deref(), Some(id.as_str()));
325+
assert_eq!(c.account().email.as_deref(), Some("other@example.org"));
326+
}
327+
328+
#[test]
329+
fn account_round_trips_and_absent_until_set() {
330+
// A config with nothing set must not emit an [account] table.
331+
let empty = toml::to_string_pretty(&Config::default()).expect("serialise");
332+
assert!(
333+
!empty.contains("[account]"),
334+
"empty config grew [account]:\n{empty}"
335+
);
336+
337+
let mut c = Config::default();
338+
c.set_account("https://vault.example.org", "me@example.org");
339+
let text = toml::to_string_pretty(&c).expect("serialise");
340+
assert!(text.contains("[account]"), "account table missing:\n{text}");
341+
let back: Config = toml::from_str(&text).expect("parse");
342+
assert_eq!(back.account(), c.account());
343+
}
344+
265345
#[test]
266346
fn agent_args_emits_only_set_keys_in_order() {
267347
let mut c = Config::default();

0 commit comments

Comments
 (0)