Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crosslink/src/commands/locks_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,26 @@ pub fn sync_cmd(crosslink_dir: &Path, db: &Database) -> Result<()> {
Err(e) => tracing::warn!("could not publish agent key: {}", e),
}

// Snapshot bootstrap status before `configure_signing` so we can
// detect — and announce — the GH#738 self-trust auto-completion
// that flips the hub out of bootstrap on the operator's first sync.
let bootstrap_before = crate::sync::bootstrap::read_bootstrap_state(sync.cache_path())
.map(|s| s.status)
.unwrap_or_default();

if let Err(e) = sync.configure_signing(crosslink_dir) {
tracing::warn!("could not configure commit signing: {e} — commits will be unsigned");
}

// GH#738: surface bootstrap completion when it happened during this
// sync (parity with `crosslink trust approve`'s post-completion msg).
let bootstrap_after = crate::sync::bootstrap::read_bootstrap_state(sync.cache_path())
.map(|s| s.status)
.unwrap_or_default();
if bootstrap_before == "pending" && bootstrap_after == "complete" {
println!("Bootstrap complete — signing enforcement is now active.");
}

// Upgrade v1 layouts to v2 if needed (migrates inline comments to standalone files)
match sync.upgrade_to_v2() {
Ok(0) => {} // already v2 or nothing to migrate
Expand Down
8 changes: 7 additions & 1 deletion crosslink/src/sync/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ pub struct BootstrapState {
}

/// Commit message prefixes that identify inherently-unsigned bootstrap commits.
/// These are generated by `init_cache()` and `ensure_agent_key_published()`.
/// These are generated by `init_cache()`, `ensure_agent_key_published()`, and
/// `register_active_key_as_trusted()` (the workspace-local trust self-bootstrap
/// introduced by GH#585).
const BOOTSTRAP_MESSAGE_PREFIXES: &[&str] = &[
"Initialize crosslink/hub branch",
"trust: publish key for agent",
"trust: register signing key",
"sync: auto-stage dirty hub state",
"bootstrap: register agent",
];
Expand Down Expand Up @@ -142,6 +145,9 @@ mod tests {
fn test_is_bootstrap_message() {
assert!(is_bootstrap_message("Initialize crosslink/hub branch"));
assert!(is_bootstrap_message("trust: publish key for agent 'foo'"));
assert!(is_bootstrap_message(
"trust: register signing key for 'foo@crosslink'"
));
assert!(is_bootstrap_message(
"sync: auto-stage dirty hub state (recovery)"
));
Expand Down
101 changes: 101 additions & 0 deletions crosslink/src/sync/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2751,6 +2751,107 @@ fn test_configure_signing_self_registration_is_idempotent() {
);
}

// ========================================================================
// GH#738: when register_active_key_as_trusted adds a new entry while the
// hub is still in bootstrap "pending", it must atomically flip bootstrap
// to "complete". Without this, trust-pending lies (nothing pending — the
// key was auto-trusted) and signing_enforcement=enforced bails forever.
// ========================================================================

#[test]
fn test_configure_signing_completes_bootstrap_on_first_self_registration() {
let (work_dir, _remote_dir) = setup_sync_env();
let crosslink_dir = work_dir.path().join(".crosslink");
let manager = SyncManager::new(&crosslink_dir).unwrap();
manager.init_cache().unwrap();

// init_cache wrote meta/bootstrap.json with status="pending".
let pre = crate::sync::bootstrap::read_bootstrap_state(manager.cache_path())
.expect("init_cache should write bootstrap.json");
assert_eq!(pre.status, "pending", "fresh hub starts pending");
assert!(pre.completed_at.is_none());

let keys_dir = crosslink_dir.join("keys");
std::fs::create_dir_all(&keys_dir).unwrap();
let key_file = keys_dir.join("driver_ed25519");
std::fs::write(&key_file, "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n").unwrap();
let pubkey_line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAGH738-bootstrap-flip driver@host";
std::fs::write(format!("{}.pub", key_file.display()), pubkey_line).unwrap();
set_repo_signing_key(work_dir.path(), &key_file);

let agent = AgentConfig {
agent_id: "bootstrap-flip-driver".to_string(),
machine_id: "test-host".to_string(),
description: None,
role: AgentRole::Driver,
ssh_key_path: None,
ssh_fingerprint: None,
ssh_public_key: None,
};
let agent_json = serde_json::to_string_pretty(&agent).unwrap();
std::fs::write(crosslink_dir.join("agent.json"), agent_json).unwrap();

manager.configure_signing(&crosslink_dir).unwrap();

let post = crate::sync::bootstrap::read_bootstrap_state(manager.cache_path())
.expect("bootstrap.json must still exist after configure_signing");
assert_eq!(
post.status, "complete",
"self-registering the workspace key during bootstrap must flip status to complete"
);
assert!(
post.completed_at.is_some(),
"complete state must carry a timestamp"
);
}

#[test]
fn test_configure_signing_does_not_revisit_completed_bootstrap() {
// Idempotency check: when the same workspace syncs again later, the
// key is already trusted (register short-circuits) and bootstrap must
// remain "complete" with its original completed_at timestamp.
let (work_dir, _remote_dir) = setup_sync_env();
let crosslink_dir = work_dir.path().join(".crosslink");
let manager = SyncManager::new(&crosslink_dir).unwrap();
manager.init_cache().unwrap();

let keys_dir = crosslink_dir.join("keys");
std::fs::create_dir_all(&keys_dir).unwrap();
let key_file = keys_dir.join("driver_ed25519");
std::fs::write(&key_file, "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n").unwrap();
let pubkey_line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAGH738-idempotent driver@host";
std::fs::write(format!("{}.pub", key_file.display()), pubkey_line).unwrap();
set_repo_signing_key(work_dir.path(), &key_file);

let agent = AgentConfig {
agent_id: "bootstrap-idempotent-driver".to_string(),
machine_id: "test-host".to_string(),
description: None,
role: AgentRole::Driver,
ssh_key_path: None,
ssh_fingerprint: None,
ssh_public_key: None,
};
let agent_json = serde_json::to_string_pretty(&agent).unwrap();
std::fs::write(crosslink_dir.join("agent.json"), agent_json).unwrap();

manager.configure_signing(&crosslink_dir).unwrap();
let after_first = crate::sync::bootstrap::read_bootstrap_state(manager.cache_path())
.expect("first call should complete bootstrap");
assert_eq!(after_first.status, "complete");
let first_ts = after_first.completed_at.clone();

manager.configure_signing(&crosslink_dir).unwrap();
manager.configure_signing(&crosslink_dir).unwrap();

let after_repeat = crate::sync::bootstrap::read_bootstrap_state(manager.cache_path()).unwrap();
assert_eq!(after_repeat.status, "complete");
assert_eq!(
after_repeat.completed_at, first_ts,
"completed_at must not be rewritten when bootstrap was already complete"
);
}

#[test]
fn test_configure_signing_skips_self_registration_when_pub_missing() {
// When the .pub companion isn't on disk, self-registration must
Expand Down
49 changes: 44 additions & 5 deletions crosslink/src/sync/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,12 +607,25 @@ impl SyncManager {
/// <agent>` ever wrote to `allowed_signers`. The driver's own signing
/// key (selected by `configure_signing`) was never registered, so every
/// signed hub commit out of a driver workspace failed verification.
///
/// GH#738: when this function actually adds a new entry while the hub is
/// still in `bootstrap.status = "pending"`, the registration *is* the
/// trust-establishment event — morally identical to running `crosslink
/// trust approve` on the workspace's own key — so we also flip bootstrap
/// to `"complete"` atomically in the same unsigned commit. Without this,
/// `trust pending` reports nothing pending (because the key is already
/// trusted by self-registration), and the bootstrap state would remain
/// "pending" forever, blocking signing enforcement.
///
/// Returns `Ok(true)` when an entry was added (and, by implication, when
/// bootstrap may have been completed). `Ok(false)` when the key was
/// already trusted under some principal.
fn register_active_key_as_trusted(
cache_dir: &Path,
crosslink_dir: &Path,
private_key_path: &Path,
allowed_signers_path: &Path,
) -> Result<()> {
) -> Result<bool> {
use crate::signing::{AllowedSignerEntry, AllowedSigners};

// Resolve the public-key companion file (SSH convention: <key>.pub).
Expand All @@ -624,13 +637,13 @@ fn register_active_key_as_trusted(
"skipping allowed_signers self-registration: cannot read pubkey at {}: {e}",
public_key_path.display()
);
return Ok(());
return Ok(false);
}
};

let mut signers = AllowedSigners::load(allowed_signers_path)?;
if signers.contains_key(&public_key) {
return Ok(()); // Already trusted under some principal — no-op.
return Ok(false); // Already trusted under some principal — no-op.
}

// Pick a principal: prefer the agent.json identity for visibility,
Expand All @@ -650,6 +663,21 @@ fn register_active_key_as_trusted(
});
signers.save(allowed_signers_path)?;

// GH#738: If the hub is still in the bootstrap "pending" state, this
// self-registration completes bootstrap. The flag file is staged
// alongside allowed_signers in the same atomic commit below.
let bootstrap_completed_now =
if let Some(state) = super::bootstrap::read_bootstrap_state(cache_dir) {
if state.status == "pending" {
super::bootstrap::complete_bootstrap(cache_dir)?;
true
} else {
false
}
} else {
false
};

// Commit unsigned; best-effort. If the commit fails (e.g. nothing
// staged because of a race), the on-disk file is still correct for
// local verification, and the next push will pick up any residue.
Expand All @@ -658,9 +686,11 @@ fn register_active_key_as_trusted(
"registered '{principal}' in allowed_signers on disk but commit failed: {e} \
(run `crosslink sync` to recover)"
);
} else if bootstrap_completed_now {
tracing::info!("bootstrap completed: self-registered '{principal}' as trusted signer");
}

Ok(())
Ok(true)
}

/// Compute the conventional public-key path for an SSH private key
Expand All @@ -671,7 +701,9 @@ fn with_pub_extension(private_key_path: &Path) -> PathBuf {
PathBuf::from(s)
}

/// Stage `trust/allowed_signers` and commit it without signing.
/// Stage `trust/allowed_signers` (plus `meta/bootstrap.json` when present,
/// to fold a bootstrap state-flip into the same commit; see GH#738) and
/// commit it without signing.
///
/// Used only by [`register_active_key_as_trusted`]. Unsigned commit is
/// required because the just-added key isn't yet visible in any earlier
Expand All @@ -696,6 +728,13 @@ fn commit_allowed_signers_unsigned(cache_dir: &Path, principal: &str) -> Result<
};

run(&["add", "trust/allowed_signers"])?;
// GH#738: when bootstrap was just completed (file written by the
// caller before this commit), fold the state-flip into the same
// atomic commit. Best-effort — if the file is absent or unchanged,
// `git add` is a no-op and the commit still succeeds.
if cache_dir.join("meta").join("bootstrap.json").exists() {
let _ = run(&["add", "meta/bootstrap.json"]);
}
run(&[
"-c",
"commit.gpgsign=false",
Expand Down
Loading