Skip to content

Commit 986670e

Browse files
Merge pull request #25 from Spacecraft-Software/card-write
feat(vault): create/edit card ciphers (write path)
2 parents 4818e97 + ba65759 commit 986670e

10 files changed

Lines changed: 495 additions & 11 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

13+
- **Create/edit card ciphers.** `vault add … --type card` and `vault edit`
14+
now build/update card (type 3) items: cardholder/brand/expiry via flags, the
15+
**number and CVV prompted on the controlling terminal** (`/dev/tty`, never
16+
argv — so they don't leak to shell history / `ps`). `--expiry` takes
17+
`MM/YYYY` or `MM/YY`; on edit, `--number`/`--code` re-prompt those secrets.
18+
Editing card fields on a non-card item is rejected.
19+
- `vault-core`: `from_plain` emits a `Card` for type 3. `vault-ipc`: a typed
20+
`CardWrite` (secrets as zeroized `Vec<u8>`, redacted `Debug`) on
21+
`Request::Add`/`Edit` (serde-defaulted). `vault-agent`: `add_cipher` builds
22+
a `PlainCard`; `apply_cipher_edits` sets only the given card fields (others
23+
preserved).
24+
- Identity create/edit remains the tracked follow-up (read-only for now).
25+
Tests: `from_plain``decrypt` card round-trip; `apply_cipher_edits` card
26+
partial-update; `CardWrite` transport + `Debug`-redaction; CLI `split_expiry`.
27+
1328
- **TUI card/identity detail render.** Selecting a card or identity in
1429
`vault-tui` now shows its fields in the detail pane: card brand/expiry with a
1530
masked number (`Space` reveals it, re-masked on navigation) and masked CVV;

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@ the primary field — a card's number, an identity's email (and a login's
9898
password). The number/CVV are fetched only on reveal, so no card secret enters
9999
the TUI until you ask.
100100

101+
Create a card with `vault add … --type card` — the non-secret fields are flags;
102+
the number and CVV are prompted on the terminal (never argv, so they don't leak
103+
to shell history / `ps`):
104+
105+
```sh
106+
vault add "My Visa" --type card --brand Visa --expiry 04/2030
107+
# Card number: …
108+
# CVV (leave empty for none): …
109+
vault edit "My Visa" --expiry 05/2031 --code # --code re-prompts the CVV
110+
```
111+
112+
`--expiry` accepts `MM/YYYY` or `MM/YY`. Identity create/edit is still pending
113+
(identities are read-only for now). Editing card fields on a non-card item is
114+
rejected.
115+
101116
### PIN unlock
102117

103118
For quick access without re-typing the master password, enroll a PIN (after an

crates/vault-agent/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
287287
password,
288288
totp,
289289
uri,
290+
card,
290291
} => {
291292
let w = crate::state::CipherWrite {
292293
name: Some(name),
@@ -296,6 +297,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
296297
password,
297298
totp,
298299
uri,
300+
card,
299301
};
300302
let mut s = state.lock().await;
301303
let res = s.add_cipher(cipher_type, w).await;
@@ -315,6 +317,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
315317
password,
316318
totp,
317319
uri,
320+
card,
318321
} => {
319322
let w = crate::state::CipherWrite {
320323
name,
@@ -324,6 +327,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
324327
password,
325328
totp,
326329
uri,
330+
card,
327331
};
328332
let mut s = state.lock().await;
329333
let res = s.edit_cipher(&selector, w).await;

crates/vault-agent/src/state.rs

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use zeroize::Zeroizing;
1515

1616
use vault_api::BitwardenClient;
1717
use vault_core::EncString;
18-
use vault_core::cipher::{Cipher, DecryptOptions, Login, LoginUri, PlainCipher};
18+
use vault_core::cipher::{Card, Cipher, DecryptOptions, Login, LoginUri, PlainCard, PlainCipher};
1919
use vault_ipc::proto::{Error as IpcError, Field, Item, ListEntry, Removed, Saved, Status};
2020

2121
/// In-memory keys + ciphers held while the agent is unlocked.
@@ -256,6 +256,8 @@ pub struct CipherWrite {
256256
pub totp: Option<Vec<u8>>,
257257
/// Primary login URI.
258258
pub uri: Option<String>,
259+
/// Card fields (card ciphers only); `number`/`code` are secret bytes.
260+
pub card: Option<vault_ipc::proto::CardWrite>,
259261
}
260262

261263
impl AgentState {
@@ -535,8 +537,15 @@ impl AgentState {
535537
.name
536538
.clone()
537539
.ok_or_else(|| IpcError::Internal("add requires a name".to_owned()))?;
540+
// Decode the card sub-object (type 3) before the struct literal moves
541+
// the other `w` fields. `PlainCard::drop` scrubs number/code.
542+
let card = if cipher_type == 3 {
543+
w.card.map(card_write_to_plain).transpose()?
544+
} else {
545+
None
546+
};
538547
// `plain` owns every plaintext value; `PlainCipher::drop` scrubs the
539-
// secret fields (password/totp/notes) when it falls out of scope.
548+
// secret fields (password/totp/notes/card) when it falls out of scope.
540549
let plain = PlainCipher {
541550
id: String::new(),
542551
cipher_type,
@@ -547,8 +556,8 @@ impl AgentState {
547556
password: bytes_to_string(w.password)?,
548557
totp: bytes_to_string(w.totp)?,
549558
primary_uri: w.uri,
550-
// add/edit build logins only; card/identity aren't created here.
551-
card: None,
559+
card,
560+
// Identity write isn't built yet (read-only).
552561
identity: None,
553562
};
554563
let mut cipher = Cipher::from_plain(&plain, &v.user_enc, &v.user_mac);
@@ -579,9 +588,37 @@ impl AgentState {
579588
// the plaintext when the locals drop at the end of this call.
580589
let password = bytes_to_string(w.password)?.map(Zeroizing::new);
581590
let totp = bytes_to_string(w.totp)?.map(Zeroizing::new);
591+
// Card edit: decode its secret bytes; non-secret fields stay owned for
592+
// borrowing into the overlay.
593+
let card_present = w.card.is_some();
594+
let (card_cardholder, card_brand, card_exp_month, card_exp_year, card_number, card_code) =
595+
match w.card {
596+
Some(cw) => (
597+
cw.cardholder,
598+
cw.brand,
599+
cw.exp_month,
600+
cw.exp_year,
601+
bytes_to_string(cw.number)?.map(Zeroizing::new),
602+
bytes_to_string(cw.code)?.map(Zeroizing::new),
603+
),
604+
None => (None, None, None, None, None, None),
605+
};
582606
let v = self.vault.as_mut().ok_or(IpcError::Locked)?;
583607

584608
let mut cipher = v.ciphers[idx].clone();
609+
if card_present && cipher.cipher_type != 3 {
610+
return Err(IpcError::Internal(
611+
"card fields can only be edited on a card item".to_owned(),
612+
));
613+
}
614+
let card = card_present.then(|| CardEdit {
615+
cardholder: card_cardholder.as_deref(),
616+
brand: card_brand.as_deref(),
617+
number: card_number.as_ref().map(|z| z.as_str()),
618+
exp_month: card_exp_month.as_deref(),
619+
exp_year: card_exp_year.as_deref(),
620+
code: card_code.as_ref().map(|z| z.as_str()),
621+
});
585622
let overlay = EditOverlay {
586623
name: w.name.as_deref(),
587624
folder_id,
@@ -591,6 +628,7 @@ impl AgentState {
591628
password: password.as_ref().map(|z| z.as_str()),
592629
totp: totp.as_ref().map(|z| z.as_str()),
593630
uri: w.uri.as_deref(),
631+
card,
594632
};
595633
apply_cipher_edits(&mut cipher, &overlay, &v.user_enc, &v.user_mac);
596634

@@ -903,6 +941,18 @@ struct EditOverlay<'a> {
903941
password: Option<&'a str>,
904942
totp: Option<&'a str>,
905943
uri: Option<&'a str>,
944+
/// Card fields to set (card ciphers only); `Some` per field = change it.
945+
card: Option<CardEdit<'a>>,
946+
}
947+
948+
/// Card sub-overlay: borrowed plaintext for the card fields to change.
949+
struct CardEdit<'a> {
950+
cardholder: Option<&'a str>,
951+
brand: Option<&'a str>,
952+
number: Option<&'a str>,
953+
exp_month: Option<&'a str>,
954+
exp_year: Option<&'a str>,
955+
code: Option<&'a str>,
906956
}
907957

908958
/// Apply `o` to an already-encrypted `cipher` in place, re-encrypting only the
@@ -948,6 +998,41 @@ fn apply_cipher_edits(
948998
login.uris = Some(uris);
949999
}
9501000
}
1001+
// Card fields (the caller already checked the cipher is a card).
1002+
if let Some(c) = &o.card {
1003+
let card = cipher.card.get_or_insert_with(Card::default);
1004+
if let Some(v) = c.cardholder {
1005+
card.cardholder_name = Some(enc(v));
1006+
}
1007+
if let Some(v) = c.brand {
1008+
card.brand = Some(enc(v));
1009+
}
1010+
if let Some(v) = c.number {
1011+
card.number = Some(enc(v));
1012+
}
1013+
if let Some(v) = c.exp_month {
1014+
card.exp_month = Some(enc(v));
1015+
}
1016+
if let Some(v) = c.exp_year {
1017+
card.exp_year = Some(enc(v));
1018+
}
1019+
if let Some(v) = c.code {
1020+
card.code = Some(enc(v));
1021+
}
1022+
}
1023+
}
1024+
1025+
/// Convert a wire `CardWrite` to a `PlainCard`, decoding the secret number/code
1026+
/// bytes to strings (zeroized on failure by `bytes_to_string`).
1027+
fn card_write_to_plain(cw: vault_ipc::proto::CardWrite) -> Result<PlainCard, IpcError> {
1028+
Ok(PlainCard {
1029+
cardholder_name: cw.cardholder,
1030+
brand: cw.brand,
1031+
number: bytes_to_string(cw.number)?,
1032+
exp_month: cw.exp_month,
1033+
exp_year: cw.exp_year,
1034+
code: bytes_to_string(cw.code)?,
1035+
})
9511036
}
9521037

9531038
#[cfg(test)]
@@ -1370,6 +1455,7 @@ mod tests {
13701455
password: None,
13711456
totp: None,
13721457
uri: None,
1458+
card: None,
13731459
};
13741460
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
13751461

@@ -1397,6 +1483,7 @@ mod tests {
13971483
password: None,
13981484
totp: None,
13991485
uri: Some("https://new.example"),
1486+
card: None,
14001487
};
14011488
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
14021489

@@ -1406,6 +1493,61 @@ mod tests {
14061493
assert_eq!(decrypt_uri(&uris[1], &enc, &mac), "https://two.example");
14071494
}
14081495

1496+
#[test]
1497+
fn apply_card_edits_sets_only_given_fields() {
1498+
let enc = [0x21u8; 32];
1499+
let mac = [0x22u8; 32];
1500+
let e =
1501+
|s: &str| Some(vault_core::EncString::encrypt(&enc, &mac, s.as_bytes()).serialize());
1502+
// A card with an existing brand + number; edit only the expiry.
1503+
let mut cipher = Cipher {
1504+
id: "card-1".into(),
1505+
cipher_type: 3,
1506+
card: Some(Card {
1507+
brand: e("Visa"),
1508+
number: e("4111111111111111"),
1509+
..Card::default()
1510+
}),
1511+
..Cipher::default()
1512+
};
1513+
let overlay = EditOverlay {
1514+
name: None,
1515+
folder_id: None,
1516+
folder_provided: false,
1517+
notes: None,
1518+
username: None,
1519+
password: None,
1520+
totp: None,
1521+
uri: None,
1522+
card: Some(CardEdit {
1523+
cardholder: None,
1524+
brand: None,
1525+
number: None,
1526+
exp_month: Some("5"),
1527+
exp_year: Some("2031"),
1528+
code: None,
1529+
}),
1530+
};
1531+
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
1532+
1533+
let back = cipher
1534+
.decrypt(
1535+
&enc,
1536+
&mac,
1537+
DecryptOptions {
1538+
card: true,
1539+
..DecryptOptions::default()
1540+
},
1541+
)
1542+
.unwrap();
1543+
let c = back.card.as_ref().unwrap();
1544+
assert_eq!(c.exp_month.as_deref(), Some("5"), "expiry updated");
1545+
assert_eq!(c.exp_year.as_deref(), Some("2031"));
1546+
// Untouched fields preserved.
1547+
assert_eq!(c.brand.as_deref(), Some("Visa"));
1548+
assert_eq!(c.number.as_deref(), Some("4111111111111111"));
1549+
}
1550+
14091551
fn login_with_two_uris(enc: &[u8; 32], mac: &[u8; 32]) -> Cipher {
14101552
let uri = |s: &str| LoginUri {
14111553
uri: Some(vault_core::EncString::encrypt(enc, mac, s.as_bytes()).serialize()),

0 commit comments

Comments
 (0)