Skip to content

Commit 7c509cb

Browse files
fix(agent): route set_identity through agent to preserve meta-tag integrity (#242)
* fix(agent): route set_identity through agent to preserve meta-tag integrity set_identity previously called save_meta() directly from the CLI process, modifying the .meta file without updating the HMAC meta-tag in the keychain. This broke all subsequent sign operations because ensure_meta_integrity() detected the file/tag mismatch and rejected it as tampering. Route identity updates through a new SetIdentity agent IPC message (0xF7). The agent modifies the .meta file and atomically re-stamps the meta-tag, maintaining integrity. Also fix error swallowing in the sign path where backend.get() failures were logged without the actual error message, and update AGENTS.md with stronger CI/CD-only binary guidance. Closes #240 * fix(agent): make meta-tag re-stamp non-fatal in SetIdentity On platforms where the meta-integrity store is unavailable (e.g. Linux without Secret Service), perform_migrate_meta fails. Since the identity metadata was already saved successfully, log the re-stamp failure as a warning instead of propagating it as an error. * fix(e2e): start agent before sshenc identity in tests sshenc identity now routes through the agent IPC, so the agent must be running. Two e2e tests called sshenc identity without starting the agent first: gitenc_config_signs_commit_and_verifies and identity_persists_through_metadata.
1 parent dfb2d0a commit 7c509cb

9 files changed

Lines changed: 238 additions & 38 deletions

File tree

AGENTS.md

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,34 @@ Use your judgement on Windows/Linux: prefer the installed binary when one
1919
exists for parity reasons, but locally-built binaries are not a footgun on
2020
those platforms the way they are on macOS.
2121

22-
## CRITICAL: Never Run Unsigned Binaries (macOS)
22+
## CRITICAL: Always Use CI/CD-Built Binaries (macOS)
2323

24-
**DO NOT** run binaries from development builds (`cargo build`, `cargo run`, `~/.cargo/bin/*`) **on macOS** in production contexts or when they could access the Secure Enclave. (See the Platform Scope section above for Windows/Linux scoping.)
24+
**NEVER build, codesign, or install custom macOS binaries.** Always use the CI/CD release
25+
pipeline (`git tag vX.Y.Z` → GitHub Actions → Homebrew). This is non-negotiable.
2526

26-
### Why This Matters
27+
The CI/CD pipeline performs steps that **cannot be replicated locally**:
2728

28-
sshenc accesses hardware-backed cryptographic storage (macOS Secure Enclave, Windows TPM, Linux software keys). Running unsigned development builds as agents can:
29+
1. **Apple notarization** — AMFI (Apple Mobile File Integrity) only grants `keychain-access-groups`
30+
entitlements to notarized app bundles. Without notarization, Secure Enclave key creation with
31+
`.userPresence` access control fails with `-25308 (errSecInteractionNotAllowed)`.
32+
2. **Provisioning profile embedding** — required for the app bundle's entitlements to be honored.
33+
3. **Proper app bundle signing** — the entire `.app` bundle must be signed as a unit; signing
34+
individual binaries and placing them in an existing bundle breaks the seal.
2935

30-
1. **Poison the keychain** — unsigned agents create keychain entries that conflict with production agents
31-
2. **Trigger unexpected auth prompts** — users see Touch ID/password prompts from the wrong binary
32-
3. **Break signing** — the wrong agent binary services signing requests, causing failures
33-
4. **Leave stale processes** — development agents don't clean up properly when killed
36+
**DO NOT** run binaries from development builds (`cargo build`, `cargo run`, `~/.cargo/bin/*`)
37+
**on macOS** in production contexts or when they could access the Secure Enclave.
38+
(See the Platform Scope section above for Windows/Linux scoping.)
39+
40+
### What Goes Wrong With Custom Builds
41+
42+
sshenc accesses hardware-backed cryptographic storage (macOS Secure Enclave, Windows TPM, Linux software keys). Running non-CI/CD builds as agents can:
43+
44+
1. **Fail silently on key generation** — AMFI rejects the entitlements, SE key creation with `.userPresence` returns `-25308`
45+
2. **Poison the keychain** — unsigned agents create keychain entries that conflict with production agents
46+
3. **Invalidate HMAC integrity** — unsigned binaries writing to `~/.sshenc/keys/*.meta` files corrupt the HMAC sidecar, breaking key operations for the production binary
47+
4. **Trigger unexpected auth prompts** — users see Touch ID/password prompts from the wrong binary
48+
5. **Break signing** — the wrong agent binary services signing requests, causing failures
49+
6. **Leave stale processes** — development agents don't clean up properly when killed
3450

3551
### Safe vs Unsafe Operations
3652

@@ -132,15 +148,18 @@ cargo test --workspace
132148
cargo clippy --workspace --all-targets -- -D warnings
133149
cargo fmt --all
134150

135-
# DO NOT run the built binaries as daemons
151+
# DO NOT run the built binaries as daemons or install them
136152
# DO NOT run: cargo run --bin sshenc-agent
137153
# DO NOT run: ~/.cargo/bin/sshenc agent
138-
139-
# To test agent changes, install via homebrew first:
140-
cargo build --release
141-
# ... create PR, merge, release via CI ...
142-
brew upgrade sshenc
143-
# NOW it's safe to test agent functionality
154+
# DO NOT copy binaries into /opt/homebrew or any app bundle
155+
# DO NOT attempt to codesign binaries locally — notarization cannot be done locally
156+
157+
# To test agent/signing changes, use the CI/CD release pipeline:
158+
# 1. Create PR, get it reviewed and merged
159+
# 2. Tag a new release: git tag vX.Y.Z && git push origin vX.Y.Z
160+
# 3. Wait for GitHub Actions release workflow to complete
161+
# 4. brew upgrade sshenc
162+
# 5. NOW it's safe to test agent functionality
144163
```
145164

146165
### For Users Building From Source
@@ -190,8 +209,11 @@ git commit -S -m "your message"
190209

191210
## Summary
192211

193-
- ✅ Build and test code with `cargo`
194-
- ✅ Use production agents from `/opt/homebrew/bin`
212+
- ✅ Build and test code with `cargo build`, `cargo test`, `cargo clippy`
213+
- ✅ Use CI/CD-built binaries from Homebrew (`brew upgrade sshenc`)
214+
- ✅ Use the release pipeline for any binary changes (tag → CI → Homebrew)
195215
- ❌ Don't run `~/.cargo/bin/sshenc-agent` as a daemon
216+
- ❌ Don't build, codesign, or install custom macOS binaries — notarization cannot be done locally
217+
- ❌ Don't copy binaries into Homebrew's Cellar or app bundles
196218
- ❌ Don't test signing with development builds
197219
- 🔍 Always verify which agent binary is running before signing operations

crates/sshenc-agent-proto/src/client.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use crate::message::{
2828
self, AgentRequest, AgentResponse, SSH_AGENTC_REQUEST_IDENTITIES, SSH_AGENTC_SIGN_REQUEST,
2929
SSH_AGENTC_SSHENC_CHECK_MIGRATION_MARKER, SSH_AGENTC_SSHENC_DELETE_KEY,
3030
SSH_AGENTC_SSHENC_GENERATE_KEY, SSH_AGENTC_SSHENC_MIGRATE_META, SSH_AGENTC_SSHENC_RENAME_KEY,
31-
SSH_AGENTC_SSHENC_SET_MIGRATION_MARKER,
31+
SSH_AGENTC_SSHENC_SET_IDENTITY, SSH_AGENTC_SSHENC_SET_MIGRATION_MARKER,
3232
};
3333
use std::io::{Read, Write};
3434
use std::path::Path;
@@ -351,6 +351,16 @@ pub fn try_set_migration_marker_via_socket(sock_path: &Path) -> Option<()> {
351351
request_set_migration_marker(&mut stream)
352352
}
353353

354+
pub fn try_set_identity_via_socket(
355+
sock_path: &Path,
356+
label: &str,
357+
name: &str,
358+
email: &str,
359+
) -> Option<()> {
360+
let mut stream = connect_agent(sock_path)?;
361+
request_set_identity(&mut stream, label, name, email)
362+
}
363+
354364
/// Best-effort lookup of the agent socket from the environment —
355365
/// `SSH_AUTH_SOCK` on Unix, or on Windows (cmd.exe, PowerShell, Git
356366
/// Bash, etc.) the same variable if set. If absent, returns `None`
@@ -554,6 +564,32 @@ fn request_migrate_meta<S: Read + Write>(stream: &mut S, label: &str) -> Option<
554564
}
555565
}
556566

567+
fn request_set_identity<S: Read + Write>(
568+
stream: &mut S,
569+
label: &str,
570+
name: &str,
571+
email: &str,
572+
) -> Option<()> {
573+
let payload = message::serialize_request(&AgentRequest::SetIdentity {
574+
label: label.as_bytes().to_vec(),
575+
name: name.as_bytes().to_vec(),
576+
email: email.as_bytes().to_vec(),
577+
});
578+
debug_assert_eq!(payload[0], SSH_AGENTC_SSHENC_SET_IDENTITY);
579+
send_framed(stream, &payload)?;
580+
match recv_response(stream)? {
581+
AgentResponse::Success => Some(()),
582+
AgentResponse::Failure => {
583+
tracing::debug!("agent proxy: set-identity returned FAILURE");
584+
None
585+
}
586+
other => {
587+
tracing::debug!(?other, "agent proxy: unexpected response to set-identity");
588+
None
589+
}
590+
}
591+
}
592+
557593
fn request_check_migration_marker<S: Read + Write>(stream: &mut S) -> Option<bool> {
558594
let payload = message::serialize_request(&AgentRequest::CheckMigrationMarker);
559595
debug_assert_eq!(payload[0], SSH_AGENTC_SSHENC_CHECK_MIGRATION_MARKER);

crates/sshenc-agent-proto/src/message.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ pub const SSH_AGENTC_SSHENC_CHECK_MIGRATION_MARKER: u8 = 0xF5;
5959
/// migrate-meta calls succeed. After this, the agent's legacy_meta
6060
/// error variant switches to the strong-tamper-warning form.
6161
pub const SSH_AGENTC_SSHENC_SET_MIGRATION_MARKER: u8 = 0xF6;
62+
/// `SSH_AGENTC_SSHENC_SET_IDENTITY`: have the agent update the
63+
/// git identity (name + email) in a key's `.meta` and re-stamp the
64+
/// meta-tag. The CLI must NOT modify `.meta` directly — every meta
65+
/// mutation goes through the agent so the keychain tag stays in sync.
66+
pub const SSH_AGENTC_SSHENC_SET_IDENTITY: u8 = 0xF7;
6267

6368
// sshenc-specific response: carries the public-key bytes of a
6469
// freshly-generated key back to the CLI. On failure the agent uses
@@ -168,6 +173,15 @@ pub enum AgentRequest {
168173
/// write the migrate-meta marker to the keychain. Sent by the
169174
/// CLI after all per-label migrations succeed.
170175
SetMigrationMarker,
176+
/// `SSH_AGENTC_SSHENC_SET_IDENTITY` (sshenc extension):
177+
/// update the git name and email in a key's `.meta` and re-stamp
178+
/// the meta-tag atomically. The CLI delegates here instead of
179+
/// writing `.meta` directly so the keychain tag is always in sync.
180+
SetIdentity {
181+
label: Vec<u8>,
182+
name: Vec<u8>,
183+
email: Vec<u8>,
184+
},
171185
/// An unrecognized message type.
172186
Unknown(u8),
173187
}
@@ -271,6 +285,13 @@ pub fn parse_request(payload: &[u8]) -> Result<AgentRequest> {
271285
}
272286
SSH_AGENTC_SSHENC_CHECK_MIGRATION_MARKER => Ok(AgentRequest::CheckMigrationMarker),
273287
SSH_AGENTC_SSHENC_SET_MIGRATION_MARKER => Ok(AgentRequest::SetMigrationMarker),
288+
SSH_AGENTC_SSHENC_SET_IDENTITY => {
289+
let mut cursor = Cursor::new(body);
290+
let label = wire::read_string(&mut cursor)?;
291+
let name = wire::read_string(&mut cursor)?;
292+
let email = wire::read_string(&mut cursor)?;
293+
Ok(AgentRequest::SetIdentity { label, name, email })
294+
}
274295
other => Ok(AgentRequest::Unknown(other)),
275296
}
276297
}
@@ -370,6 +391,13 @@ pub fn serialize_request(request: &AgentRequest) -> Vec<u8> {
370391
}
371392
AgentRequest::CheckMigrationMarker => vec![SSH_AGENTC_SSHENC_CHECK_MIGRATION_MARKER],
372393
AgentRequest::SetMigrationMarker => vec![SSH_AGENTC_SSHENC_SET_MIGRATION_MARKER],
394+
AgentRequest::SetIdentity { label, name, email } => {
395+
let mut buf = vec![SSH_AGENTC_SSHENC_SET_IDENTITY];
396+
wire::write_string(&mut buf, label);
397+
wire::write_string(&mut buf, name);
398+
wire::write_string(&mut buf, email);
399+
buf
400+
}
373401
AgentRequest::Unknown(t) => vec![*t],
374402
}
375403
}
@@ -1199,4 +1227,34 @@ mod tests {
11991227
"got {parsed:?}"
12001228
);
12011229
}
1230+
1231+
// --- sshenc SetIdentity extension ---
1232+
1233+
#[test]
1234+
fn roundtrip_set_identity_request() {
1235+
let original = AgentRequest::SetIdentity {
1236+
label: b"my-key".to_vec(),
1237+
name: b"Jay Gowdy".to_vec(),
1238+
email: b"jay@example.com".to_vec(),
1239+
};
1240+
let payload = serialize_request(&original);
1241+
assert_eq!(payload[0], SSH_AGENTC_SSHENC_SET_IDENTITY);
1242+
let parsed = parse_request(&payload).unwrap();
1243+
match parsed {
1244+
AgentRequest::SetIdentity { label, name, email } => {
1245+
assert_eq!(label, b"my-key");
1246+
assert_eq!(name, b"Jay Gowdy");
1247+
assert_eq!(email, b"jay@example.com");
1248+
}
1249+
other => panic!("expected SetIdentity, got {other:?}"),
1250+
}
1251+
}
1252+
1253+
#[test]
1254+
fn parse_rejects_truncated_set_identity_request() {
1255+
let mut payload = vec![SSH_AGENTC_SSHENC_SET_IDENTITY];
1256+
wire::write_string(&mut payload, b"label");
1257+
// Missing name and email
1258+
assert!(parse_request(&payload).is_err());
1259+
}
12021260
}

crates/sshenc-agent/src/server.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1288,9 +1288,10 @@ async fn handle_request(
12881288
.await
12891289
{
12901290
Ok(Ok(k)) => k,
1291-
Ok(Err(_)) => {
1291+
Ok(Err(e)) => {
12921292
tracing::warn!(
12931293
label = label.as_str(),
1294+
error = %e,
12941295
"key lookup failed; evicting from cache"
12951296
);
12961297
blob_cache.remove(&key_blob);
@@ -1797,6 +1798,82 @@ async fn handle_request(
17971798
}
17981799
}
17991800
}
1801+
AgentRequest::SetIdentity { label, name, email } => {
1802+
let label_str = match std::str::from_utf8(&label) {
1803+
Ok(s) => s,
1804+
Err(_) => {
1805+
tracing::warn!("set_identity: label not valid UTF-8");
1806+
return Ok(AgentResponse::Failure);
1807+
}
1808+
};
1809+
let name_str = match std::str::from_utf8(&name) {
1810+
Ok(s) => s,
1811+
Err(_) => {
1812+
tracing::warn!("set_identity: name not valid UTF-8");
1813+
return Ok(AgentResponse::Failure);
1814+
}
1815+
};
1816+
let email_str = match std::str::from_utf8(&email) {
1817+
Ok(s) => s,
1818+
Err(_) => {
1819+
tracing::warn!("set_identity: email not valid UTF-8");
1820+
return Ok(AgentResponse::Failure);
1821+
}
1822+
};
1823+
tracing::info!(label = label_str, "handling set_identity request");
1824+
1825+
if !allowed_labels.is_empty() && !allowed_labels.contains(label_str) {
1826+
tracing::warn!(
1827+
label = label_str,
1828+
"set_identity: label not in agent's allowed list"
1829+
);
1830+
return Ok(AgentResponse::Failure);
1831+
}
1832+
1833+
let dir = sshenc_se::sshenc_keys_dir();
1834+
let started = Instant::now();
1835+
1836+
let result = (|| -> Result<(), String> {
1837+
let mut meta = sshenc_se::compat::load_sshenc_meta(&dir, label_str)
1838+
.map_err(|e| format!("load meta: {e}"))?;
1839+
meta.set_app_field("git_name", name_str.to_string());
1840+
meta.set_app_field("git_email", email_str.to_string());
1841+
enclaveapp_core::metadata::save_meta(&dir, label_str, &meta)
1842+
.map_err(|e| format!("save meta: {e}"))?;
1843+
if let Err(e) = perform_migrate_meta(&dir, label_str) {
1844+
tracing::warn!(
1845+
label = label_str,
1846+
error = %e,
1847+
"set_identity: meta-tag re-stamp failed (meta saved successfully)"
1848+
);
1849+
}
1850+
Ok(())
1851+
})();
1852+
1853+
let error_msg = result
1854+
.as_ref()
1855+
.err()
1856+
.map(|e| format!("set_identity failed: {e}"));
1857+
crate::op_log::record(
1858+
"set_identity",
1859+
Some(label_str),
1860+
None,
1861+
started.elapsed(),
1862+
result.is_ok(),
1863+
error_msg.as_deref(),
1864+
None,
1865+
);
1866+
match result {
1867+
Ok(()) => {
1868+
tracing::info!(label = label_str, "set_identity: succeeded");
1869+
Ok(AgentResponse::Success)
1870+
}
1871+
Err(e) => {
1872+
tracing::warn!(label = label_str, error = %e, "set_identity: failed");
1873+
Ok(AgentResponse::Failure)
1874+
}
1875+
}
1876+
}
18001877
AgentRequest::CheckMigrationMarker => {
18011878
// Success = marker SET. Failure = marker NOT set or
18021879
// keychain unreachable. The CLI treats both failure

crates/sshenc-cli/src/commands.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,21 +2130,16 @@ pub fn promote_to_default(label: &str) -> Result<()> {
21302130
}
21312131

21322132
#[allow(clippy::print_stdout)]
2133-
pub fn set_identity(label: &str, name: &str, email: &str) -> Result<()> {
2133+
pub fn set_identity(label: &str, name: &str, email: &str, socket_path: &Path) -> Result<()> {
21342134
let _label = KeyLabel::new(label)?;
21352135

2136-
use enclaveapp_core::metadata;
2137-
2138-
let keys_dir = sshenc_keys_dir();
2139-
2140-
let mut meta = sshenc_se::compat::load_sshenc_meta(&keys_dir, label)
2141-
.map_err(|e| anyhow::anyhow!("failed to load metadata for '{label}': {e}"))?;
2142-
meta.set_app_field("git_name", name.to_string());
2143-
meta.set_app_field("git_email", email.to_string());
2144-
2145-
// Write updated metadata
2146-
metadata::save_meta(&keys_dir, label, &meta)
2147-
.map_err(|e| anyhow::anyhow!("failed to save metadata for '{label}': {e}"))?;
2136+
sshenc_agent_proto::client::try_set_identity_via_socket(socket_path, label, name, email)
2137+
.ok_or_else(|| {
2138+
anyhow::anyhow!(
2139+
"failed to set identity for '{label}': agent did not accept the request. \
2140+
Is the sshenc agent running?"
2141+
)
2142+
})?;
21482143

21492144
println!("Set identity for key '{label}':");
21502145
println!(" Name: {name}");

crates/sshenc-cli/src/main.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -385,15 +385,19 @@ fn main() -> Result<()> {
385385
// produce protocol-mismatch errors on benign setups.
386386
let socket_path = commands::client_socket_path(&config.socket_path);
387387
let backend: Box<dyn sshenc_se::KeyBackend> = Box::new(
388-
sshenc_se::AgentProxyBackend::new(config.pub_dir.clone(), socket_path)
388+
sshenc_se::AgentProxyBackend::new(config.pub_dir.clone(), socket_path.clone())
389389
.map_err(|e| anyhow::anyhow!("failed to initialize agent-proxy backend: {e}"))?,
390390
);
391391

392-
run_command(cli.command, &*backend)
392+
run_command(cli.command, &*backend, &socket_path)
393393
}
394394

395395
#[allow(clippy::print_stdout, clippy::print_stderr)]
396-
fn run_command(command: Commands, backend: &dyn sshenc_se::KeyBackend) -> Result<()> {
396+
fn run_command(
397+
command: Commands,
398+
backend: &dyn sshenc_se::KeyBackend,
399+
socket_path: &std::path::Path,
400+
) -> Result<()> {
397401
match command {
398402
Commands::Keygen {
399403
label,
@@ -572,7 +576,9 @@ fn run_command(command: Commands, backend: &dyn sshenc_se::KeyBackend) -> Result
572576
},
573577
Commands::Install => commands::install(),
574578
Commands::Uninstall => commands::uninstall(),
575-
Commands::Identity { label, name, email } => commands::set_identity(&label, &name, &email),
579+
Commands::Identity { label, name, email } => {
580+
commands::set_identity(&label, &name, &email, socket_path)
581+
}
576582
Commands::Default { label } => commands::promote_to_default(&label),
577583
Commands::Ssh { label, ssh_args } => commands::ssh_wrapper(label.as_deref(), &ssh_args),
578584
#[cfg(windows)]

0 commit comments

Comments
 (0)