specs/001-gpg-event-signing/tasks.md
Ref: Size: 13.5 KiB
# Tasks: Ed25519 Signing for Event Commits
**Input**: Design documents from `/specs/001-gpg-event-signing/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Tests**: Included (TDD approach — user preference). Write tests first, ensure they fail, then implement.
**Organization**: Tasks grouped by user story for independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup
**Purpose**: Add dependencies and create the new signing module skeleton
<!-- sequential -->
- [x] T001 Add `ed25519-dalek` (v2, features: `rand_core`), `rand_core` (v0.6, features: `getrandom`), `base64` (v0.22), and `dirs` (v5) dependencies to Cargo.toml. Do NOT enable serde_json's `preserve_order` feature (BTreeMap-backed Map required for canonical key sorting)
- [x] T002 Create `src/signing.rs` module skeleton with `SignedEvent` struct (using `#[serde(flatten)]` on `Event`), `VerifyStatus` enum (`Valid`, `Invalid`, `Missing` — no `UnknownKey`, key trust is out of scope), and `SignatureVerificationResult` struct per data-model.md. Add `pub mod signing;` to `src/lib.rs`. IMPORTANT: Test that `serde(flatten)` round-trips correctly with Event's internally-tagged Action enum — if it doesn't work, pivot to manual JSON Value construction
- [x] T003 Add signing-related error variants to `src/error.rs`: `Signing(String)` for key/sign errors, `Verification(String)` for verify errors, and `KeyNotFound` for missing key file
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core signing/verification functions that ALL user stories depend on
**CRITICAL**: No user story work can begin until this phase is complete
### Tests for Foundation
<!-- sequential (all target same file: tests/signing_test.rs) -->
- [x] T004 Write tests for key generation and storage in `tests/signing_test.rs`: create the test file, test `generate_keypair()` creates valid key files at expected paths with correct permissions (0o600 for private), test loading keypair from disk, test error when key file missing
- [x] T005 Write tests for sign/verify round-trip in `tests/signing_test.rs`: test `sign_event()` produces a `SignedEvent` with non-empty base64 `signature` and `pubkey` fields, test `verify_signed_event()` returns `Valid` for correctly signed event, test tampered event returns `Invalid`, test missing signature returns `Missing`
- [x] T006 Write tests for canonical serialization in `tests/signing_test.rs`: test that serializing the same `Event` twice produces byte-exact identical output, test that `SignedEvent` JSON contains `signature` and `pubkey` fields alongside flattened event fields, test `serde(flatten)` round-trip with internally-tagged Action enum
### Implementation for Foundation
<!-- sequential -->
- [x] T007 Implement `generate_keypair(config_dir: &Path) -> Result<VerifyingKey>` in `src/signing.rs`: generate Ed25519 keypair using `SigningKey::generate(&mut OsRng)`, write private key (base64) to `{config_dir}/signing-key` with 0o600 permissions, write public key (base64) to `{config_dir}/signing-key.pub`, create config dir with 0o700 if needed
- [x] T008 Implement `load_signing_key(config_dir: &Path) -> Result<SigningKey>` and `load_verifying_key(config_dir: &Path) -> Result<VerifyingKey>` in `src/signing.rs`: read base64-encoded key files, decode, construct key types, return `KeyNotFound` error if files don't exist
- [x] T009 Implement `canonical_json(event: &Event) -> Result<Vec<u8>>` in `src/signing.rs`: serialize Event to `serde_json::Value`, then to compact string with `to_string()`. CRITICAL: Confirm serde_json uses BTreeMap-backed Map (no `preserve_order` feature) which sorts keys alphabetically. Add a dedicated test that serializes the same Event twice and asserts byte-exact equality. Use `base64::engine::general_purpose::STANDARD` (with padding) for all base64 encoding throughout the module
- [x] T010 Implement `sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent>` in `src/signing.rs`: call `canonical_json()`, sign bytes with `signing_key.sign()`, construct `SignedEvent` with base64-encoded signature and pubkey
- [x] T011 Implement `verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus>` in `src/signing.rs`: extract signature and pubkey, decode base64 (STANDARD with padding), reconstruct Event from SignedEvent, compute canonical JSON, verify signature against embedded pubkey, return `Valid` if signature checks out or `Invalid` if not. Note: key trust (known-keys registry) is out of scope — any cryptographically valid signature is accepted
**Checkpoint**: Foundation ready — signing primitives work, tests pass
---
## Phase 3: User Story 1 — Sign Event Commits (Priority: P1) MVP
**Goal**: Every event commit created by the system carries a valid Ed25519 signature embedded in event.json
**Independent Test**: Create an issue, read the event.json blob from the commit, verify it contains valid `signature` and `pubkey` fields
### Tests for User Story 1
<!-- parallel-group: 2 (max 3 concurrent) -->
- [x] T012 [P] [US1] Write integration test in `tests/collab_test.rs`: create a temp repo with a signing key, call `issue::open()`, walk the DAG, deserialize event.json as `SignedEvent`, assert `signature` and `pubkey` are present and `verify_signed_event()` returns `Valid`
- [x] T013 [P] [US1] Write integration test in `tests/collab_test.rs`: attempt `issue::open()` without a signing key present, assert it returns a `KeyNotFound` error
- [x] T014 [P] [US1] Write CLI test in `tests/cli_test.rs`: run `collab init-key`, assert success and key files created; run `collab init-key` again without `--force`, assert error; run with `--force`, assert success
### Implementation for User Story 1
<!-- sequential -->
- [x] T015 [US1] Update `dag::create_root_event()` and `dag::append_event()` in `src/dag.rs` to accept an `Option<&SigningKey>` parameter. When `Some`, call `sign_event()` and serialize `SignedEvent` as the blob instead of plain `Event`. When `None`, return `KeyNotFound` error
- [x] T016 [US1] Update `dag::reconcile()` in `src/dag.rs` to accept `Option<&SigningKey>` and sign the merge event when creating merge commits
- [x] T017 [US1] Update all callers in `src/issue.rs` to load the signing key via `load_signing_key()` and pass it to `dag::create_root_event()` / `dag::append_event()`. Use `dirs::config_dir()` or `$HOME/.config/git-collab` for key path
- [x] T018 [US1] Update all callers in `src/patch.rs` to load the signing key and pass it to DAG functions, same pattern as issue.rs
- [x] T019 [US1] Add `InitKey { force: bool }` variant to `Commands` enum in `src/cli.rs` with clap attributes for the `init-key` subcommand with `--force` flag
- [x] T020 [US1] Add `init-key` handler in `src/lib.rs` `run()` function: call `signing::generate_keypair()`, handle key-already-exists error (suggest `--force`), print pubkey on success per CLI contract
- [x] T021 [US1] Update `dag::walk_events()` in `src/dag.rs` to deserialize `SignedEvent` and extract the `Event` from it, so existing state materialization continues to work
**Checkpoint**: User Story 1 complete — all locally-created events are signed, `collab init-key` works
---
## Phase 4: User Story 2 — Verify Signatures on Sync (Priority: P2)
**Goal**: All incoming event commits are verified for valid Ed25519 signatures during sync, with unsigned/invalid commits rejected
**Independent Test**: Sync from a remote that contains unsigned commits, verify they are rejected with clear error messages
### Tests for User Story 2
<!-- parallel-group: 3 (max 3 concurrent) -->
- [x] T022 [P] [US2] Write test in `tests/sync_test.rs`: set up two repos (Alice and Bob) both with signing keys, Alice creates a signed issue, Bob syncs — verify sync succeeds and issue is present
- [x] T023 [P] [US2] Write test in `tests/sync_test.rs`: set up repo with unsigned event commits (created by directly writing event.json without signature), sync from it — verify the ref is rejected and error message includes commit OID and "missing signature"
- [x] T024 [P] [US2] Write test in `tests/sync_test.rs`: set up repo with tampered event (valid signature but modified event content), sync — verify ref rejected with "invalid signature"
### Implementation for User Story 2
<!-- sequential -->
- [x] T025 [US2] Implement `verify_ref(repo: &Repository, ref_name: &str) -> Result<Vec<SignatureVerificationResult>>` in `src/signing.rs`: walk DAG for the ref, for each commit read event.json, deserialize as `SignedEvent`, call `verify_signed_event()`, collect results. Return error if any commit lacks event.json
- [x] T026 [US2] Add verification step in `sync::reconcile_refs()` in `src/sync.rs`: before calling `dag::reconcile()`, call `verify_ref()` on the remote sync ref. If any result is not `Valid`, skip reconciliation for that ref, print error to stderr with commit OIDs and failure reasons per CLI contract, continue to next ref
- [x] T027 [US2] Handle the "new from remote" case in `sync::reconcile_refs()`: when a local ref doesn't exist and we're importing from remote, verify the remote ref first. Reject if verification fails
**Checkpoint**: User Story 2 complete — sync rejects unsigned/invalid events
---
## Phase 5: User Story 3 — Merge Commit Signing During Reconciliation (Priority: P3)
**Goal**: Merge commits created during sync reconciliation are signed with the syncing user's key
**Independent Test**: Create divergent histories between two repos, sync to trigger reconciliation, verify the merge commit's event.json contains a valid signature
### Tests for User Story 3
<!-- sequential -->
- [x] T028 [US3] Write test in `tests/sync_test.rs`: set up two repos (Alice and Bob) with signing keys, both create events on the same issue (divergent history), sync from one to the other — verify the reconciliation merge commit has a valid Ed25519 signature from the syncing user
### Implementation for User Story 3
<!-- sequential -->
- [x] T029 [US3] Update `sync::sync()` in `src/sync.rs` to load the syncing user's signing key at the start and pass it through to `reconcile_refs()` and then to `dag::reconcile()`
- [x] T030 [US3] Ensure `dag::reconcile()` uses the signing key (from T016) to sign the merge event — verify the merge commit's event.json blob is a properly signed `SignedEvent`
**Checkpoint**: All user stories complete — signing chain is maintained through reconciliation
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Update existing tests, ensure backward compatibility handling
<!-- sequential (T032 must land before T031/T033 since they depend on the helper) -->
- [x] T032 Update test helpers in `tests/common/mod.rs` to include a `setup_signing_key(config_dir: &Path)` helper that generates a test keypair, and update `init_repo()` / `TestRepo::new()` to call it
<!-- parallel-group: 4 (max 2 concurrent, after T032) -->
- [x] T031 [P] Update existing tests in `tests/collab_test.rs` to set up signing keys in test fixtures so all event creation tests pass with mandatory signing
- [x] T033 [P] Update existing tests in `tests/sync_test.rs` to use signing keys in both repos for sync tests
<!-- sequential -->
- [x] T034 Run `cargo test` and fix any remaining compilation errors or test failures across the entire test suite
- [x] T035 Run quickstart.md scenarios manually: `collab init-key`, create signed issue, sync with verification — verify all flows match documented behavior
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Foundation)**: Depends on Phase 1 — BLOCKS all user stories
- **Phase 3 (US1)**: Depends on Phase 2 — MVP
- **Phase 4 (US2)**: Depends on Phase 3 (needs signed events to exist for verification)
- **Phase 5 (US3)**: Depends on Phase 4 (needs verification in sync before testing merge signing)
- **Phase 6 (Polish)**: Depends on Phase 5
### User Story Dependencies
- **US1 (Sign Events)**: Foundational — must complete first since US2 and US3 depend on events being signed
- **US2 (Verify on Sync)**: Depends on US1 — needs signed events to verify against
- **US3 (Merge Signing)**: Depends on US2 — needs verification in sync to validate merge signatures
### Within Each User Story
- Tests written FIRST, verified to FAIL
- Implementation follows test structure
- Story complete before next priority
### Parallel Opportunities
- T004, T005, T006 (foundation tests) can run in parallel
- T012, T013, T014 (US1 tests) can run in parallel
- T022, T023, T024 (US2 tests) can run in parallel
- T031, T032, T033 (polish) can run in parallel
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001-T003)
2. Complete Phase 2: Foundation (T004-T011)
3. Complete Phase 3: User Story 1 (T012-T021)
4. **STOP and VALIDATE**: All locally-created events are signed
### Incremental Delivery
1. Setup + Foundation → Signing primitives ready
2. US1 → Events are signed → MVP
3. US2 → Sync verifies signatures → Trust model complete
4. US3 → Merge commits signed → Full signing chain
5. Polish → All tests updated → Production ready
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story
- TDD approach: tests first, verify they fail, then implement
- Total: 35 tasks (3 setup, 8 foundation, 10 US1, 6 US2, 3 US3, 5 polish)
- Key architectural insight: signing is injected at the DAG layer so issue.rs/patch.rs callers just need to pass the signing key through