mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 18:08:02 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30136efdd | ||
|
|
a00d7d5c48 | ||
|
|
4cd0a6a306 | ||
|
|
0919cf7c62 | ||
|
|
05afd68e41 | ||
|
|
6c971e7ea0 | ||
|
|
10d5b98b2b | ||
|
|
1544bedea7 | ||
|
|
15e017ad77 |
No files matched your search
@@ -58,4 +58,16 @@ ignore = [
|
||||
# (verified 2026-11-03). REMOVE only when Cargo.lock has no crypto-bigint
|
||||
# below 0.7.6: `grep -A1 'name = "crypto-bigint"' Cargo.lock`.
|
||||
"RUSTSEC-2026-0387", # crypto-bigint timing variability (no compatible fix)
|
||||
|
||||
# libsqlite3-sys 0.30.1 (bundled SQLite WAL uninitialized-memory disclosure,
|
||||
# CVE-2026-119375). Present in Cargo.lock because sqlx resolves sqlx-sqlite
|
||||
# unconditionally; sqlx is built postgres-only
|
||||
# (crates/gitlawb-node/Cargo.toml), so sqlx-sqlite and its bundled SQLite C
|
||||
# source are never compiled or linked (`cargo tree -i sqlx-sqlite` prints
|
||||
# nothing) and there is no reachable WAL open to attack. The fix requires
|
||||
# libsqlite3-sys >= 0.39, but sqlx-sqlite 0.8.6 pins ^0.30.1; a compatible
|
||||
# bump needs a sqlx upgrade, not a lockfile refresh. REMOVE this ignore if
|
||||
# sqlx is ever built with the `sqlite` feature or any other libsqlite3-sys
|
||||
# consumer enters the build.
|
||||
"RUSTSEC-2026-0459", # libsqlite3-sys bundled SQLite CVE (not linked in our build)
|
||||
]
|
||||
@@ -347,6 +347,14 @@ GITLAWB_TASK_READ_RATE_LIMIT=1200
|
||||
# brake caps a single-source flood. Uses GITLAWB_TRUSTED_PROXY below to resolve
|
||||
# the client IP. 0 disables. Default 120.
|
||||
GITLAWB_CREATE_RATE_LIMIT=120
|
||||
# Per-DID creation requests allowed inside the window below (repo create,
|
||||
# issue create, PR create, register, fork). 0 is rejected at startup: it would
|
||||
# silently disable the limiter. Default 10.
|
||||
GITLAWB_RATE_LIMIT_MAX=10
|
||||
# Window the per-DID creation count above applies over, in seconds. 0 is
|
||||
# rejected at startup: every timestamp would prune immediately, disabling the
|
||||
# limiter. Default 3600.
|
||||
GITLAWB_RATE_LIMIT_WINDOW_SECS=3600
|
||||
|
||||
# ── Peer-sync rate limiting (per client IP, uses GITLAWB_TRUSTED_PROXY below) ─
|
||||
# /api/v1/peers/announce and /api/v1/sync/notify accept unsigned requests from
|
||||
|
||||
Generated
+1
@@ -3601,6 +3601,7 @@ dependencies = [
|
||||
"ed25519-dalek 2.2.0",
|
||||
"gitlawb-core",
|
||||
"icaptcha-client",
|
||||
"libc",
|
||||
"mockito",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Canonical seed list for the Twigpine network. Merged with GITLAWB_BOOTSTRAP_PEERS at startup. PRs to add public nodes welcome.",
|
||||
"version": 1,
|
||||
"updated": "2026-06-30",
|
||||
"updated": "2026-10-02",
|
||||
"peers": [
|
||||
{
|
||||
"name": "gitlawb",
|
||||
@@ -42,6 +42,14 @@
|
||||
"http_url": "https://rapybus-gitlawb.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-05-16"
|
||||
},
|
||||
{
|
||||
"name": "moonwake-ledger",
|
||||
"operator": "Moonwake Ledger",
|
||||
"did": "did:key:z6Mkh9AZ46Z3RkGZfFCom5vzX2535f54d6G2o2ZGkCqjwW8K",
|
||||
"http_url": "https://node.moonwakeledger.com",
|
||||
"p2p_multiaddr": null,
|
||||
"added": "2026-10-02"
|
||||
}
|
||||
]
|
||||
}
|
||||
+228
-11
@@ -41,6 +41,30 @@ use crate::{Error, Result};
|
||||
/// The certificate type discriminant. Always `"gitlawb/ref-update/v1"`.
|
||||
pub const CERT_TYPE: &str = "gitlawb/ref-update/v1";
|
||||
|
||||
/// Exact url-safe unpadded base64 length of a 64-byte Ed25519 signature.
|
||||
/// The tolerant threshold scan requires this canonical unpadded length before
|
||||
/// attempting decode: the length gate is a DoS bound that keeps an
|
||||
/// attacker-sized sig string from costing allocation before the signature is
|
||||
/// rejected. A longer string is not meaningless (the same 64 bytes can appear
|
||||
/// in padded or non-url-safe encodings) but no in-tree signer produces one,
|
||||
/// so the scan skips non-canonical entries rather than decoding them.
|
||||
const ENCODED_SIG_LEN: usize = 86;
|
||||
|
||||
/// Hard ceiling on signature entries a certificate may carry into the
|
||||
/// tolerant threshold scan. A legitimate certificate holds one entry per
|
||||
/// maintainer (a re-sign after key rotation may briefly leave a second), so
|
||||
/// this is far above any real signer set, while turning the scan's worst case
|
||||
/// from "one curve verification per attacker-appended entry" into a small
|
||||
/// constant. Rejection is loud (`Err`), not a silent prefix scan: scanning
|
||||
/// only the first N entries would let junk placed ahead of the real
|
||||
/// signatures starve them out of the window, which is the #349 denial again
|
||||
/// in positional form. An attacker positioned to inflate the list past this
|
||||
/// ceiling can already suppress the certificate outright. The loud
|
||||
/// rejection does deny a legitimately over-long certificate; that is an
|
||||
/// explicit, detectable denial above the cap rather than a silent window
|
||||
/// junk placement could exploit.
|
||||
const MAX_SIGNATURE_ENTRIES: usize = 64;
|
||||
|
||||
/// A signature on a ref-update certificate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CertSignature {
|
||||
@@ -138,9 +162,15 @@ impl RefUpdateCert {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify all signatures on this certificate.
|
||||
/// Verify all signatures on this certificate, failing closed.
|
||||
///
|
||||
/// Returns the list of DIDs whose signatures are valid.
|
||||
/// On success returns the list of DIDs whose signatures are valid. This is
|
||||
/// strict: it returns an error if *any* signature entry is malformed or
|
||||
/// invalid. Because signature entries live outside the signed body (anyone
|
||||
/// can append one without key material), do not use this to make a
|
||||
/// threshold decision on an untrusted certificate — a single appended junk
|
||||
/// entry would reject the whole cert. Use [`Self::satisfies_threshold`],
|
||||
/// which ignores invalid entries and counts only the valid signers.
|
||||
pub fn verify_all(&self) -> Result<Vec<Did>> {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
let signing_bytes = self.body.to_signing_bytes()?;
|
||||
@@ -169,21 +199,65 @@ impl RefUpdateCert {
|
||||
/// from the provided set of authorized maintainer DIDs.
|
||||
///
|
||||
/// Counts distinct signer DIDs, not signature entries: a repeated
|
||||
/// signature from the same maintainer counts once.
|
||||
/// signature from the same maintainer counts once. Malformed and invalid
|
||||
/// signature entries are skipped, not fatal — entries live outside the
|
||||
/// signed body, so any third party can append junk without key material,
|
||||
/// and short-circuiting on the first bad one (as [`Self::verify_all`]
|
||||
/// does) would let that junk deny an otherwise valid certificate (#349).
|
||||
///
|
||||
/// `threshold` must be at least 1. Zero is rejected instead of being
|
||||
/// treated as "no signatures required": a caller that derives the
|
||||
/// threshold from configuration and gets 0 would otherwise accept any
|
||||
/// certificate, whether its signatures verify or not.
|
||||
/// Tolerance must not become an unbounded verification path (#349 review
|
||||
/// round 2): entries are attacker-extensible, so the work done for them is
|
||||
/// bounded BEFORE the loop, not discovered inside it:
|
||||
///
|
||||
/// - a list over [`MAX_SIGNATURE_ENTRIES`] is rejected outright (see the
|
||||
/// constant for why loud rejection beats a silent prefix scan);
|
||||
/// - an encoded signature is length-gated before base64 work — a 64-byte
|
||||
/// Ed25519 signature is exactly [`ENCODED_SIG_LEN`] characters in
|
||||
/// url-safe unpadded base64, so an oversized blob never allocates;
|
||||
/// - entries whose signer is not in `maintainers`, or is already counted,
|
||||
/// are skipped on a string compare, before any decode or curve work —
|
||||
/// so even within the cap, junk naming strangers costs no crypto;
|
||||
/// - the scan returns as soon as the threshold is met.
|
||||
pub fn satisfies_threshold(&self, maintainers: &[Did], threshold: usize) -> Result<bool> {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
if threshold == 0 {
|
||||
return Err(Error::RefCert("threshold must be at least 1".to_string()));
|
||||
}
|
||||
if self.signatures.len() > MAX_SIGNATURE_ENTRIES {
|
||||
return Err(Error::RefCert(format!(
|
||||
"certificate carries {} signature entries (max {})",
|
||||
self.signatures.len(),
|
||||
MAX_SIGNATURE_ENTRIES
|
||||
)));
|
||||
}
|
||||
let signing_bytes = self.body.to_signing_bytes()?;
|
||||
let mut counted: HashSet<&Did> = HashSet::new();
|
||||
|
||||
let valid = self.verify_all()?;
|
||||
let distinct_signers: HashSet<&Did> =
|
||||
valid.iter().filter(|d| maintainers.contains(d)).collect();
|
||||
Ok(distinct_signers.len() >= threshold)
|
||||
for cert_sig in &self.signatures {
|
||||
if counted.contains(&cert_sig.signer) || !maintainers.contains(&cert_sig.signer) {
|
||||
continue;
|
||||
}
|
||||
if cert_sig.sig.len() != ENCODED_SIG_LEN {
|
||||
continue;
|
||||
}
|
||||
let Ok(vk) = cert_sig.signer.to_verifying_key() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(sig_bytes_vec) = URL_SAFE_NO_PAD.decode(&cert_sig.sig) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(sig_bytes) = <[u8; 64]>::try_from(sig_bytes_vec) else {
|
||||
continue;
|
||||
};
|
||||
if verify(&vk, &signing_bytes, &sig_bytes).is_ok() {
|
||||
counted.insert(&cert_sig.signer);
|
||||
if counted.len() >= threshold {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(counted.len() >= threshold)
|
||||
}
|
||||
|
||||
/// Validate the certificate structure (not signatures).
|
||||
@@ -416,6 +490,149 @@ mod tests {
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
/// An attacker can append junk signature entries to a cert (they live
|
||||
/// outside the signed body, so no key material is needed). `satisfies_threshold`
|
||||
/// must ignore them and still count the real signers — before the fix,
|
||||
/// `verify_all`'s `?` on the junk made the whole check error out, denying a
|
||||
/// valid 2-of-2 cert.
|
||||
#[test]
|
||||
fn satisfies_threshold_ignores_appended_junk_signatures() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp1.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
cert.countersign(&kp2).unwrap();
|
||||
|
||||
// Junk sits AHEAD of the real entries so the scan must actually skip
|
||||
// it: appended last, the threshold-2 early return at the second real
|
||||
// signature would fire before any junk is examined and the test would
|
||||
// stay green even if the skip logic never ran.
|
||||
cert.signatures.insert(
|
||||
0,
|
||||
CertSignature {
|
||||
signer: kp1.did(),
|
||||
sig: "!!!not-base64!!!".to_string(),
|
||||
},
|
||||
);
|
||||
// Valid base64 but only 3 bytes — fails the 64-byte length check.
|
||||
cert.signatures.insert(
|
||||
0,
|
||||
CertSignature {
|
||||
signer: kp2.did(),
|
||||
sig: "AAAA".to_string(),
|
||||
},
|
||||
);
|
||||
// Exactly ENCODED_SIG_LEN of valid base64 decoding to garbage —
|
||||
// exercises the decode+verify path before the skip, not just the
|
||||
// length gate.
|
||||
cert.signatures.insert(
|
||||
0,
|
||||
CertSignature {
|
||||
signer: kp1.did(),
|
||||
sig: "A".repeat(ENCODED_SIG_LEN),
|
||||
},
|
||||
);
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
assert!(cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
/// A junk entry that names a real maintainer DID but carries a signature
|
||||
/// that does not verify must not be counted — otherwise appending garbage
|
||||
/// under a maintainer's DID could forge a threshold.
|
||||
#[test]
|
||||
fn satisfies_threshold_does_not_count_a_forged_maintainer_signature() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp1.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
// Well-formed 64-byte signature from kp2, but over unrelated bytes, so
|
||||
// it fails verification against the cert body.
|
||||
cert.signatures.push(CertSignature {
|
||||
signer: kp2.did(),
|
||||
sig: kp2.sign_b64(b"unrelated bytes"),
|
||||
});
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
// Only kp1 actually signed the body: 2-of-2 must fail, 1-of-2 holds.
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
}
|
||||
|
||||
/// The tolerant scan must not be an unbounded verification path: a list
|
||||
/// past MAX_SIGNATURE_ENTRIES is rejected loudly before any curve work,
|
||||
/// while a list at the cap still verifies normally.
|
||||
#[test]
|
||||
fn satisfies_threshold_rejects_an_oversized_signature_list() {
|
||||
let kp = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp,
|
||||
)
|
||||
.unwrap();
|
||||
let junk = || CertSignature {
|
||||
signer: Keypair::generate().did(),
|
||||
sig: "AAAA".to_string(),
|
||||
};
|
||||
while cert.signatures.len() < MAX_SIGNATURE_ENTRIES {
|
||||
cert.signatures.push(junk());
|
||||
}
|
||||
let maintainers = vec![kp.did()];
|
||||
// At the cap: still fine, the real signature counts.
|
||||
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
// One past the cap: loud rejection, no tolerant scan.
|
||||
cert.signatures.push(junk());
|
||||
let err = cert.satisfies_threshold(&maintainers, 1).unwrap_err();
|
||||
assert!(err.to_string().contains("signature entries"));
|
||||
}
|
||||
|
||||
/// An entry whose encoded signature is not the exact base64 length of a
|
||||
/// 64-byte Ed25519 signature is skipped before any base64 decode — an
|
||||
/// attacker-sized sig string must cost neither allocation nor curve work,
|
||||
/// and must not affect the verdict.
|
||||
#[test]
|
||||
fn satisfies_threshold_skips_an_oversized_encoded_signature() {
|
||||
let kp1 = Keypair::generate();
|
||||
let kp2 = Keypair::generate();
|
||||
let mut cert = RefUpdateCert::new(
|
||||
kp1.did(),
|
||||
"refs/heads/main".to_string(),
|
||||
dummy_hash('0'),
|
||||
dummy_hash('a'),
|
||||
1,
|
||||
&kp1,
|
||||
)
|
||||
.unwrap();
|
||||
// Names a real maintainer, valid base64 alphabet, but megabytes long:
|
||||
// the length gate must refuse it without decoding.
|
||||
cert.signatures.push(CertSignature {
|
||||
signer: kp2.did(),
|
||||
sig: "A".repeat(2 * 1024 * 1024),
|
||||
});
|
||||
|
||||
let maintainers = vec![kp1.did(), kp2.did()];
|
||||
assert!(cert.satisfies_threshold(&maintainers, 1).unwrap());
|
||||
assert!(!cert.satisfies_threshold(&maintainers, 2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_zero_is_rejected() {
|
||||
let kp = Keypair::generate();
|
||||
|
||||
@@ -244,6 +244,29 @@ pub struct Config {
|
||||
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
|
||||
pub shutdown_grace_secs: u64,
|
||||
|
||||
/// Maximum number of creation requests (repo create, issue create, PR
|
||||
/// create, register, fork) allowed per DID within the rate-limit window.
|
||||
/// 0 is rejected at parse time: it would silently disable the limiter
|
||||
/// (RateLimiter treats max_requests == 0 as allow-all), not block every
|
||||
/// creation request.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_RATE_LIMIT_MAX",
|
||||
default_value_t = 10,
|
||||
value_parser = clap::value_parser!(u32).range(1..)
|
||||
)]
|
||||
pub rate_limit_max_requests: u32,
|
||||
|
||||
/// Rate-limit window size, in seconds, over which
|
||||
/// `rate_limit_max_requests` applies. 0 is rejected at parse time: every
|
||||
/// timestamp would prune immediately, silently disabling the limiter.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GITLAWB_RATE_LIMIT_WINDOW_SECS",
|
||||
default_value_t = 3600,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
pub rate_limit_window_secs: u64,
|
||||
/// Maximum wall-clock time a single served git operation (upload-pack /
|
||||
/// receive-pack through `run_git_service`) may run before it is aborted and
|
||||
/// its process group torn down, in seconds. Bounds a git that neither
|
||||
|
||||
@@ -356,9 +356,14 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// Per-DID limiter for the creation endpoints. Keyed on the authenticated
|
||||
// DID (attacker-varied), so bound its key set to cap memory.
|
||||
let rate_limiter =
|
||||
rate_limit::RateLimiter::new_bounded(10, std::time::Duration::from_secs(3600), 200_000);
|
||||
// DID (attacker-varied), so bound its key set to cap memory. Max and window
|
||||
// are operator-configurable via GITLAWB_RATE_LIMIT_MAX /
|
||||
// GITLAWB_RATE_LIMIT_WINDOW_SECS; both reject 0 at parse time.
|
||||
let rate_limiter = rate_limit::RateLimiter::new_bounded(
|
||||
config.rate_limit_max_requests as usize,
|
||||
std::time::Duration::from_secs(config.rate_limit_window_secs),
|
||||
200_000,
|
||||
);
|
||||
|
||||
// Per-client-IP flood brake for the creation endpoints. The per-DID limiter
|
||||
// above is bypassed by a DID farm (one throwaway did:key per repo), which is
|
||||
|
||||
@@ -70,6 +70,14 @@ impl RateLimiter {
|
||||
self.window.min(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
/// The configured window, exposed so a 429 can carry an honest
|
||||
/// `retry-after`: a hardcoded value lies when the operator retunes the
|
||||
/// window, and a client that honors the header would retry into another
|
||||
/// 429 until the real window elapses.
|
||||
pub fn window(&self) -> Duration {
|
||||
self.window
|
||||
}
|
||||
|
||||
pub(crate) async fn check(&self, key: &str) -> bool {
|
||||
// max_requests == 0 means the limiter is disabled, not "block all".
|
||||
if self.max_requests == 0 {
|
||||
@@ -293,12 +301,7 @@ pub async fn rate_limit_by_did(request: Request, next: Next) -> Response {
|
||||
|
||||
if let (Some(limiter), Some(did)) = (limiter, did) {
|
||||
if !limiter.check(&did).await {
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
RATE_LIMIT_MESSAGE,
|
||||
)
|
||||
.into_response();
|
||||
return too_many_requests(limiter.window().as_secs());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,10 +412,10 @@ pub const RATE_LIMIT_MESSAGE: &str = "rate limit exceeded — try again later";
|
||||
/// The shared 429 response for the per-IP flood brakes. Route-agnostic: this
|
||||
/// middleware now serves the push path AND the peer-sync routes, so the message
|
||||
/// stays generic (the offending path is recorded in the warn log below).
|
||||
pub fn too_many_requests() -> Response {
|
||||
pub fn too_many_requests(retry_after_secs: u64) -> Response {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[("retry-after", "60")],
|
||||
[("retry-after", retry_after_secs.to_string())],
|
||||
RATE_LIMIT_MESSAGE,
|
||||
)
|
||||
.into_response()
|
||||
@@ -481,7 +484,7 @@ pub async fn rate_limit_by_ip(request: Request, next: Next) -> Response {
|
||||
if let Some(key) = client_key(request.headers(), peer, limiter.trust) {
|
||||
if !limiter.limiter.check(&key).await {
|
||||
tracing::warn!(key = %key, path = %request.uri().path(), "per-IP rate limit exceeded");
|
||||
return too_many_requests();
|
||||
return too_many_requests(limiter.limiter.window().as_secs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ uuid = { workspace = true }
|
||||
url = { workspace = true }
|
||||
percent-encoding = { workspace = true }
|
||||
urlencoding = "2"
|
||||
libc = "0.2"
|
||||
alloy = { version = "1", default-features = false, features = [
|
||||
"contract",
|
||||
"provider-http",
|
||||
|
||||
@@ -152,23 +152,13 @@ async fn cmd_new_with_reader(
|
||||
}
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dir)
|
||||
crate::secret_file::create_dir(&dir)
|
||||
.with_context(|| format!("failed to create directory {}", dir.display()))?;
|
||||
|
||||
let keypair = Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
|
||||
// Write with restricted permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::write(&path, pem.as_bytes())?;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&path, pem.as_bytes())?;
|
||||
}
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
|
||||
let did = keypair.did();
|
||||
println!("✓ Generated new identity");
|
||||
@@ -223,16 +213,7 @@ async fn cmd_backup(out: Option<PathBuf>, dir: Option<PathBuf>) -> Result<()> {
|
||||
.join("identity.pem.bak")
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
}
|
||||
crate::secret_file::write(&dest, pem.as_bytes())?;
|
||||
|
||||
println!("✓ Identity backed up");
|
||||
println!(" DID: {}", keypair.did());
|
||||
@@ -283,19 +264,10 @@ async fn cmd_restore_with_reader(
|
||||
}
|
||||
}
|
||||
|
||||
fs::create_dir_all(&base)
|
||||
crate::secret_file::create_dir(&base)
|
||||
.with_context(|| format!("failed to create directory {}", base.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
fs::set_permissions(&dest, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
fs::write(&dest, pem.as_bytes())?;
|
||||
}
|
||||
crate::secret_file::write(&dest, pem.as_bytes())?;
|
||||
|
||||
println!("✓ Identity restored");
|
||||
println!(" DID: {}", keypair.did());
|
||||
|
||||
+6
-15
@@ -109,16 +109,16 @@ pub async fn run(args: InitArgs) -> Result<()> {
|
||||
.dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".gitlawb"));
|
||||
std::fs::create_dir_all(&ucan_dir)?;
|
||||
crate::secret_file::create_dir(&ucan_dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
std::fs::write(
|
||||
ucan_dir.join("ucan.json"),
|
||||
serde_json::to_string_pretty(&record)?,
|
||||
crate::secret_file::write(
|
||||
&ucan_dir.join("ucan.json"),
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -232,22 +232,13 @@ fn generate_identity(dir: Option<&std::path::Path>) -> Result<gitlawb_core::iden
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
std::fs::create_dir_all(&base)?;
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = base.join("identity.pem");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
}
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
|
||||
Ok(keypair)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ mod protect;
|
||||
mod quickstart;
|
||||
mod register;
|
||||
mod repo;
|
||||
mod secret_file;
|
||||
mod star;
|
||||
mod status;
|
||||
mod sync;
|
||||
|
||||
+20
-18
@@ -100,19 +100,29 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
let payload: Value = resp.json().await.unwrap_or_default();
|
||||
let ucan = payload["ucan"].as_str().unwrap_or("");
|
||||
if !ucan.is_empty() {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
crate::secret_file::create_dir(&dir)?;
|
||||
let record = json!({
|
||||
"ucan": ucan,
|
||||
"node": args.node,
|
||||
"did": did,
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
crate::secret_file::write(
|
||||
&ucan_path,
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
|
||||
println!(" ✓ Registered successfully");
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
} else {
|
||||
// A 2xx without a usable ucan is not a registration; the
|
||||
// response may not even be JSON. Saying success here would
|
||||
// also leave a stale ucan.json from another node in place.
|
||||
println!(" ✗ Registration returned no UCAN (unexpected response body)");
|
||||
// No retry hint here: a `gl register` instruction without
|
||||
// this invocation's --dir would load a different identity.
|
||||
}
|
||||
let trust = payload["trust_score"].as_f64().unwrap_or(0.0);
|
||||
println!(" ✓ Registered successfully");
|
||||
println!(" Trust score: {trust:.2}");
|
||||
println!(" UCAN saved to {}", ucan_path.display());
|
||||
println!();
|
||||
}
|
||||
Ok(resp) => {
|
||||
@@ -226,23 +236,15 @@ pub async fn run(args: QuickstartArgs) -> Result<()> {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn generate_identity(dir: &PathBuf) -> Result<gitlawb_core::identity::Keypair> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
fn generate_identity(dir: &std::path::Path) -> Result<gitlawb_core::identity::Keypair> {
|
||||
crate::secret_file::create_dir(dir)
|
||||
.with_context(|| format!("failed to create {}", dir.display()))?;
|
||||
|
||||
let keypair = gitlawb_core::identity::Keypair::generate();
|
||||
let pem = keypair.to_pem()?;
|
||||
let path = dir.join("identity.pem");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(&path, pem.as_bytes())?;
|
||||
}
|
||||
crate::secret_file::write(&path, pem.as_bytes())?;
|
||||
|
||||
let did = keypair.did();
|
||||
println!(" ✓ Generated new identity");
|
||||
|
||||
@@ -72,7 +72,10 @@ pub async fn run(args: RegisterArgs) -> Result<()> {
|
||||
"did": did.to_string(),
|
||||
"saved_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
std::fs::write(&ucan_path, serde_json::to_string_pretty(&record)?)?;
|
||||
crate::secret_file::write(
|
||||
&ucan_path,
|
||||
serde_json::to_string_pretty(&record)?.as_bytes(),
|
||||
)?;
|
||||
tracing::debug!("saved UCAN to {}", ucan_path.display());
|
||||
}
|
||||
|
||||
@@ -108,7 +111,7 @@ fn ucan_path(dir: Option<&std::path::Path>) -> Result<PathBuf> {
|
||||
.context("could not determine home directory")?
|
||||
.join(".gitlawb")
|
||||
};
|
||||
std::fs::create_dir_all(&base)?;
|
||||
crate::secret_file::create_dir(&base)?;
|
||||
Ok(base.join("ucan.json"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Writes and directories that hold key material or bearer tokens.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// Write `contents` to `path` with owner-only permissions.
|
||||
///
|
||||
/// On unix the mode is pinned at creation, so the file never exists with a
|
||||
/// permissive mode between open and chmod. `O_NOFOLLOW` refuses to write
|
||||
/// through a pre-planted symlink. `mode()` applies only when the file is
|
||||
/// created, so the mode is pinned on the descriptor before the file is
|
||||
/// truncated: a pre-existing loose file is tightened before, not after, the
|
||||
/// new contents land in it.
|
||||
pub(crate) fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)?;
|
||||
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
f.set_len(0)?;
|
||||
f.write_all(contents)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create `path` (and missing parents) as an owner-only directory.
|
||||
///
|
||||
/// An existing directory is also re-pinned: a `~/.gitlawb` created before
|
||||
/// this helper stays group- and world-listable otherwise. A symlinked
|
||||
/// `path` is left alone rather than chmodded through, which would strip
|
||||
/// group and world access from whatever the link points at.
|
||||
pub(crate) fn create_dir(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(path)?;
|
||||
// Re-pin through the opened directory, not the path: O_NOFOLLOW
|
||||
// refuses a swapped-in symlink and fchmod acts on the checked inode,
|
||||
// so a final-component swap between check and chmod cannot retarget
|
||||
// the mode change at whatever the link points at.
|
||||
match std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)
|
||||
{
|
||||
Ok(dir) => {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = dir.as_raw_fd();
|
||||
// Re-pin only a leaf this process owns. A --dir the caller
|
||||
// can write but cannot chmod (foreign-owned 0775 leaf, ACL
|
||||
// grant) is adopted unchanged: create_dir_all allowed the
|
||||
// write before, and aborting turns a usable directory into
|
||||
// a command failure. An owned directory that still refuses
|
||||
// chmod (EPERM) is adopted the same way.
|
||||
let owned = unsafe {
|
||||
let mut st: libc::stat = std::mem::zeroed();
|
||||
libc::fstat(fd, &mut st) == 0 && st.st_uid == libc::geteuid()
|
||||
};
|
||||
if owned && unsafe { libc::fchmod(fd, 0o700) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.raw_os_error() != Some(libc::EPERM) {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ELOOP: the path is a symlink and is left alone per contract.
|
||||
Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::create_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn create_dir_adopts_writable_leaf_it_cannot_chmod() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// /tmp is root-owned 1777: writable by this euid, not chmod-able.
|
||||
// The pre-helper create_dir_all allowed the write; the re-pin must
|
||||
// adopt the leaf unchanged rather than abort on EPERM.
|
||||
let before = std::fs::metadata("/tmp").unwrap().permissions().mode();
|
||||
super::create_dir(std::path::Path::new("/tmp")).unwrap();
|
||||
let after = std::fs::metadata("/tmp").unwrap().permissions().mode();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn write_creates_file_with_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("key.pem");
|
||||
super::write(&path, b"secret").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn write_tightens_preexisting_loose_file() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("key.pem");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
super::write(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn write_refuses_symlink() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("target.pem");
|
||||
std::fs::write(&target, b"planted").unwrap();
|
||||
let link = dir.path().join("link.pem");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
assert!(super::write(&link, b"secret").is_err());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"planted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn create_dir_modes_0700_and_tightens_existing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a").join("b");
|
||||
super::create_dir(&path).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
super::create_dir(&path).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn create_dir_does_not_chmod_through_symlink() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("shared");
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
super::create_dir(&link).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
|
||||
0o755
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ async fn cmd_delegate(
|
||||
let encoded = ucan.encode()?;
|
||||
|
||||
if let Some(path) = out {
|
||||
std::fs::write(&path, &encoded)?;
|
||||
crate::secret_file::write(&path, encoded.as_bytes())?;
|
||||
println!("UCAN saved to {}", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user