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
2 changes: 1 addition & 1 deletion .claude/skills/sc-cli-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ cargo run --bin sc -- demo --agents 4 --checkout # also materialize each workt
# temp dir (the one disk-writing path), then clean up
cargo run --bin sc -- import --repo <path> # import a Git repo's HEAD
bash demo/run_demo.sh # independent zero-residue proof
cargo run --bin sc -- keygen [--out <file>] # (default ~/.sc/identity; 0600 on unix only) v2 identity: one seed derives BOTH an X25519
cargo run --bin sc -- keygen [--out <file>] # (default ~/.sc/identity; 0600 on unix, owner-only DACL on Windows) v2 identity: one seed derives BOTH an X25519
# encryption key and an Ed25519 signing key (P22);
# a pre-P22 v1 (scl-sk-) identity still parses and
# still encrypts, but cannot sign
Expand Down
17 changes: 13 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ jobs:
run: cargo doc --workspace --no-deps

# Advisory (not a required context): proves the Windows build and the owner-only
# ACL on .sc/local-key (#214). Deliberately not `cargo test --workspace`; the
# wider suite still has unix-only assumptions.
# ACL on .sc/local-key, the `sc keygen` identity and the TLS serve key (#214,
# #281). Deliberately not `cargo test --workspace`; the wider suite still has
# unix-only assumptions.
windows:
name: windows (check + key ACL tests)
name: windows (check + secret-file ACL tests)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -68,8 +69,16 @@ jobs:
run: cargo check --workspace --all-targets --keep-going
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: key ACL and cache tests
- name: owner-only helper tests (scl-core)
run: cargo test -p scl-core --lib "owner_only::"
- name: local-key ACL and cache tests
run: cargo test -p scl-repo --lib "cache::"
- name: TLS serve key ACL test
run: cargo test -p scl-repo --lib "minted_serve_tls_key"
- name: TLS identity injection tests (scl-tlsio)
run: cargo test -p scl-tlsio --lib "identity::"
- name: keygen identity ACL test
run: cargo test -p scl-cli --test keygen_overwrite

desktop:
name: desktop (typecheck + test + audit)
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1120,7 +1120,7 @@ Trust is accept-new TOFU, the SSH `known_hosts` shape: the client pins
`SC_HTTPS_FINGERPRINT` pre-pins for CI;
`SC_HTTPS_STRICT=1` refuses unknown hosts). A pin mismatch always
hard-fails. The server auto-mints a self-signed identity into
`.sc/serve-tls/` (key `0600`) unless PEM is supplied; `sc serve fingerprint`
`.sc/serve-tls/` (key `0600`; owner-only DACL on Windows, #281) unless PEM is supplied; `sc serve fingerprint`
prints the pin an operator distributes out-of-band. Follow-ons: `sc tls
list/forget/pin/path` manages the pin file (ADR-0053), and a client can opt into
CA validation instead of pinning with `SC_HTTPS_CA_FILE=<pem>` or
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,11 @@ transport-adjacent. The rest, imperatively:
transferred.** Cache saves are best-effort and ordered after ref moves; a
cache failure may cause a spurious re-seal but never incorrectness. The key
is owner-only on unix (`0600`) and Windows (single-ACE protected DACL, from the first byte;
`crates/repo/src/winacl.rs`); other targets, or a failed Windows ACL call, keep default
permissions and warn once (#214).
`crates/core/src/winacl.rs` behind `scl_core::owner_only`, the one creation primitive); other
targets, or a failed Windows ACL call, keep default permissions and warn once (#214). The
`sc keygen` identity and the TLS serve key use the same primitive (#281); `tlsio` is a leaf, so
`load_or_mint` takes the creator from `repo` (`load_or_mint_serve_identity`) and never calls
core. `windows-sys` and the sole Windows `unsafe` live in `scl-core` only.
- **Revoke is standing-only.** A revoked recipient keeps decrypting ciphertext
sealed before the revoke (they hold the old wraps); cryptographic cutover is
`sc rewrap --rotate` at the tip (fresh key per protected blob; a plain
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ CI (`ci.yml`) runs `fmt --check`, `clippy -D warnings`, `cargo test --workspace
--all-targets` (Linux and macOS) and `cargo doc` for the Rust workspace, plus a
`desktop` job (`npm ci`, `npm audit --audit-level=high`, typecheck, test).
An advisory `windows` job (`cargo check` and `clippy` for the workspace, plus
the `scl-repo` `cache::` tests, which verify the owner-only ACL on
`.sc/local-key`) is not a required check; keep it green when you touch
the `owner_only`, `cache::`, serve-key, `tlsio` identity and keygen tests, which
verify the owner-only ACL on `.sc/local-key`, the `sc keygen` identity and the TLS
serve key) is not a required check; keep it green when you touch
Windows-specific code. Your PR must be green on all required jobs. Changes to
`Cargo.toml` or `Cargo.lock` also run the RustSec `audit` workflow (it runs daily
as well). CodeQL, Scorecard, the SBOM generation, the `vendor-provenance` check
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 3 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,9 @@ across every phase.
verified by the advisory `windows` CI job (#214). Windows keys created
before that keep their inherited DACL and no longer warn; delete
`.sc/local-key` to recreate it owner-only. `sc keygen` now refuses to
overwrite an existing identity on Windows too (#281 tracks owner-only
protection for the identity file and the TLS serve key).
overwrite an existing identity on Windows too. The `sc keygen` identity
file and the auto-minted TLS serve key get the same owner-only DACL through
one shared primitive, `scl_core::owner_only` (#281).

## Active

Expand Down Expand Up @@ -833,12 +834,6 @@ smaller ones (protected-cache hygiene, theirs-side resolve carry, sparse and
P28 review follow-ons, the port de-flake, the clippy allow-list) are recorded in
`CLAUDE.md`, `ARCHITECTURE.md` and the ADRs.

- **Windows owner-only protection for the `sc keygen` identity file and the TLS
serve key (#281, follow-on to #214).** `.sc/local-key` gets an owner-only DACL;
the identity file (`sc keygen --out`, `0600` on unix only) and the serve-home
TLS key still get default permissions on non-unix. The helper lives in
`scl-repo` (`pub(crate)`), and `tlsio` is a leaf that must not depend on it, so
it moves to `core` or reaches `tlsio` as a callback.
- **Remaining history-editing depth:** operation objects in the CAS
(Jujutsu-deep upgrade to the file oplog), oplog entries for
remote-tracking refs.
Expand Down
37 changes: 26 additions & 11 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1082,7 +1082,25 @@ fn init_max_object_size(flag: Option<&str>) -> Result<()> {
scl_core::set_max_object_size(n).map_err(|e| anyhow::anyhow!("{src}: {e}"))
}

/// Windows gives the main thread a 1 MiB stack, which the clap parse and the
/// large command dispatch overflow in a debug build (`sc.exe` died with a stack
/// overflow before reaching any command). Run on a roomier thread there; other
/// platforms keep the main thread untouched.
#[cfg(windows)]
fn main() -> Result<()> {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(real_main)?
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}

#[cfg(not(windows))]
fn main() -> Result<()> {
real_main()
}

fn real_main() -> Result<()> {
let cli = Cli::parse();
init_max_object_size(match &cli.cmd {
Cmd::Serve {
Expand Down Expand Up @@ -1669,8 +1687,8 @@ fn resolve_identity_path(flag: Option<PathBuf>) -> PathBuf {
/// `sc keygen`: generate a v2 identity — a single random seed from which both
/// the X25519 encryption key and the Ed25519 signing key are deterministically
/// derived (`scl_crypto::generate_identity_v2`). Only the seed (the `scl-id-`
/// line) is written to disk, 0600, and never echoed to stdout — it is the
/// whole private identity, encryption *and* signing, so it gets the same
/// line) is written to disk, owner-only (0600 / Windows DACL), and never
/// echoed to stdout — it is the whole private identity, encryption *and* signing, so it gets the same
/// discipline the v1 private key always had (see the P5-scanner memory: an
/// identity file is scanner-bait and must stay outside any working tree).
/// Both PUBLIC halves are printed, with registration hints for both
Expand Down Expand Up @@ -1699,17 +1717,14 @@ fn run_keygen(out: Option<PathBuf>) -> Result<()> {
.expect("generate_identity_v2 always derives a signing half");
let enc_pk = identity.enc.public();
let sig_pk = signing.public();
// Create the key file 0600 *atomically* so the private key is never visible
// group/world-readable through the umask window between write and chmod.
// `create_new(true)` refuses to clobber an existing identity, on every
// platform. Windows has no owner-only protection for this file (#281).
// Create the key file owner-only *atomically* (unix 0600, Windows protected
// DACL) so the private key is never visible to other users through a
// window between write and chmod/ACL. `create_new` semantics refuse to
// clobber an existing identity, on every platform. If Windows cannot apply
// the ACL, the file gets default permissions and a one-time warning.
{
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(&path).map_err(|e| {
let mut f = scl_core::owner_only::create_new_owner_only_or_warn(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
anyhow::anyhow!(
"identity already exists at {} (refusing to overwrite)",
Expand Down
46 changes: 46 additions & 0 deletions crates/cli/tests/keygen_overwrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,49 @@ fn keygen_refuses_to_overwrite_an_existing_identity() {
std::fs::remove_dir_all(&dir).unwrap();
assert!(!dir.exists());
}

/// Windows: the identity file is created with a single explicit, non-inherited
/// full-control ACE for the current user, read back with `icacls` (#281). On
/// unix it is 0600.
#[test]
fn keygen_identity_file_is_owner_only() {
let dir = std::env::temp_dir().join(format!("scl-cli-keygen-acl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let id = dir.join("alice.id");
let out = Command::new(env!("CARGO_BIN_EXE_sc"))
.args(["keygen", "--out", id.to_str().unwrap()])
.current_dir(&dir)
.output()
.expect("sc runs");
assert!(out.status.success(), "{out:?}");
assert!(
!String::from_utf8_lossy(&out.stderr).contains("warning"),
"no fallback warning expected: {out:?}"
);

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&id).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(windows)]
{
let out = Command::new("icacls").arg(&id).output().unwrap();
assert!(out.status.success(), "icacls failed: {out:?}");
let text = String::from_utf8_lossy(&out.stdout).to_string();
let acl = text.replace(id.to_str().unwrap(), "");
let aces: Vec<&str> = acl.lines().filter(|l| l.contains(":(")).collect();
let user = std::env::var("USERNAME").unwrap().to_lowercase();
assert_eq!(aces.len(), 1, "exactly one ACE expected:\n{text}");
let ace = aces[0].trim().to_lowercase();
assert!(
ace.contains(&format!("\\{user}:(")) && ace.contains("(f)") && !ace.contains("(i)"),
"ACE must be an explicit full-control grant to {user}:\n{text}"
);
}

std::fs::remove_dir_all(&dir).unwrap();
assert!(!dir.exists());
}
4 changes: 4 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ thiserror = "2.0.18"
hex = "0.4.3"
zstd = "0.14.0"

# Owner-only DACL for secret files (#214, #281); the only unsafe is in src/winacl.rs.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }

[lints]
workspace = true

Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ pub mod error;
pub mod fsutil;
pub mod id;
pub mod object;
pub mod owner_only;
pub mod pack;
pub mod store;
#[cfg(windows)]
mod winacl;

/// Default for the largest single object (decoded/decompressed canonical
/// bytes) this system accepts from an untrusted source. The live value is
Expand Down
Loading
Loading