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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ hand.
**What if I lose my key?**
If no remaining device has access, the data is gone. There is no recovery
service by design. Keep an offline backup of at least one administrator key and
its `admin-state.json` pin (under `~/.local/state/git-e2ee/<repository-root>/`).
its `continuity.json` floor (under `~/.local/state/git-e2ee/<repository-root>/`).

**Does revoking a device hide old history from it?**
No. It keeps whatever it could already decrypt. It gets no keys for anything
Expand Down
7 changes: 5 additions & 2 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,11 @@ quadratic in aggregate metadata: each immutable policy generation repeats a
larger device list and each manifest repeats a larger envelope list. This does
not multiply content packs.

Push timing also depends on the client path. The direct diagnostic
`git-e2ee push` has no clone-local continuity pin, so it deliberately validates
Push timing also depends on the client path. (The direct diagnostic
`git-e2ee push`/`fetch` mode measured here no longer exists: every push and
fetch now goes through the continuity floor, and the benchmark scripts use the
native helper only. The numbers below are kept as recorded.) The direct
`git-e2ee push` had no clone-local continuity pin, so it deliberately validated
the complete 100-generation chain on every push. It took 3.12--3.54 seconds in
this run. Normal Git usage goes through `git-remote-e2ee`; set
`BENCH_E2EE_PUSH_MODE=native` to benchmark that path. Its first push after 99
Expand Down
67 changes: 34 additions & 33 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,22 @@ administrative CAS is never automatically rebased.
- Packs are decrypted segment by segment directly into `git index-pack`
oldest-first.
- After import, every advertised ref must resolve to a complete local Git
object graph before the continuity floor or remote-tracking refs move.
- Authentication alone never advances the continuity floor. The floor advances
after a successful import and connectivity check, after the client's own
publication wins compare-and-swap, or during listing when every advertised
ref already resolves to a complete local object graph. The last case matters
because Git skips the helper's fetch command when nothing needs to be
transferred, as after a membership change, a deletion, or a tag pointing at
an existing object; without it, such a state would never be pinned and a
later replay of the previous state would be accepted.
- Per-repository/remote client-state updates hold an advisory file lock across
validation and read-modify-write. The state replacement remains durable and
atomic, and its manifest and policy generations never move backward.
object graph before the checkout's import progress or remote-tracking refs
move.
- One continuity floor per repository root per machine,
`$XDG_STATE_HOME/git-e2ee/<root>/continuity.json`, is the only continuity
authority. Listing, fetch, push and dry-run push, `init`, `device-*`,
`verify`, and `recover` take its exclusive lock, require the authenticated
chain to pass through it, and advance it after success, so concurrent
processes on one machine serialize. Listing advances it on authentication
and continuity alone; Git omits the fetch command when nothing needs to be
transferred (after a membership change, a deletion, or a tag pointing at an
existing object), so a floor that waited for fetch would never pin such a
state.
- Each checkout's import progress (`.git/git-remote-e2ee/<root>/progress.json`)
records the imported generation, imported pack IDs, and verified ref
frontier. It is a cache under its own lock, taken after the floor's, and is
never consulted for continuity.
- The helper advertises `refs/heads/*` and `refs/tags/*`; every other ref
namespace is rejected before publication.
- Branch updates require fast-forward ancestry unless force is explicit. Tags
Expand Down Expand Up @@ -256,25 +260,22 @@ motivates future garbage collection.

## Continuity and limits

Returning clients pin repository root, manifest ID/generation, and policy
generation. They reject rollback, non-descendant state, policy rollback, and
root substitution. A signed manifest seen by list is not yet a pin: helper
state advances after all required packs are imported and every advertised ref
passes the complete-object-graph check, or after the client's own publication
successfully wins compare-and-swap. A missing or unusable pack therefore cannot
raise the continuity floor.

Each local repository/remote pair has one stable lock file beside its client
state. Helpers hold the exclusive lock while checking the current floor and
performing an import or publication update. State writes merge monotonically
and use the existing flushed temporary-file, rename, and parent-directory
sync path. Administrator pins use the same serialized, monotonic state update
rules after initialization or a successful policy transition.

State files written by earlier builds are not lowered automatically. Their
head pin may have come from listing, but it is indistinguishable from a valid
pin made after the client's own publication; lowering it could weaken rollback
detection.
The machine floor pins repository root, manifest ID/generation, and policy
generation. It rejects rollback, non-descendant state, policy rollback, and
root substitution. The library takes the state directory as an argument
(`EncryptedRepository::new(storage, key, state_dir)`); only the binaries read
`XDG_STATE_HOME`. There is no code path that publishes or fetches without the
floor.

A single machine-wide floor closes a gap that per-checkout pins left open: a
revocation seen only by `device-revoke` used to protect administrative
commands but not a checkout that had not fetched it, so a replayed
pre-revocation state could receive a new push readable by the revoked device.

The floor file is replaced with the flushed temporary-file, rename, and
parent-directory sync path. Its stable `continuity.json.lock` sibling is held
from validation through advancement. Floor writes are monotonic: a lower
generation is ignored and a different head at the same generation is a fork.

Storage alone still cannot prevent:

Expand Down Expand Up @@ -389,8 +390,8 @@ require the current tip to be a single-parent child of it.

`git-e2ee recover` takes a target (a Git remote name or a storage URL),
plus `--key` and `--repo`. The repository defaults to `.`; the continuity floor
is read from `.git/git-remote-e2ee/<repository-root>/state.json`, where the root
comes from the key file. Without `--publish`, the command
is this machine's `$XDG_STATE_HOME/git-e2ee/<repository-root>/continuity.json`,
where the root comes from the key file. Without `--publish`, the command
only reports. Publishing an older offer requires `--discard-newer`. Directory
publication requires `--accept-stale-floor` and prints the warning above.

Expand Down
12 changes: 6 additions & 6 deletions docs/durability.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Windows and virtual-power-cut results below were verified on 2026-09-23.

## Publication sequence

Client continuity state (`state.json`), filesystem `HEAD`, and immutable
The continuity floor (`continuity.json`), filesystem `HEAD`, and immutable
objects use the same flush rules:

1. Create any missing directory that will hold the published name, then flush
Expand All @@ -19,7 +19,7 @@ objects use the same flush rules:
2. Write a temporary file and `File::sync_all` it. Return the error if that
fails.
3. Publish the name.
- `state.json` and `HEAD` use `rename` in the destination directory and
- `continuity.json` and `HEAD` use `rename` in the destination directory and
replace an existing file.
- Immutable objects use `hard_link` from `.staging/<temp>` to
`packs/<prefix>/<id>`. Those are different directories on the same
Expand All @@ -33,9 +33,9 @@ objects use the same flush rules:
`FilesystemStorage::initialize` creates `packs` and `manifests`
with the same directory helper, so the storage root (the preexisting ancestor)
is flushed when those directories are new. A later object shard flushes the new
`<prefix>` directory and `packs`, not the storage root again. Client state
creates `.git/git-remote-e2ee/<remote>` the same way and stops at `.git` when
that directory already exists.
`<prefix>` directory and `packs`, not the storage root again. The floor
creates `$XDG_STATE_HOME/git-e2ee/<repository-root>` the same way and stops at
the deepest directory that already exists.

The carrier-Git backend does not use this helper. Its compare-and-swap is the
fast-forward push of the carrier branch (`refs/heads/main` by default).
Expand Down Expand Up @@ -199,7 +199,7 @@ above, and panics otherwise.

Ops are isolated inside the fixture:

- `client_state` → `<fixture>\client\state.json` via `persist_client_state`
- `client_state` → `<fixture>\client\durability-fixture\continuity.json` via the continuity-floor writer
- `head` → `<fixture>\head\HEAD` via `FilesystemStorage::compare_and_swap_head`
- `object` → `<fixture>\object-store\objects\<prefix>\<id>` via `put_object_if_absent`

Expand Down
40 changes: 21 additions & 19 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,22 @@ repository is its administrator. The key path is stored only in local Git
configuration. The key file is never written to the encrypted remote and must
never be committed.

The CLI keeps an administrative continuity pin per repository root at
`$XDG_STATE_HOME/git-e2ee/<repository-root>/admin-state.json` (by default under
`~/.local/state`). `device-add`, `device-revoke`, and `device-list` all check
the storage head against this pin and then advance it, so storage cannot show
an older membership list, for example one that hides a revocation, once you
have seen a newer one. Moving or renaming the key file keeps the pin. Back up
that file together with the key.

Inside a Git repository, the continuity pin is likewise kept per repository
root at `.git/git-remote-e2ee/<repository-root>/state.json`, not per Git remote
name. Renaming a remote or adding a second remote for the same encrypted
repository keeps rollback protection. Every remote for one repository root
shares one floor, so a mirror that lags behind the newest state you have seen
is reported as rolled back.
Each machine keeps one continuity floor per repository root at
`$XDG_STATE_HOME/git-e2ee/<repository-root>/continuity.json` (by default under
`~/.local/state`). `git fetch`, `git push`, `init`, `device-add`,
`device-revoke`, `device-list`, `verify`, and `recover` all check the storage
head against this floor and then advance it, so once anything on this machine
has seen a state, storage cannot show an older one. For example, after
`device-revoke` no checkout on this machine will push onto a replayed
pre-revocation state, even one that never fetched the revocation. Moving or
renaming the key file, renaming a Git remote, or adding a second remote or
clone for the same repository keeps the floor. Every remote for one
repository root shares it, so a mirror that lags behind the newest state you
have seen is reported as rolled back. Back up that file together with the key.

Each clone also records its own import progress under
`.git/git-remote-e2ee/<repository-root>/`. That is a cache of work already
done; deleting it only causes a full re-import.

## Git host as storage (carrier-Git backend)

Expand Down Expand Up @@ -237,11 +239,11 @@ cd /work/my-clone
git-e2ee recover origin
```

The local floor is read from
`<repo>/.git/git-remote-e2ee/<repository-root>/state.json`; use a repository
that previously fetched or pushed. The remote name does not matter. A fresh clone has no floor,
so recovery reports that freshness and fork identity are unverified and will
not publish.
The local floor is this machine's
`$XDG_STATE_HOME/git-e2ee/<repository-root>/continuity.json`, so run recovery
on a machine that previously used the repository. A machine that never did has
no floor, so recovery reports that freshness and fork identity are unverified
and will not publish.

Review the offered manifest IDs, generations, verified signers, replays, and
any fork report. Recovery is allowed only when the current head is demonstrably
Expand Down
58 changes: 30 additions & 28 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,23 +432,26 @@ refs, pinning the new head, or publishing a successor. A signed ref advance
whose required pack delta is absent is invalid even when every cryptographic
check succeeds.

Authenticating a manifest alone MUST NOT advance the continuity floor. A
helper client's head, generation, repository root, and policy-generation floor
advances only when the complete-ref connectivity check succeeds for that
manifest's refs, either after pack import or, during listing, because every
advertised ref is already connected locally; or after that client successfully
publishes its own successor with compare-and-swap. A client MUST NOT rely on
receiving a fetch command to pin a state, because Git omits it when no objects
need to be transferred. A failed or incomplete
import leaves the previous floor intact, so an authenticated but unusable
publication cannot make a later valid successor look like rollback.

Client-state updates for one repository and remote MUST serialize their
read-modify-write operation across helper processes. The merge MUST preserve
the greatest recorded manifest and policy generations and MUST reject a
same-generation head-ID conflict. The durable state-file replacement remains
atomic; the lock is held while an import or publication checks and advances
the floor.
Each machine (per OS user) keeps one continuity floor per repository root:
repository root, manifest ID and generation, and policy generation. Every
client operation that reads or publishes storage state (listing, fetch, push
and its dry run, initialization, membership listing and changes,
verification, and recovery) MUST hold that floor's exclusive lock, require the
authenticated storage chain to pass through the floor, and, after it succeeds,
advance the floor to the head it accepted or published. The floor never moves
backward; a different head at the floor's generation is a fork. Because every
operation shares the floor, a rollback that hides a state seen by any
operation on the machine, including a revocation published by
`device-revoke`, is rejected by every later operation, whichever checkout runs
it. Holding the lock across validation and publication serializes processes
on one machine.

The floor records what storage has shown, not what a checkout has imported.
Import progress (imported generation, imported pack IDs, and the verified
frontier below) is kept per checkout and never substitutes for the floor. A
signed head whose packs are missing still advances the floor once it
authenticates and descends from it; import progress stays where it was until
a fetch succeeds.

A client MAY satisfy that requirement incrementally. After a successful full
or incremental connectivity check, it records the exact checked ref tips as a
Expand All @@ -464,10 +467,10 @@ Imported pack IDs do not prove that their objects remain in the local object
database; deleting a remote-tracking ref and pruning removes them. When the
incremental check fails, the client MUST re-import every pack of the
authenticated chain and run a full connectivity walk before failing. The
re-import does not move the floor; only a successful full check advances it.
recorded import progress moves only after a successful check.

A missing frontier in a v5 client state requires a full connectivity walk. A
client state from another wire version is not accepted. The incremental rule assumes the trusted
A missing frontier requires a full connectivity walk. Local state in an
unknown format is not accepted. The incremental rule assumes the trusted
client's existing object database has not been corrupted outside this
protocol; explicit full verification remains available to check local storage.

Expand Down Expand Up @@ -561,14 +564,13 @@ Unreachable losing objects require later GC.

## 11. Continuity and invitations

Returning clients pin repository root, manifest ID and generation, and policy
generation. The policy ID is not stored separately: the pinned manifest ID
binds it through the manifest's sealed header. Helper continuity pins advance
only after the complete-ref connectivity check succeeds (after import, or
during listing when the advertised refs are already connected locally) or
after a successful own publication, never from authentication alone. They reject rollback, a non-descendant manifest, policy
rollback/sideways movement, root substitution, and generation gaps. Local
client-state updates are serialized and monotonic across concurrent helpers.
Each machine pins, per repository root, the root, manifest ID and generation,
and policy generation (section 9). The policy ID is not stored separately:
the pinned manifest ID binds it through the manifest's sealed header. The
floor advances after any successful validation or publication on the machine.
It rejects rollback, a non-descendant manifest, policy rollback/sideways
movement, root substitution, and generation gaps. Floor updates are serialized
and monotonic across concurrent processes.

A fresh device should receive an administrator-authorized invitation checkpoint
through an authenticated channel containing:
Expand Down
Loading
Loading