Compare commits

..
Author SHA1 Message Date
beardthelion d30136efdd Merge remote-tracking branch 'origin/main' into pr-148 2026-11-09 16:15:10 -06:00
beardthelion a00d7d5c48 fix(gl): pin secret-file modes at creation instead of chmod after (#354) (#557)
* fix(gl): pin secret-file modes at creation instead of chmod after (#354)

identity.pem and ucan.json were written with fs::write and then chmod'd,
leaving the private key world-readable between the two syscalls, and
ucan.json was never chmod'd at all. A shared helper now opens with
mode(0o600) so no permissive window exists, adds O_NOFOLLOW so a
pre-planted symlink is refused rather than written through, and re-pins
the mode after write so a pre-existing loose file is tightened. The
containing directories are created 0700 via DirBuilder and re-pinned the
same way. gitlawb-node's own key write gets the same treatment.

* fix(gl): tighten secret handling before, not after, replacing contents (#459)

Address PR review feedback:

- secret_file::write and the node key write now open without truncate,
  pin mode 0600 on the descriptor, then set_len(0) and write, so a
  pre-existing permissive file never holds new secret contents while
  still group/world-readable.
- Directory re-pins skip symlinked paths instead of chmodding through
  them to the link target.
- The node's existing-key read opens with O_NOFOLLOW and reads from the
  descriptor instead of read_to_string on the path.
- An existing permissive key parent dir is re-pinned to 0700.

* fix(gl): adopt writable --dir leaves the user cannot chmod (#557)

create_dir re-pinned every existing final directory to 0700. On a leaf the
caller can write but does not own (group 0775, ACL grant, a system dir like
/tmp), fchmod fails EPERM and the whole command aborted, a regression over
create_dir_all's no-op. The re-pin now applies only when the euid owns the
opened directory; residual EPERM on an owned leaf adopts rather than aborts.
Regression test proves /tmp is adopted with its mode unchanged.
2026-11-09 20:13:51 +00:00
4cd0a6a306 fix(node)!: gate agent-task reads behind visibility rules (#553)
* fix(node): gate agent-task reads behind the same visibility rules as repo data

list_tasks and get_task had no authorization at all: any anonymous caller
could enumerate every task on the node, including another party's
repo-less task, its ucan_token, and its payload (#268). Add task_visible,
mirroring the repo read-visibility gate already used by the ref-updates
feed: the delegator and assignee can always read their own task, a
repo-scoped task follows that repo's normal visibility rules, and a task
naming no repo (or a repo this node doesn't host) is visible only to its
delegator/assignee. Both REST and GraphQL now route through the same
collect_visible_tasks/get_visible_task collectors so the two surfaces
cannot drift, and neither read path echoes ucan_token back, since the
holder already received it via the create/claim response.

Fixes #268

* fix(node): query the task limit-clamp test as the delegator

tasks_limit_ceiling_clamped_to_200 seeded 201 repo-less tasks and read
them back anonymously, expecting all 200. That read is exactly the
enumeration #268 closes, so the new visibility gate correctly returns
none of them and the test went red. The clamp ceiling is what this test
pins, not the gate, so query as the tasks' delegator, who can legitimately
see all 201 rows.

Refs #268

* fix(node): scope task visibility lookups to the page's repos

collect_visible_tasks loaded every repo on the node and every visibility
rule in order to gate at most 200 tasks, so an anonymous request paid for
the whole node's repo and rule set. Narrow both lookups to the repo ids the
fetched page actually names, and skip them when no task names a repo.

The deduped repo snapshot stays the source of truth for resolving a
repo_id: it collapses mirror and canonical pairs and omits quarantined
repos, and an id missing from it has to keep failing closed. Resolving ids
straight from the repos table would surface exactly those withheld rows.

Add GraphQL denial tests as well. Nothing pinned that the task resolvers
delegate to the shared collectors, so a resolver that queried the database
directly would not have gone red.

Refs #268

* Preserve authorized task reads across shipped clients.

Refs #268

* Bound denied task history scans.

Refs #268

* Signal incomplete task scans with recoverable cursors and map read errors to AppError.

Refs #268

* Stop disclosing denied task rows in the scan-wall cursor and add GraphQL pagination state.

Refs #268

* Normalize task cursors and gate mutation endpoints behind visibility.

Canonicalize RFC 3339 timestamps in parse_after_cursor to handle URL-decoded spaces, reject mixed cursor alias families, gate complete_task and fail_task behind get_visible_task so unreadable tasks 404 instead of leaking existence with 403, and only flag incomplete when hitting candidate ceilings on full SQL batches.

Refs #268

* Update complete_task test in test_support to exercise both 404 on unreadable and 403 on non-assignee tasks.

Refs #268

* Bind task to repo id in complete_task test in test_support.

Refs #268

* Preserve timestamp fractional width and anchor continuation tests on legitimate cursors.

Refs #268

* Match on Value::Object to extract task ID in GraphQL continuation test.

Refs #268

* Remove unused mut binding on visible_newer in GraphQL query test.

Refs #268

* Hide unauthorized claim and keep assigned tasks from being stolen

Gate REST and GraphQL claim behind the same visibility check as complete and fail, refuse claim when another assignee already holds the task, and only broadcast publicly visible task events. Treat a full list page as incomplete when more candidates remain. Surface HTTP errors from CLI and MCP claim and complete helpers.

Refs #327

Co-Authored-By: cairn-code <[email protected]>

* Keep task-list incomplete for the scan ceiling only.

A full visible page was flagged incomplete whenever the SQL batch was full, so the first page of any list with more than 200 candidates looked stalled. Align the GraphQL claim test with the visibility gate's not-found message.

Refs #268

* fix(node): pin claim guards and map mutation errors through AppError

Review required tests that go red if the pre-assigned claim predicate or
the anonymous announce gate is deleted, and incomplete must not stay
true when the candidate stream is exhausted at the scan ceiling. Route
claim, complete, and fail through AppError so closed-pool outages stay
503 and 404s match the read envelope.

Refs #268

* fix(node): match bare and did:key assignee forms in claim and list

create_task stores the supplied assignee unchanged, so a raw SQL
equality check drops a designated assignee who presents the other
did:key form. Compare the normalized key so claim and filtered list
agree with did_matches.

Refs #268

* fix(node): check task creation status and add assignee expression index

- Add error_for_status() to cmd_create and task_create MCP tool
- Update test_create_task_server_error to assert failure on 500
- Add migration v18 creating expression index idx_agent_tasks_assignee_key matching ASSIGNEE_DID_CASE_SQL
- Add did:web:z6Mkfoo single-residual shape to parity boundary matrix

Refs #327

# Conflicts:
#	crates/gitlawb-node/src/db/mod.rs

* fix(node)!: Replace task cursors with opaque continuation tokens

The task read path treated visibility, pagination, and error vocabulary as
separate edits, so each one broke where they met. Rework them as one contract.

A raw (created_at, id) cursor forced a choice between two broken options: it
could name the last visible row, and then a denied window longer than the
1,000-candidate scan budget was unpageable forever; or it could name the last
examined row, and then a denied read leaked the id and timestamp of a task
GET /tasks/{id} otherwise 404s. Continuation tokens remove the choice. They
carry the last examined candidate, so paging always advances a full scan budget
per request, and they are encrypted and authenticated under a node-derived key,
so the caller learns nothing from one and cannot forge one naming a row of
their choosing. Encryption is a synthetic-IV construction over the hmac/sha2
pair already used for webhook signatures, so it adds no dependency and needs no
randomness source.

Making the token the only accepted cursor also gives the ordering key one
domain. agent_tasks.created_at is TEXT and compared as TEXT, so a caller-typed
'...Z' and '...+00:00' denote one instant but sort differently, and a client
could silently skip or repeat same-time rows. The token carries the stored
string verbatim, so the value compared is always one the server wrote. The raw
after_*/cursor_* pairs are removed rather than kept alongside it, since a second
domain is the bug.

Separate the two facts the old single incomplete flag conflated: has_more says
candidates remain, incomplete says this page is short only because the
authorization scan hit its ceiling. Both REST and GraphQL now return has_more,
incomplete, and next_cursor from the shared collector, and REST echoes the limit
it actually applied so a clamped request is visible as clamped.

Have gl task list and MCP task_list follow next_cursor instead of issuing one
request: --limit 500 returned a successful but silently truncated 200 rows.
Following is bounded by a page cap and a no-progress guard, and a run stopped by
either reports an explicit incomplete result with a resume cursor.

Route claimTask, completeTask, and failTask through the same task_write_conflict
classifier the REST handlers use, via curated helpers in the graphql module so
the map_err source guard still holds. A claim race or stale finish reached
GraphQL clients as a generic database error while REST clients got an actionable
conflict; genuine sqlx faults stay opaque on both.

Refs #327

* fix(node): End the candidate stream only after the batch is examined

A short SQL batch means no rows exist past it, not that every row in it was
examined. When the page filled mid-batch the collector treated the two as the
same, marked the stream ended, and suppressed the continuation, so every row
after the one that filled the page was unreachable. The equal-timestamp paging
tests caught it: three rows with a limit of one returned only the first.

Track how much of each batch was consumed and end the stream only when the
whole of a short batch has been examined. Otherwise leave `has_more` to the
probe row, which resumes from the last examined candidate.

Refs #327

* fix(gl): Reject a non-positive task-list limit and fault the write under test

A `--limit 0` reached the node, which clamped it to zero and answered with
an empty page marked complete, so an invalid request read as proof that no
tasks exist. Reject a non-positive limit in `fetch_tasks()`, the helper the
CLI and MCP share, so the guard cannot drift between the two surfaces.

`task_write_sql_faults_stay_opaque` did not exercise what it named. Dropping
`updated_at` also broke the SELECT in `get_task()`, so the fault surfaced
from the `get_visible_task()` pre-check through `graphql_app_err` and never
reached `graphql_claim_conflict`. A `BEFORE UPDATE` trigger keeps every read
valid and faults only inside `Db::claim_task`, and the test now also asserts
that a write-time fault is not reclassified as a claim race.

Refs #268

* fix(node): Bind task cursors to the caller and brake the task read routes

A continuation token names the last candidate a scan examined, not the last
row it returned, so it encodes how far that scan got under one caller's
visibility. The MAC bound the page filter but not the presenting identity,
so resuming a token as a different caller started the scan past rows that
caller was entitled to read and dropped them from the answer with nothing
to signal the loss. Bind the caller's normalized DID into the MAC, with
anonymous flagged absent rather than encoded as empty. Normalization goes
through normalize_owner_key so the two spellings of one did:key identity
bind identically, matching did_matches on the read path: a caller who
presents the other form of their own DID keeps their own page. A mismatched
token renders the existing single rejection message, so this adds no oracle.

GET /api/v1/tasks and GET /api/v1/tasks/{id} are anonymously reachable, and
the visibility gate costs a task lookup plus deduped-repo and
visibility-rule queries before it can return the opaque 404. An
unauthenticated prober therefore pays nothing while the node pays per
request, whether or not the id exists. Attach the per-IP limiter already
used on /ipfs/{cid}, configurable through GITLAWB_TASK_READ_RATE_LIMIT and
swept by the periodic task like every other per-key limiter.

Refs #268

* fix(node): Brake anonymous GraphQL task reads on the same per-IP bucket

The per-IP brake added for the task read routes covered only
/api/v1/tasks*, so an anonymous caller reached the same
collect_visible_tasks and get_visible_task gate over /graphql with no
bucket at all. The fence had an open lane beside it.

Carry the brake as GraphQL request data and debit it in the tasks and
task resolvers rather than layering rate_limit_by_ip onto the GraphQL
router: /graphql is one endpoint for every operation, so a router layer
would charge unrelated queries and every mutation against the task-read
bucket. Debiting per resolved field also prices an aliased query
honestly, since ten aliased tasks fields run the gate ten times.

Extract RATE_LIMIT_MESSAGE so the GraphQL surface, which cannot return a
429 status inside a 200 envelope, refuses with the same text the REST
routes use.

/graphql/ws serves the query root as well and stays unbraked; closing it
needs a WebSocketUpgrade handler and is left for a follow-up.

Refs #268
Refs #327

* fix(gl,node): parse typed task pages, track cursor cycles, and normalize assignee filter MAC

* fix(node): Cap aliased GraphQL task reads per request and derive has_more from visible rows

- Cap aliased GraphQL task read fields per request using an atomic counter
  on TaskReadBrake (MAX_GRAPHQL_TASK_READS_PER_REQUEST = 5).
- Derive has_more in collect_visible_tasks by scanning for bounded_limit + 1
  visible rows, eliminating the un-gated keyset probe that could leak the
  presence of trailing denied tasks.
- Add regression tests covering aliased GraphQL capping and trailing denied
  task has_more privacy.

Refs #327

* fix(node): Check database continuation when scan budget ends on exact batch boundary

When candidate scanning reaches MAX_TASK_SCAN_CANDIDATES without finding
a target_visible row and the final batch was full, probe the database for
rows beyond the scan position so an exhausted candidate stream is not
erroneously marked incomplete.

Refs #327

* fix(node): Document and pin the bound on the task scan-ceiling probe

The scan-ceiling branch of collect_visible_tasks settles has_more with an
un-gated LIMIT 1 probe, so a caller can learn whether any row - readable
or not - trails the position the scan stopped at. Withholding the probe
does not remove that bit: enumeration past a denied window longer than
one scan budget requires handing back a continuation, and following that
continuation returns the same terminal page one round trip later.

State what the probe discloses (one bit, only at server-chosen positions
a full scan budget apart, reachable only through a MAC'd cursor, never a
denied row's id, payload or ucan_token) and pin it end to end. Also
correct the comment above the branch, which claimed has_more never comes
from an un-gated probe while the code below it did exactly that.

Refs #327

* Index task keyset pages so the scan ceiling bounds database work.

Optional IS NULL predicates kept the planner from using a created_at/id order, so every list_tasks_keyset batch could sort a growing match set before LIMIT. Dedicated per-domain SQL plus v28 indexes make the candidate ceiling a database bound.

Refs #327

* Reject oversized task pages so list never returns more than the requested limit.

fetch_tasks asked for the remaining total, then appended every row on a valid-shaped page. A remote that sent more tasks than want could make gl and MCP expose more than --limit. Treat that page as protocol-invalid before any extra row is kept.

Refs #327

* Enforce task page limits on legacy responses and drop redundant single-column indexes.

Refs #327

* Address review feedback on task-read authorization gating and client safety.

- Wire TaskReadBrake into /graphql/ws subscriptions and verify per-request field caps and per-IP rate limiting over WebSocket connections.
- Define open-claim eligibility separately from read visibility so unassigned tasks on readable/unscoped domains can be claimed without making task bodies enumerable.
- Propagate identity errors on explicit key directories in CLI and MCP instead of silently falling back to anonymous mode.
- Enforce response byte limits before deserializing task pages, validate row schema and page-local uniqueness before row commit, and sanitize continuation cursors in terminal diagnostics.
- Add GraphQL denial assertions for repo-less tasks and token isolation.

Refs #268
Refs #327

* Address review feedback on task-read authorization gating, WebSocket budgets, and test framing.

Refs #395

* Address review feedback on quarantined task claims, WebSocket auth, and MCP limit parsing.

Refs #395

* Reject forged signatures during WebSocket authentication upgrade.

Refs #395

* Accumulate forged WebSocket handshake response until headers terminate.

Refs #395

* Withhold quarantined-repo tasks on all read surfaces before party checks.

Refs #395

* docs: Align task-read documentation with enforced visibility and rate limits.

Refs #395

* docs: Distinguish task list and get behavior in rate-limit guidance.

Refs #395

* fix(node): Batch task quarantine checks to bound read costs.

Refs #395

* docs(node): clarify the shared task-read budget

Document that GraphQL task queries over HTTP and WebSocket debit the same per-IP hourly budget as REST task reads.

Refs #395

* fix(node,gl): bound scoped repo lookup and validate MCP cursor

Bound repository access in list_repos_deduped_by_ids to requested groups via idx_repos_owner_key_name, leaving dedup_cte unscoped. Strictly validate MCP task_list cursor argument before network dispatch.

Refs #396

* test(db): verify scoped lookup work in prepared plans

Measure discarded rows and verify actual custom and generic prepared-plan counters, including the previous full-index-scan regression.

Fixup for a0ea4c78e4.
Refs #396

* docs(db): remove stray dedup comment escapes

Restore readable root-path notation in the deduplicated listing documentation.

Fixup for a0ea4c78e4.
Refs #396

* fix(node): address #464 review findings

- Hold migration 27/28 numbering: reservation comment now names the
  competing Gitlawb/node#384 range (27-35) and states the numbering is
  on hold pending #384. No renumber.
- Restore crates/gl/src/identity.rs to LF endings so the diff shows
  only the added load_optional_keypair function.
- Drop the dead single-column idx_agent_tasks_assignee_key build from
  v27 (v28 builds the keyset-ordered variants directly); adjust the
  v27 test to assert the raw index is dropped and the expression
  index is never built.
- Align task_claimable with claim_task's conditional write: drop the
  delegator short-circuit and correct the docstring.
- Pin error_for_status on the gl task list path with a 403 mock that
  serves a well-formed empty page, so the test goes red if the call
  is removed.

* test(node): pin the delegator deny on a pre-assigned claim

- Add claim_task_delegator_cannot_claim_preassigned_task: pins opaque 404
  for delegator claiming a pre-assigned task (regression guard for P2)
- Fix test_list_tasks_server_error_surfaces regex: /api/v1/tasks\? ->
  /api/v1/tasks\? (escaped ? for literal question mark)
- Assert actual reqwest StatusCode::FORBIDDEN instead of matching "403"
  in error string (avoids spurious pass from random mock port)

* test(node): exercise streamed byte-budget guard with chunked body

Use with_chunked_body instead of with_body so mockito sends no
Content-Length, pinning the no-length streamed accumulation guard.
Assert the "exceeded" message to distinguish from the declared-length
check.

* fix(node): pin declared-length byte-budget arm in oversized content-length test

Addresses beardthelion review finding (2026-09-26): test_read_task_page_json_oversized_content_length now asserts on the "(declared" arm the way the chunked twin asserts "(exceeded", so the Content-Length pre-check at crates/gl/src/task.rs:211 is genuinely pinned. Verified the pin by neutering the pre-check (test goes red), then restored. fmt clean.

* fix(node): fail closed on claim for unresolvable repo

task_claimable returned true for an unassigned task whose repo_id is
slash-form (mirror row) or names a repo this node does not host, while
task_visible fails closed on the same rows. A signed stranger could claim
such a task even though reads 404. Return false for a Some(repo_id) the
node cannot resolve to a locally-hosted repo, matching task_visible's
fail-closed check; keep the None arm open (repo-less tasks claimable by
design). Adds a regression test asserting a stranger's claim 404s and
leaves the assignee unchanged for both row classes.

* fix(node): pad sealed task cursor to fixed width

The scan-ceiling token seals the last examined row's (created_at, id) as
variable-length JSON under a length-preserving keystream, so token length
leaked the withheld row's field widths: a 32-char to_rfc3339() rendering
mints a 148-char token where a 35-char one mints 161 chars. Pad the
serialized payload with trailing JSON whitespace to a fixed 128-byte
bucket before the SIV tag is computed; decode tolerates the padding
(serde_json ignores trailing whitespace) and tokens minted before this
change still verify. REST and GraphQL share task_cursor::encode, so one
fix covers both.

Adds token_length_does_not_vary_with_position_width: asserts identical
token length across narrow/wide timestamp positions and that the padded
token still decodes to the exact stored position.

* fix(node): address beardthelion round-six P3 findings on task cursor

- Pin the encode docstring compat claim: add unpadded_legacy_token_still_verifies
  test minting a token via the pre-padding recipe (siv + apply_keystream with
  no padding) and asserting decode returns the position.
- Pin the PAYLOAD_BUCKET round-up claim: add oversized_payload_rounds_up_to_next_bucket
  test with a 140-char id crossing the 128-byte bucket, asserting round-trip
  at two buckets.
- Correct timestamp-width figures: to_rfc3339() renders 25 chars with no
  fractional seconds (not 32); 32 is the microsecond width.

* fix(node): address beardthelion round-seven P3 findings on task cursor

- Correct wire-form docstring to v1.<iv>.<body>
- Fix payload width figures: 101 bytes total, to_rfc3339() precision ladder
- Tighten oversized-payload test to assert exactly 2*PAYLOAD_BUCKET
- Pin legacy back-compat test with a frozen v1 token fixture

* test(node): address beardthelion round-eight P3 findings on task cursor

- Pin the one-bucket width: round_trips_position_verbatim now asserts the
  decoded body of a normal token is exactly PAYLOAD_BUCKET, catching a pad
  that skips next-multiple or a drifted bucket constant. Every other width
  assertion here is bucket-relative, so only an absolute check goes red.

- Assert the frozen fixture is the unpadded shape:
  unpadded_legacy_token_still_verifies decodes the body segment and asserts
  raw.len() < PAYLOAD_BUCKET, so a literal re-minted under the padded
  recipe would retire the back-compat pin loudly instead of passing
  silently.

- Narrow the recipe-pin claim: the docstring now names what the frozen
  literal actually binds (domains, MAC input, keystream, segment layout,
  field names) instead of claiming padding and serialization changes break
  the test. The padding-width claim stays with
  oversized_payload_rounds_up_to_next_bucket.

* fix(node): trim stored assignee on every task gate; align SECURITY.md task-read wording

* test(node): pin mirror-row list-vs-get asymmetry; fix plan-test bind (#553)

- EXPLAIN helper binds normalize_owner_key(assignee) to match the
  production list_tasks_keyset bind.
- Pin the deliberate asymmetry jatmn asked about: a delegator sees a
  slash-form (mirror-hosted) task in a list, while get fails closed for
  every caller because the row has no local repo backing. Notes in
  get_visible_task and a regression test keep it from drifting.
- Repair test-mod drift from the main merge: build_schema gains its
  TaskCursorKey arg at three sites and list_tasks calls move to
  list_tasks_keyset.

* test(node): reconcile tasks complexity contract with TaskPageType (#553)

The four GraphQL task/brake tests failed on this branch: contract tests
still queried the flat tasks { id } shape that TaskPageType replaced, and
the alias-brake tests ran default-size lists whose six aliases exceed
GRAPHQL_MAX_COMPLEXITY before the brake can debit.

- Contract tests now select through items { ... } with refreshed cost
  arithmetic.
- Brake tests use tasks(limit: 1), keeping six aliases under the budget
  so the 6th field reaches TaskReadBrake and fails RATE_LIMIT_MESSAGE.
  Complexity stays the outer gate (#552); the brake stays the per-request
  field-count gate.

* docs: note mirror-hosted task list-vs-get asymmetry (#553)

Option 3 for slash-form mirror repo_id rows was pinned in code and test at
6c0926de; this adds the operator-facing half jatmn asked for. README and
SECURITY task-read bullets now state that mirror rows list for party readers
while get-by-id and GraphQL task fail closed for all callers.

* ci: retrigger codeql scan after alert 86 dismissal

---------

Co-authored-by: euxarist <[email protected]>
Co-authored-by: cairn-code <[email protected]>
Co-authored-by: cairn-intern <[email protected]>
Co-authored-by: Marlowe <[email protected]>
2026-11-09 19:02:54 +00:00
0919cf7c62 fix(core): ignore invalid signatures in satisfies_threshold (#349) (#554)
* fix(core): ignore invalid signatures in satisfies_threshold (#349)

RefUpdateCert::satisfies_threshold called verify_all, which fails closed
on the first malformed or invalid signature entry. Signature entries live
outside the signed body, so any third party can append a junk entry with
no key material — appending one denied an otherwise valid certificate, a
DoS on the threshold check.

Add a lenient valid_signers() helper that skips entries failing to parse,
decode, or verify, and have satisfies_threshold count distinct valid
signers from it. verify_all stays strict (fail-closed) so its API and the
tampered_signature_fails_verify_all test are unchanged. A forged entry
naming a real maintainer DID with a bad signature is skipped, so it cannot
inflate the count either.

Adds tests: appended junk entries do not deny a valid 2-of-2 cert, and a
forged maintainer signature is not counted toward the threshold.

Closes #349

* fix(core): bound the tolerant signature scan before cryptographic work

satisfies_threshold verified every entry, so a peer could append
arbitrarily many syntactically valid entries with bogus signatures and
buy attacker-controlled CPU and allocation on every threshold check.
Bound each stage ahead of the loop: reject a list over
MAX_SIGNATURE_ENTRIES loudly (a prefix scan would let junk starve real
signatures out of the window), refuse to base64-decode any encoded
signature that is not exactly the 86 chars of a 64-byte Ed25519
signature, skip non-maintainer and already-counted signers on a string
compare before any curve work, and return as soon as the threshold is
met.

* docs(cert): correct ENCODED_SIG_LEN length-gate comment (#554)

The comment claimed anything longer cannot decode to a valid signature.
The same 64 bytes appear in padded or non-url-safe encodings; the gate is a
canonical-form DoS bound for the tolerant scan, not a crypto impossibility.

---------

Co-authored-by: andreolf <[email protected]>
Co-authored-by: Kevin Codex <[email protected]>
2026-11-09 17:42:13 +00:00
beardthelionandRapyBus S.A.C. 05afd68e41 bootstrap-peers: add moonwake-ledger with a dialable p2p multiaddr (#559)
Co-authored-by: RapyBus S.A.C. <[email protected]>
2026-11-09 17:21:54 +00:00
beardthelion 6c971e7ea0 ci(audit): ignore RUSTSEC-2026-0459, unlinked libsqlite3-sys advisory (#564)
The advisory covers bundled SQLite in libsqlite3-sys 0.30.1, which sits in
Cargo.lock only because sqlx resolves sqlx-sqlite unconditionally. sqlx builds
postgres-only, so the bundled C source is never compiled or linked. The fix
needs libsqlite3-sys >= 0.39 but sqlx-sqlite 0.8.6 pins ^0.30.1; the waiver is
documented to retire if the sqlite feature ever enters the build.
2026-11-09 16:54:05 +00:00
beardthelion 10d5b98b2b fix(gl): report quickstart registration failure when the node returns no UCAN (#355) (#556)
A 2xx with a non-JSON or ucan-less body still printed "Registered
successfully / UCAN saved to ..." while nothing was written, and left a
stale ucan.json from another node in place. Print the success lines only
when a UCAN was actually persisted.
2026-11-09 16:11:08 +00:00
beardthelion 1544bedea7 Merge remote-tracking branch 'origin/main' into pr-148
# Conflicts:
#	crates/gitlawb-node/src/config.rs
#	crates/gitlawb-node/src/main.rs
#	crates/gl/src/pr.rs
2026-11-09 09:12:41 -06:00
carlitos973 15e017ad77 fix(node): make creation-route rate limit configurable; fix(gl): surface real errors on pr create failure
- gitlawb-node: rate limiter was hardcoded to 10 req/hour per DID across all
  creation routes (repo/issue/PR create, register, fork). Adds
  GITLAWB_RATE_LIMIT_MAX and GITLAWB_RATE_LIMIT_WINDOW_SECS config fields
  (clap env-var pattern matching the rest of Config), defaults unchanged.
  Lets node operators tune this for real agent/bot usage without recompiling.

- gl pr create: previously called resp.json() before checking response
  status, so any non-2xx response with an empty or non-JSON body crashed
  with a generic "invalid JSON" error instead of surfacing the real cause.
  Now reads the raw response body first and includes it in the error message
  on failure.
2026-07-03 07:29:34 -07:00
16 changed files with 520 additions and 93 deletions

No files matched your search

+12
View File
@@ -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)
]
+8
View File
@@ -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
View File
@@ -3601,6 +3601,7 @@ dependencies = [
"ed25519-dalek 2.2.0",
"gitlawb-core",
"icaptcha-client",
"libc",
"mockito",
"percent-encoding",
"reqwest",
+9 -1
View File
@@ -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
View File
@@ -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();
+23
View File
@@ -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
+8 -3
View File
@@ -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
+12 -9
View File
@@ -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());
}
}
}
+1
View File
@@ -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",
+5 -33
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -26,6 +26,7 @@ mod protect;
mod quickstart;
mod register;
mod repo;
mod secret_file;
mod star;
mod status;
mod sync;
+20 -18
View File
@@ -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");
+5 -2
View File
@@ -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"))
}
+180
View File
@@ -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
);
}
}
+1 -1
View File
@@ -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(());
}