Skip to content

Commit 9f1e8fa

Browse files
Merge pull request #27 from Spacecraft-Software/identity-write
feat(vault): create/edit identity ciphers (write path)
2 parents 05c8cc6 + 035e8f5 commit 9f1e8fa

10 files changed

Lines changed: 609 additions & 12 deletions

File tree

CHANGELOG.md

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

1111
### Added
1212

13+
- **Create/edit identity ciphers.** `vault add … --type identity` and `vault
14+
edit` now build/update identity (type 4) items — the last cipher type to get a
15+
write path. The 15 non-secret fields (title, names, username, company, email,
16+
phone, address1–3, city, state, postal code, country) are flags; the three
17+
sensitive fields — **SSN, passport, license** — are prompted on the controlling
18+
terminal when their bool flag (`--ssn`/`--passport`/`--license`) is set (never
19+
argv). The identity username is `--identity-username` (the bare `--username` is
20+
the login field). Editing identity fields on a non-identity item is rejected.
21+
- `vault-core`: `from_plain` emits an `Identity` for type 4. `vault-ipc`: a
22+
typed `IdentityWrite` (ssn/passport/license as zeroized `Vec<u8>`, redacted
23+
`Debug`) on `Request::Add`/`Edit` (serde-defaulted). `vault-agent`:
24+
`add_cipher` builds a `PlainIdentity`; `apply_cipher_edits` sets only the
25+
given identity fields. Tests: `from_plain``decrypt` identity round-trip;
26+
`apply_cipher_edits` identity partial-update; `IdentityWrite` transport +
27+
`Debug`-redaction.
28+
- This unblocks **identity TUI editing** (the next follow-up) — the backend it
29+
needed now exists.
30+
1331
- **TUI card create/edit.** The `vault-tui` add/edit form (`a`/`e`) now composes
1432
card (type 3) items, not just logins and secure notes. The **Type** row cycles
1533
`login → secure note → card`; the card rows are cardholder/brand/number/expiry

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,22 @@ vault add "My Visa" --type card --brand Visa --expiry 04/2030
109109
vault edit "My Visa" --expiry 05/2031 --code # --code re-prompts the CVV
110110
```
111111

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
112+
`--expiry` accepts `MM/YYYY` or `MM/YY`. Editing card fields on a non-card item
113+
is rejected.
114+
115+
Create an identity with `vault add … --type identity` — the non-secret fields
116+
are flags; SSN, passport, and license numbers are prompted on the terminal when
117+
you pass the matching bool flag (never argv):
118+
119+
```sh
120+
vault add "Jane Doe" --type identity --first-name Jane --last-name Doe \
121+
--email jane@example.org --city Amber --ssn
122+
# SSN / national id: …
123+
vault edit "Jane Doe" --city "New Amber" --passport # --passport prompts it
124+
```
125+
126+
(The login username is `--username`; the identity's own username field is
127+
`--identity-username`.) Editing identity fields on a non-identity item is
114128
rejected.
115129

116130
The TUI can also create and edit cards: press `a` and cycle the **Type** row

crates/vault-agent/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
288288
totp,
289289
uri,
290290
card,
291+
identity,
291292
} => {
292293
let w = crate::state::CipherWrite {
293294
name: Some(name),
@@ -298,6 +299,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
298299
totp,
299300
uri,
300301
card,
302+
identity,
301303
};
302304
let mut s = state.lock().await;
303305
let res = s.add_cipher(cipher_type, w).await;
@@ -318,6 +320,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
318320
totp,
319321
uri,
320322
card,
323+
identity,
321324
} => {
322325
let w = crate::state::CipherWrite {
323326
name,
@@ -328,6 +331,7 @@ async fn dispatch(req: Request, state: &Arc<Mutex<AgentState>>) -> Response {
328331
totp,
329332
uri,
330333
card,
334+
identity,
331335
};
332336
let mut s = state.lock().await;
333337
let res = s.edit_cipher(&selector, w).await;

crates/vault-agent/src/state.rs

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

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

2123
/// In-memory keys + ciphers held while the agent is unlocked.
@@ -258,6 +260,9 @@ pub struct CipherWrite {
258260
pub uri: Option<String>,
259261
/// Card fields (card ciphers only); `number`/`code` are secret bytes.
260262
pub card: Option<vault_ipc::proto::CardWrite>,
263+
/// Identity fields (identity ciphers only); ssn/passport/license are secret
264+
/// bytes.
265+
pub identity: Option<vault_ipc::proto::IdentityWrite>,
261266
}
262267

263268
impl AgentState {
@@ -537,15 +542,21 @@ impl AgentState {
537542
.name
538543
.clone()
539544
.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.
545+
// Decode the card/identity sub-objects before the struct literal moves
546+
// the other `w` fields. Their `Drop`s scrub the secret members.
542547
let card = if cipher_type == 3 {
543548
w.card.map(card_write_to_plain).transpose()?
544549
} else {
545550
None
546551
};
552+
let identity = if cipher_type == 4 {
553+
w.identity.map(identity_write_to_plain).transpose()?
554+
} else {
555+
None
556+
};
547557
// `plain` owns every plaintext value; `PlainCipher::drop` scrubs the
548-
// secret fields (password/totp/notes/card) when it falls out of scope.
558+
// secret fields (password/totp/notes/card/identity) when it falls out of
559+
// scope.
549560
let plain = PlainCipher {
550561
id: String::new(),
551562
cipher_type,
@@ -557,8 +568,7 @@ impl AgentState {
557568
totp: bytes_to_string(w.totp)?,
558569
primary_uri: w.uri,
559570
card,
560-
// Identity write isn't built yet (read-only).
561-
identity: None,
571+
identity,
562572
};
563573
let mut cipher = Cipher::from_plain(&plain, &v.user_enc, &v.user_mac);
564574
v.ensure_online().await?;
@@ -603,6 +613,10 @@ impl AgentState {
603613
),
604614
None => (None, None, None, None, None, None),
605615
};
616+
// Identity edit: decode into an owned `PlainIdentity` (its `Drop` scrubs
617+
// ssn/passport/license); the overlay borrows `&str` out of it.
618+
let identity_present = w.identity.is_some();
619+
let identity_plain = w.identity.map(identity_write_to_plain).transpose()?;
606620
let v = self.vault.as_mut().ok_or(IpcError::Locked)?;
607621

608622
let mut cipher = v.ciphers[idx].clone();
@@ -611,6 +625,11 @@ impl AgentState {
611625
"card fields can only be edited on a card item".to_owned(),
612626
));
613627
}
628+
if identity_present && cipher.cipher_type != 4 {
629+
return Err(IpcError::Internal(
630+
"identity fields can only be edited on an identity item".to_owned(),
631+
));
632+
}
614633
let card = card_present.then(|| CardEdit {
615634
cardholder: card_cardholder.as_deref(),
616635
brand: card_brand.as_deref(),
@@ -619,6 +638,26 @@ impl AgentState {
619638
exp_year: card_exp_year.as_deref(),
620639
code: card_code.as_ref().map(|z| z.as_str()),
621640
});
641+
let identity = identity_plain.as_ref().map(|p| IdentityEdit {
642+
title: p.title.as_deref(),
643+
first_name: p.first_name.as_deref(),
644+
middle_name: p.middle_name.as_deref(),
645+
last_name: p.last_name.as_deref(),
646+
username: p.username.as_deref(),
647+
company: p.company.as_deref(),
648+
ssn: p.ssn.as_deref(),
649+
passport_number: p.passport_number.as_deref(),
650+
license_number: p.license_number.as_deref(),
651+
email: p.email.as_deref(),
652+
phone: p.phone.as_deref(),
653+
address1: p.address1.as_deref(),
654+
address2: p.address2.as_deref(),
655+
address3: p.address3.as_deref(),
656+
city: p.city.as_deref(),
657+
state: p.state.as_deref(),
658+
postal_code: p.postal_code.as_deref(),
659+
country: p.country.as_deref(),
660+
});
622661
let overlay = EditOverlay {
623662
name: w.name.as_deref(),
624663
folder_id,
@@ -629,6 +668,7 @@ impl AgentState {
629668
totp: totp.as_ref().map(|z| z.as_str()),
630669
uri: w.uri.as_deref(),
631670
card,
671+
identity,
632672
};
633673
apply_cipher_edits(&mut cipher, &overlay, &v.user_enc, &v.user_mac);
634674

@@ -943,6 +983,8 @@ struct EditOverlay<'a> {
943983
uri: Option<&'a str>,
944984
/// Card fields to set (card ciphers only); `Some` per field = change it.
945985
card: Option<CardEdit<'a>>,
986+
/// Identity fields to set (identity ciphers only); `Some` per field = change.
987+
identity: Option<IdentityEdit<'a>>,
946988
}
947989

948990
/// Card sub-overlay: borrowed plaintext for the card fields to change.
@@ -955,6 +997,28 @@ struct CardEdit<'a> {
955997
code: Option<&'a str>,
956998
}
957999

1000+
/// Identity sub-overlay: borrowed plaintext for the identity fields to change.
1001+
struct IdentityEdit<'a> {
1002+
title: Option<&'a str>,
1003+
first_name: Option<&'a str>,
1004+
middle_name: Option<&'a str>,
1005+
last_name: Option<&'a str>,
1006+
username: Option<&'a str>,
1007+
company: Option<&'a str>,
1008+
ssn: Option<&'a str>,
1009+
passport_number: Option<&'a str>,
1010+
license_number: Option<&'a str>,
1011+
email: Option<&'a str>,
1012+
phone: Option<&'a str>,
1013+
address1: Option<&'a str>,
1014+
address2: Option<&'a str>,
1015+
address3: Option<&'a str>,
1016+
city: Option<&'a str>,
1017+
state: Option<&'a str>,
1018+
postal_code: Option<&'a str>,
1019+
country: Option<&'a str>,
1020+
}
1021+
9581022
/// Apply `o` to an already-encrypted `cipher` in place, re-encrypting only the
9591023
/// changed fields under fresh IVs. Everything not named in the overlay —
9601024
/// secondary URIs, custom `fields`, `organization_id` — is preserved verbatim.
@@ -1020,6 +1084,58 @@ fn apply_cipher_edits(
10201084
card.code = Some(enc(v));
10211085
}
10221086
}
1087+
// Identity fields (the caller already checked the cipher is an identity).
1088+
if let Some(i) = &o.identity {
1089+
let id = cipher.identity.get_or_insert_with(Identity::default);
1090+
let set = |slot: &mut Option<String>, v: Option<&str>| {
1091+
if let Some(v) = v {
1092+
*slot = Some(enc(v));
1093+
}
1094+
};
1095+
set(&mut id.title, i.title);
1096+
set(&mut id.first_name, i.first_name);
1097+
set(&mut id.middle_name, i.middle_name);
1098+
set(&mut id.last_name, i.last_name);
1099+
set(&mut id.username, i.username);
1100+
set(&mut id.company, i.company);
1101+
set(&mut id.ssn, i.ssn);
1102+
set(&mut id.passport_number, i.passport_number);
1103+
set(&mut id.license_number, i.license_number);
1104+
set(&mut id.email, i.email);
1105+
set(&mut id.phone, i.phone);
1106+
set(&mut id.address1, i.address1);
1107+
set(&mut id.address2, i.address2);
1108+
set(&mut id.address3, i.address3);
1109+
set(&mut id.city, i.city);
1110+
set(&mut id.state, i.state);
1111+
set(&mut id.postal_code, i.postal_code);
1112+
set(&mut id.country, i.country);
1113+
}
1114+
}
1115+
1116+
/// Convert a wire `IdentityWrite` to a `PlainIdentity`, decoding the secret
1117+
/// ssn/passport/license bytes to strings (zeroized on failure / on drop).
1118+
fn identity_write_to_plain(iw: vault_ipc::proto::IdentityWrite) -> Result<PlainIdentity, IpcError> {
1119+
Ok(PlainIdentity {
1120+
title: iw.title,
1121+
first_name: iw.first_name,
1122+
middle_name: iw.middle_name,
1123+
last_name: iw.last_name,
1124+
username: iw.username,
1125+
company: iw.company,
1126+
ssn: bytes_to_string(iw.ssn)?,
1127+
passport_number: bytes_to_string(iw.passport_number)?,
1128+
license_number: bytes_to_string(iw.license_number)?,
1129+
email: iw.email,
1130+
phone: iw.phone,
1131+
address1: iw.address1,
1132+
address2: iw.address2,
1133+
address3: iw.address3,
1134+
city: iw.city,
1135+
state: iw.state,
1136+
postal_code: iw.postal_code,
1137+
country: iw.country,
1138+
})
10231139
}
10241140

10251141
/// Convert a wire `CardWrite` to a `PlainCard`, decoding the secret number/code
@@ -1456,6 +1572,7 @@ mod tests {
14561572
totp: None,
14571573
uri: None,
14581574
card: None,
1575+
identity: None,
14591576
};
14601577
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
14611578

@@ -1484,6 +1601,7 @@ mod tests {
14841601
totp: None,
14851602
uri: Some("https://new.example"),
14861603
card: None,
1604+
identity: None,
14871605
};
14881606
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
14891607

@@ -1527,6 +1645,7 @@ mod tests {
15271645
exp_year: Some("2031"),
15281646
code: None,
15291647
}),
1648+
identity: None,
15301649
};
15311650
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
15321651

@@ -1548,6 +1667,76 @@ mod tests {
15481667
assert_eq!(c.number.as_deref(), Some("4111111111111111"));
15491668
}
15501669

1670+
#[test]
1671+
fn apply_identity_edits_sets_only_given_fields() {
1672+
let enc = [0x31u8; 32];
1673+
let mac = [0x32u8; 32];
1674+
let e =
1675+
|s: &str| Some(vault_core::EncString::encrypt(&enc, &mac, s.as_bytes()).serialize());
1676+
// An identity with an existing name + ssn; edit only the email + city.
1677+
let mut cipher = Cipher {
1678+
id: "id-1".into(),
1679+
cipher_type: 4,
1680+
identity: Some(Identity {
1681+
first_name: e("Jane"),
1682+
last_name: e("Doe"),
1683+
ssn: e("123-45-6789"),
1684+
..Identity::default()
1685+
}),
1686+
..Cipher::default()
1687+
};
1688+
let overlay = EditOverlay {
1689+
name: None,
1690+
folder_id: None,
1691+
folder_provided: false,
1692+
notes: None,
1693+
username: None,
1694+
password: None,
1695+
totp: None,
1696+
uri: None,
1697+
card: None,
1698+
identity: Some(IdentityEdit {
1699+
title: None,
1700+
first_name: None,
1701+
middle_name: None,
1702+
last_name: None,
1703+
username: None,
1704+
company: None,
1705+
ssn: None,
1706+
passport_number: None,
1707+
license_number: None,
1708+
email: Some("jane@example.org"),
1709+
phone: None,
1710+
address1: None,
1711+
address2: None,
1712+
address3: None,
1713+
city: Some("Amber"),
1714+
state: None,
1715+
postal_code: None,
1716+
country: None,
1717+
}),
1718+
};
1719+
apply_cipher_edits(&mut cipher, &overlay, &enc, &mac);
1720+
1721+
let back = cipher
1722+
.decrypt(
1723+
&enc,
1724+
&mac,
1725+
DecryptOptions {
1726+
identity: true,
1727+
..DecryptOptions::default()
1728+
},
1729+
)
1730+
.unwrap();
1731+
let i = back.identity.as_ref().unwrap();
1732+
assert_eq!(i.email.as_deref(), Some("jane@example.org"), "email set");
1733+
assert_eq!(i.city.as_deref(), Some("Amber"), "city set");
1734+
// Untouched fields preserved.
1735+
assert_eq!(i.first_name.as_deref(), Some("Jane"));
1736+
assert_eq!(i.last_name.as_deref(), Some("Doe"));
1737+
assert_eq!(i.ssn.as_deref(), Some("123-45-6789"));
1738+
}
1739+
15511740
fn login_with_two_uris(enc: &[u8; 32], mac: &[u8; 32]) -> Cipher {
15521741
let uri = |s: &str| LoginUri {
15531742
uri: Some(vault_core::EncString::encrypt(enc, mac, s.as_bytes()).serialize()),

0 commit comments

Comments
 (0)