Skip to content

Commit 043bf80

Browse files
mahabubul470claude
andcommitted
Security: passphrase-wrap Graphex master key + gate human-only
Closes the Phase 3 deviation (docs/TODO.md P0). With $GPP_GRAPHEX_PASSPHRASE set (or KeyStore::{generate,open}_with): - master.age is scrypt-passphrase-wrapped at rest; - the human-only tier key is sealed directly to the passphrase, so the master identity alone can no longer decrypt human-only content. Legacy unwrapped stores are auto-detected and keep working unchanged; agent-readable/agent-restricted stay master-sealed (unattended agent reads still work). crypto::age_open dispatches on the age envelope type; dead age_decrypt removed. gpp keys show/generate report the mode. ROADMAP Phase 3 deviation marked resolved; docs/TODO.md item checked off. Also includes the updated README + docs/TODO.md backlog. 126 workspace tests pass (+3 net: crypto/keys passphrase tests); clippy + rustfmt clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9aa65e1 commit 043bf80

8 files changed

Lines changed: 378 additions & 66 deletions

File tree

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,22 @@ full specification (architecture, data model, CLI, protocols, roadmap), and
1010

1111
## Status
1212

13-
**All 9 phases (0–8) complete.** See [`docs/ROADMAP.md`](docs/ROADMAP.md)
14-
for the per-phase deliverables and documented deviations. 123 workspace
15-
tests pass; `cargo clippy` / `cargo fmt` clean. No stub crates remain.
13+
**All 9 phases (0–8) implemented.** See [`docs/ROADMAP.md`](docs/ROADMAP.md)
14+
for the per-phase deliverables and documented deviations, and
15+
[`docs/TODO.md`](docs/TODO.md) for the prioritized backlog of what's next.
16+
17+
Verified 2026-05-18: **123 workspace tests pass**, `cargo clippy` and
18+
`cargo fmt` clean, full workspace builds. No stub crates remain — every
19+
crate has a working implementation.
20+
21+
The test suite is currently all in-crate unit tests (no `tests/`
22+
integration dirs yet) and its depth is **uneven**: foundational layers are
23+
well covered (`gpp-core` 23, `gpp-graphex` 17, `gpp-diff` 13, CLI 16),
24+
while several integration/UI crates have only smoke-level coverage
25+
(`gpp-sdk` 1; `gpp-notify`/`gpp-rbac`/`gpp-replay`/`gpp-tui` 2 each).
26+
"Implemented" here means *built and smoke-tested against its milestone*,
27+
not *exhaustively tested or hardened* everywhere. Closing that gap is the
28+
top item in [`docs/TODO.md`](docs/TODO.md).
1629

1730
| Layer | Crate | What's implemented |
1831
|---|---|---|

crates/gpp-cli/src/phase3.rs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ pub fn keys(args: &KeysArgs, repo_override: Option<&Path>) -> Result<()> {
6464
.collect::<Vec<_>>()
6565
.join(", ")
6666
);
67+
if ks.passphrase_protected() {
68+
println!(
69+
" master key: passphrase-wrapped; human-only tier is \
70+
passphrase-gated (keep ${} safe — it cannot be recovered)",
71+
gpp_graphex::PASSPHRASE_ENV
72+
);
73+
} else {
74+
println!(
75+
" master key: stored unwrapped (set ${} before generate \
76+
to passphrase-protect at rest)",
77+
gpp_graphex::PASSPHRASE_ENV
78+
);
79+
}
6780
Ok(())
6881
}
6982
KeysAction::Rotate => {
@@ -76,18 +89,24 @@ pub fn keys(args: &KeysArgs, repo_override: Option<&Path>) -> Result<()> {
7689
if !KeyStore::exists(&gpp) {
7790
bail!("no key store — run `gpp keys generate`");
7891
}
92+
let protected = KeyStore::is_passphrase_protected(&gpp);
7993
let ks = KeyStore::open(&gpp)?;
8094
println!("master recipient: {}", ks.master_recipient());
95+
println!(
96+
"master key: {}",
97+
if protected {
98+
"passphrase-wrapped"
99+
} else {
100+
"unwrapped (set $GPP_GRAPHEX_PASSPHRASE to protect)"
101+
}
102+
);
81103
for t in ks.present_tiers() {
82-
println!(
83-
" {:<18} {}",
84-
t.as_str(),
85-
if KeyStore::is_encrypted(t) {
86-
"encrypted (master-sealed)"
87-
} else {
88-
"plaintext"
89-
}
90-
);
104+
let how = match (t.as_str(), protected) {
105+
("public", _) => "plaintext",
106+
("human-only", true) => "encrypted (passphrase-gated)",
107+
_ => "encrypted (master-sealed)",
108+
};
109+
println!(" {:<18} {how}", t.as_str());
91110
}
92111
Ok(())
93112
}

crates/gpp-graphex/src/crypto.rs

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -89,28 +89,75 @@ pub fn age_encrypt(recipient: &str, data: &[u8]) -> Result<Vec<u8>> {
8989
Ok(out)
9090
}
9191

92-
/// Decrypt `age` ciphertext with an X25519 identity string (`AGE-SECRET-KEY-…`).
93-
pub fn age_decrypt(identity: &str, data: &[u8]) -> Result<Vec<u8>> {
94-
let id: age::x25519::Identity = identity
95-
.parse()
96-
.map_err(|e| Error::Crypto(format!("bad identity: {e}")))?;
97-
let dec = match age::Decryptor::new(data).map_err(|e| Error::Crypto(format!("age: {e}")))? {
98-
age::Decryptor::Recipients(d) => d,
99-
_ => return Err(Error::Crypto("expected recipients envelope".into())),
100-
};
92+
/// Encrypt `data` with a passphrase (age scrypt recipient).
93+
pub fn passphrase_encrypt(pass: &str, data: &[u8]) -> Result<Vec<u8>> {
94+
let enc = age::Encryptor::with_user_passphrase(age::secrecy::Secret::new(pass.to_owned()));
10195
let mut out = Vec::new();
102-
let mut r = dec
103-
.decrypt(std::iter::once(&id as &dyn age::Identity))
104-
.map_err(|e| Error::Crypto(format!("age decrypt: {e}")))?;
105-
r.read_to_end(&mut out)
106-
.map_err(|e| Error::Crypto(format!("age read: {e}")))?;
96+
let mut w = enc
97+
.wrap_output(&mut out)
98+
.map_err(|e| Error::Crypto(format!("age wrap: {e}")))?;
99+
w.write_all(data)
100+
.map_err(|e| Error::Crypto(format!("age write: {e}")))?;
101+
w.finish()
102+
.map_err(|e| Error::Crypto(format!("age finish: {e}")))?;
103+
Ok(out)
104+
}
105+
106+
/// Open an `age` blob that may be sealed *either* to an X25519 identity
107+
/// (legacy / master-sealed) *or* to a passphrase (hardened). The envelope
108+
/// header selects the path, so callers don't need to know which was used.
109+
pub fn age_open(identity: Option<&str>, passphrase: Option<&str>, data: &[u8]) -> Result<Vec<u8>> {
110+
let mut out = Vec::new();
111+
match age::Decryptor::new(data).map_err(|e| Error::Crypto(format!("age: {e}")))? {
112+
age::Decryptor::Recipients(d) => {
113+
let id_str = identity.ok_or_else(|| {
114+
Error::Crypto("recipients envelope but no identity available".into())
115+
})?;
116+
let id: age::x25519::Identity = id_str
117+
.parse()
118+
.map_err(|e| Error::Crypto(format!("bad identity: {e}")))?;
119+
let mut r = d
120+
.decrypt(std::iter::once(&id as &dyn age::Identity))
121+
.map_err(|e| Error::Crypto(format!("age decrypt: {e}")))?;
122+
r.read_to_end(&mut out)
123+
.map_err(|e| Error::Crypto(format!("age read: {e}")))?;
124+
}
125+
age::Decryptor::Passphrase(d) => {
126+
let pass = passphrase.ok_or(Error::Crypto("passphrase required".into()))?;
127+
let mut r = d
128+
.decrypt(&age::secrecy::Secret::new(pass.to_owned()), None)
129+
.map_err(|e| Error::Crypto(format!("age passphrase decrypt: {e}")))?;
130+
r.read_to_end(&mut out)
131+
.map_err(|e| Error::Crypto(format!("age read: {e}")))?;
132+
}
133+
}
107134
Ok(out)
108135
}
109136

110137
#[cfg(test)]
111138
mod tests {
112139
use super::*;
113140

141+
#[test]
142+
fn passphrase_roundtrip_and_wrong_pass_fails() {
143+
let ct = passphrase_encrypt("correct horse", b"master identity").unwrap();
144+
assert_eq!(
145+
age_open(None, Some("correct horse"), &ct).unwrap(),
146+
b"master identity"
147+
);
148+
assert!(age_open(None, Some("wrong"), &ct).is_err());
149+
assert!(age_open(None, None, &ct).is_err());
150+
}
151+
152+
#[test]
153+
fn age_open_handles_recipient_envelopes_too() {
154+
use age::secrecy::ExposeSecret;
155+
let id = age::x25519::Identity::generate();
156+
let ct = age_encrypt(&id.to_public().to_string(), b"tier key").unwrap();
157+
let pt = age_open(Some(id.to_string().expose_secret()), None, &ct).unwrap();
158+
assert_eq!(pt, b"tier key");
159+
}
160+
114161
#[test]
115162
fn seal_open_roundtrip_encrypted() {
116163
let key = new_symmetric_key().unwrap();
@@ -133,14 +180,4 @@ mod tests {
133180
let env = seal(b"x", &k1, true).unwrap();
134181
assert!(open(&env, &k2).is_err());
135182
}
136-
137-
#[test]
138-
fn age_envelope_roundtrip() {
139-
use age::secrecy::ExposeSecret;
140-
let id = age::x25519::Identity::generate();
141-
let pk = id.to_public().to_string();
142-
let ct = age_encrypt(&pk, b"tier key bytes").unwrap();
143-
let pt = age_decrypt(id.to_string().expose_secret(), &ct).unwrap();
144-
assert_eq!(pt, b"tier key bytes");
145-
}
146183
}

crates/gpp-graphex/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ pub enum Error {
2222
#[error("key store not initialized — run `gpp keys generate`")]
2323
NoKeys,
2424

25+
#[error("this key store is passphrase-protected — set $GPP_GRAPHEX_PASSPHRASE")]
26+
PassphraseRequired,
27+
2528
#[error("unknown access tier {0:?}")]
2629
UnknownTier(String),
2730

0 commit comments

Comments
 (0)