Skip to content

Commit 645b513

Browse files
UnbreakableMJclaude
andcommitted
feat(vault): M4 write paths — vault add + vault edit
Completes the M4 write verbs (`remove`/`generate` shipped in 61a56fb). `add` and `edit` are the inverse of the read path: caller-supplied plaintext fields are encrypted inside the agent — the unwrapped user key never crosses the UDS — and POST/PUT to the server. Login and secure-note types are supported. - vault-core: `Cipher::from_plain` — the encryption inverse of `decrypt`; assembles a /sync-shaped Cipher from a PlainCipher. 3 round-trip tests. - vault-api: `create_cipher` (POST /api/ciphers → new id) and `update_cipher` (PUT /api/ciphers/{id}). camelCase request body (CipherRequest) reusing vault-core's PascalCase Login nested, with a `secureNote` marker on type 2. Shared `bearer_headers` helper. Wiremock tests assert method/path/Bearer, an EncString-shaped name, and the type-2 secureNote marker. - vault-ipc: `Request::Add` / `Request::Edit` (plaintext optional fields, secrets as `Vec<u8>`) and `Response::Saved { id, name }`. - vault-agent: `add_cipher` / `edit_cipher`, with folder name→id resolution. `edit` re-encrypts only changed fields onto a clone of the original encrypted cipher (`apply_cipher_edits`), so secondary URIs, custom fields, and org membership it doesn't model survive verbatim — `--uri` replaces the primary and keeps the rest. Two unit tests prove a secondary URI survives an edit. Secrets wiped via PlainCipher/Zeroizing. - vault-cli: `vault add <name> [--type login|note] [--username] [--uri] [--folder] [--notes] [--generate[=LEN]] [--json]` (password from stdin or generated; no --password flag, so secrets never enter argv) and `vault edit <selector> [field flags] [--password|--generate] [--json]`. Remaining M4: `--json` on unlock/lock/sync/stop-agent, and a real re-pull `vault sync`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 61a56fb commit 645b513

9 files changed

Lines changed: 1108 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@ range may break in any release.
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- **M4 (slice 3) — `vault add` + `vault edit`.** The two remaining write verbs,
14+
the inverse of the read path: caller-supplied plaintext fields are encrypted
15+
**inside the agent** (the user key never leaves it) and `POST`/`PUT` to the
16+
server. Login (`--type login`) and secure note (`--type note`) are supported.
17+
- `vault add <name> [--type login|note] [--username U] [--uri URL]
18+
[--folder F] [--notes N] [--generate[=LEN]] [--json]`. The password is read
19+
from stdin or generated locally with `--generate` (printed back so the user
20+
has it); no `--password` flag, so secrets never enter argv / shell history.
21+
- `vault edit <selector> [--name|--username|--uri|--folder|--notes ...]
22+
[--password (stdin)] [--generate[=LEN]] [--json]`. Only the flags you pass
23+
change; `edit` re-encrypts just those fields onto a clone of the original
24+
encrypted cipher, so everything it doesn't individually edit — secondary
25+
URIs, custom fields, organization membership — survives verbatim. `--uri`
26+
replaces the primary URI and keeps the rest. Folder is resolved by id or
27+
case-insensitive name.
28+
- New `vault_core::Cipher::from_plain` (the encryption inverse of `decrypt`),
29+
`vault_api::BitwardenClient::{create_cipher, update_cipher}` (`POST`/`PUT
30+
/api/ciphers`, camelCase request body with a `secureNote` marker on type 2),
31+
`Request::Add` / `Request::Edit` and `Response::Saved { id, name }`, and the
32+
agent's `add_cipher` / `edit_cipher` (with folder name→id resolution). Tests:
33+
3 `from_plain` round-trips (`vault-core`); `resolve_folder` and two
34+
`apply_cipher_edits` cases proving a secondary URI survives an edit (agent);
35+
and `#[ignore]`d wiremock create/update + secure-note-marker tests (api).
36+
37+
(`add` + `edit` complete the write verbs; remaining M4: `--json` on
38+
`unlock`/`lock`/`sync`/`stop-agent`, and a real re-pull `vault sync`.)
39+
1140
### Fixed
1241

1342
- **CI is green for the first time (M0–M3 had been red on every push).** Four

crates/vault-agent/src/server.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ async fn handle_conn(stream: UnixStream, state: Arc<Mutex<AgentState>>) {
6969
}
7070
}
7171

72+
#[allow(clippy::too_many_lines)] // flat one-arm-per-request protocol dispatch reads best in one place
7273
async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
7374
match req {
7475
Request::Ping | Request::Status => {
@@ -145,6 +146,62 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
145146
Err(e) => Response::Error(e),
146147
}
147148
}
149+
Request::Add {
150+
name,
151+
cipher_type,
152+
folder,
153+
notes,
154+
username,
155+
password,
156+
totp,
157+
uri,
158+
} => {
159+
let w = crate::state::CipherWrite {
160+
name: Some(name),
161+
folder,
162+
notes,
163+
username,
164+
password,
165+
totp,
166+
uri,
167+
};
168+
let mut s = state.lock().await;
169+
let res = s.add_cipher(cipher_type, w).await;
170+
s.touch();
171+
drop(s);
172+
match res {
173+
Ok(saved) => Response::Saved(saved),
174+
Err(e) => Response::Error(e),
175+
}
176+
}
177+
Request::Edit {
178+
selector,
179+
name,
180+
folder,
181+
notes,
182+
username,
183+
password,
184+
totp,
185+
uri,
186+
} => {
187+
let w = crate::state::CipherWrite {
188+
name,
189+
folder,
190+
notes,
191+
username,
192+
password,
193+
totp,
194+
uri,
195+
};
196+
let mut s = state.lock().await;
197+
let res = s.edit_cipher(&selector, w).await;
198+
s.touch();
199+
drop(s);
200+
match res {
201+
Ok(saved) => Response::Saved(saved),
202+
Err(e) => Response::Error(e),
203+
}
204+
}
148205
Request::Quit => {
149206
let mut s = state.lock().await;
150207
s.lock();

0 commit comments

Comments
 (0)