Skip to content
Closed
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
20 changes: 17 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,23 @@ reader count, and total growth. Public keys, device IDs, roles, signer identity,
and membership changes are encrypted; object-size patterns can still suggest
that a membership transition occurred. Inner Git metadata remains encrypted.

The current carrier implementation clones a temporary checkout for each helper
process. Uploads are incremental, but a persistent partial-clone cache is
needed before multi-gigabyte repositories are practical.
Each helper process fetches the carrier branch into a persistent bare object
cache, then creates a disposable checkout that borrows the cache's objects.
Fetches transfer only objects missing from the cache. The cache is keyed by the
SHA-256 of a normalized remote URL; a file lock serializes updates across helper
processes, and automatic garbage collection is disabled so borrowed objects
remain available. The carrier branch is fetched again immediately before each
CAS, and the CAS remains a normal fast-forward push. A stale cache therefore
cannot authorize a write against an outdated branch tip.

Recovery publishes a fresh cache generation instead of replacing objects that
an in-flight temporary checkout may still borrow. Obsolete generations are
retained so those checkouts remain valid; repeated cache corruption can therefore
consume additional local disk space.

The cache contains carrier Git objects and metadata only. It never stores keys,
plaintext, or decrypted repository data. Its default location and the
`GIT_REMOTE_E2EE_CACHE_DIR` override are documented in the [user guide](guide.md#git-host-as-storage-carrier-git-backend).

## Concurrency

Expand Down
5 changes: 2 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,5 @@ Planned extensions:
1. checkpoint/compaction and garbage collection with explicit authority;
2. M-of-N administrator authorization and recovery;
3. automatic stale-push retry with explicit merge behavior;
4. persistent partial-clone carrier cache;
5. S3 backend with conditional-write compatibility tests;
6. optional gossip or transparency-log anchoring.
4. S3 backend with conditional-write compatibility tests;
5. optional gossip or transparency-log anchoring.
49 changes: 36 additions & 13 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::path::PathBuf;

use anyhow::Result;
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};

use git_remote_e2ee::crypto::{KeyFile, PublicDevice};
use git_remote_e2ee::policy::DeviceRoles;
Expand All @@ -15,6 +15,23 @@ struct Cli {
command: Command,
}

#[derive(Clone, Copy, Debug, ValueEnum)]
enum DeviceRole {
Read,
Write,
Admin,
}

impl From<DeviceRole> for DeviceRoles {
fn from(role: DeviceRole) -> Self {
match role {
DeviceRole::Read => Self::reader(),
DeviceRole::Write => Self::collaborator(),
DeviceRole::Admin => Self::owner(),
}
}
}

#[derive(Subcommand)]
enum Command {
Keygen {
Expand Down Expand Up @@ -78,8 +95,8 @@ enum Command {
key: PathBuf,
#[arg(long)]
device: PathBuf,
#[arg(long)]
admin: bool,
#[arg(long, value_enum, default_value = "write")]
role: DeviceRole,
},
DeviceRevoke {
#[arg(long, conflicts_with = "remote", required_unless_present = "remote")]
Expand Down Expand Up @@ -178,16 +195,12 @@ fn main() -> Result<()> {
remote,
key,
device,
admin,
role,
} => {
let pin = admin_pin_path(&key);
let public = PublicDevice::read(&device)?;
let device_id = public.device_id.clone();
let roles = if admin {
DeviceRoles::owner()
} else {
DeviceRoles::collaborator()
};
let roles = DeviceRoles::from(role);
let (manifest, policy) = match (storage, remote) {
(Some(storage), None) => {
EncryptedRepository::new(FilesystemStorage::new(storage), KeyFile::read(&key)?)
Expand Down Expand Up @@ -239,11 +252,9 @@ fn main() -> Result<()> {
};
for device in devices {
println!(
"{} reader={} writer={} admin={} status={}",
"{} role={} status={}",
device.public.device_id,
device.roles.reader,
device.roles.writer,
device.roles.administrator,
role_name(&device.roles),
if device.active() { "active" } else { "revoked" }
);
}
Expand All @@ -252,6 +263,18 @@ fn main() -> Result<()> {
Ok(())
}

fn role_name(roles: &DeviceRoles) -> &'static str {
if roles.administrator {
"admin"
} else if roles.writer {
"write"
} else if roles.reader {
"read"
} else {
"none"
}
}

fn admin_pin_path(key: &std::path::Path) -> PathBuf {
let mut value = key.as_os_str().to_owned();
value.push(".admin-state.json");
Expand Down
24 changes: 24 additions & 0 deletions src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,30 @@ mod tests {
assert!(error.contains("does not match its sealed audit entry"));
}

#[test]
fn reader_only_device_cannot_sign_an_ordinary_manifest() {
let owner = KeyFile::generate();
let reader = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
let (genesis, _) = PolicyState::genesis(&owner).unwrap();
let mut devices = genesis.body.devices.clone();
devices.push(DeviceRecord {
public: reader.public_device().unwrap(),
roles: DeviceRoles::reader(),
revoked_at: None,
});
let (policy, _) = PolicyState::successor(&genesis, devices, &owner).unwrap();
let generation_key = random_key();

// seal_manifest can construct a cryptographically valid signature from
// any key. Verification must still enforce the signer's policy role.
let encrypted = writer_manifest(&reader, &policy, &generation_key);
let opened = open_manifest(&encrypted, &generation_key).unwrap();
let error = verify_manifest(&opened, &policy, None, &generation_key)
.unwrap_err()
.to_string();
assert!(error.contains("manifest was not signed by an active writer"));
}

#[test]
fn reader_trials_all_envelopes_and_reports_inactive_membership_clearly() {
let owner = KeyFile::generate();
Expand Down
40 changes: 40 additions & 0 deletions src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ pub struct DeviceRoles {
}

impl DeviceRoles {
pub fn reader() -> Self {
Self {
reader: true,
writer: false,
administrator: false,
}
}

pub fn owner() -> Self {
Self {
reader: true,
Expand Down Expand Up @@ -265,6 +273,11 @@ impl PolicyState {
.is_some_and(|device| device.active() && device.roles.writer)
}

pub fn is_active_reader(&self, device_id: &str) -> bool {
self.device(device_id)
.is_some_and(|device| device.active() && device.roles.reader)
}

pub fn is_active_admin(&self, device_id: &str) -> bool {
self.device(device_id)
.is_some_and(|device| device.active() && device.roles.administrator)
Expand Down Expand Up @@ -342,6 +355,33 @@ mod tests {
assert!(unauthorized.validate_successor(&parent).is_err());
}

#[test]
fn writers_and_administrators_must_also_be_readers() {
let owner = KeyFile::generate();
let (parent, _) = PolicyState::genesis(&owner).unwrap();
for roles in [
DeviceRoles {
reader: false,
writer: true,
administrator: false,
},
DeviceRoles {
reader: false,
writer: false,
administrator: true,
},
] {
let added = KeyFile::generate_for_repository(owner.repository_root.clone()).unwrap();
let mut devices = parent.body.devices.clone();
devices.push(DeviceRecord {
public: added.public_device().unwrap(),
roles,
revoked_at: None,
});
assert!(PolicyState::successor(&parent, devices, &owner).is_err());
}
}

#[test]
fn genesis_cannot_be_substituted_under_a_known_repository_root() {
let expected = KeyFile::generate();
Expand Down
11 changes: 10 additions & 1 deletion src/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,16 @@ impl<S: Storage> EncryptedRepository<S> {
git::ensure_repository(repo)?;
let chain = self.current_chain()?;
let current = chain.first().context("manifest chain is empty")?.clone();
if !current.policy.is_active_writer(&self.key.device_id()?) {
let device_id = self.key.device_id()?;
if !current.policy.is_active_writer(&device_id) {
if current.policy.is_active_reader(&device_id) {
if let Some(remote_name) = remote_name {
bail!(
"this device is read-only for {remote_name}; ask an administrator for the write role"
)
}
bail!("this device is read-only; ask an administrator for the write role")
}
bail!("device is not an active writer")
}
if let Some(remote_name) = remote_name {
Expand Down
Loading
Loading