# Story 4.13: Tamper-Evident Hash Chains for Audit Events

## Status

Cancelled

## Decision

**This story has been cancelled.** Tamper-evident functionality is offered exclusively through the enterprise `fapilog-tamper` plugin (Stories 4.14–4.18), not as part of core fapilog.

### Rationale

- Keep core fapilog lightweight and focused on logging fundamentals
- Enterprise compliance features belong in enterprise add-ons
- Avoids partial implementations that could confuse users
- Clear separation: core = logging, plugin = tamper-evident integrity

### Migration

All functionality originally planned for this story is now covered by:

| Original 4.13 Feature     | New Location                                  |
| ------------------------- | --------------------------------------------- |
| `sequence_number` field   | Story 4.15: `IntegrityFields.seq`             |
| `previous_hash` field     | Story 4.15: `IntegrityFields.prev_chain_hash` |
| `checksum` computation    | Story 4.15: `IntegrityFields.mac` (keyed)     |
| `verify_checksum()`       | Story 4.17: `Verifier.verify_record()`        |
| `verify_chain()`          | Story 4.17: `Verifier.verify_chain()`         |
| `ChainVerificationResult` | Story 4.17: `VerifyReport`                    |

### Existing Placeholder Fields

The `AuditEvent` model contains placeholder fields that will remain unused by core:

```python
checksum: Optional[str] = None  # Reserved for future use
signature: Optional[str] = None  # Reserved for future use
```

These fields are retained for backwards compatibility but are not populated by core fapilog.

---

## Original Story (Archived)

**As an** enterprise application developer using fapilog,  
**I want** audit events to include sequence numbers and cryptographic hash chains,  
**So that** I can detect tampering, gaps, and prove log integrity for compliance audits.

## Background

The `AuditEvent` model already has placeholder fields that are never populated:

```python
checksum: Optional[str] = None  # For log integrity verification
signature: Optional[str] = None  # Digital signature for tamper detection
```

This story implements structural chain integrity:

1. `sequence_number` - monotonic counter for gap detection
2. `previous_hash` - SHA-256 of previous event for chain linkage
3. `checksum` - SHA-256 of event payload for integrity verification

This is **keyless** integrity (no secrets required). Story 4.11 extends this with HMAC for keyed signatures.

## Acceptance Criteria

### AC1: New fields on AuditEvent

- [ ] `sequence_number: int` field added (monotonic, starts at 1)
- [ ] `previous_hash: str` field added (SHA-256 hex, genesis = 64 zeros)
- [ ] `checksum` field auto-populated (SHA-256 of payload excluding checksum)

### AC2: Chain integrity maintained by AuditTrail

- [ ] `AuditTrail` maintains `_sequence_counter: int` and `_last_hash: str`
- [ ] `log_event()` assigns chain fields under lock before queuing
- [ ] Genesis uses `previous_hash = "0" * 64`
- [ ] Hash computation is deterministic (sorted keys, consistent serialization)

### AC3: Verification helpers

- [ ] `AuditEvent.compute_checksum() -> str` recomputes hash
- [ ] `AuditEvent.verify_checksum() -> bool` compares stored vs computed
- [ ] `AuditTrail.verify_chain(events) -> ChainVerificationResult` validates chain

### AC4: Thread/async safety

- [ ] Sequence increment and hash computation protected by `_lock`
- [ ] No race conditions under concurrent `log_event()` calls

### AC5: Backwards compatibility

- [ ] Existing code creating `AuditEvent` without new fields still works
- [ ] Events loaded from storage without new fields remain usable

## Technical Design

### Schema Changes (`src/fapilog/core/audit.py`)

```python
GENESIS_HASH = "0" * 64

class AuditEvent(BaseModel):
    # ... existing fields ...

    # Chain integrity (auto-populated by AuditTrail)
    sequence_number: int = Field(
        default=0,
        description="Monotonic sequence number for gap detection"
    )
    previous_hash: str = Field(
        default="",
        description="SHA-256 of previous event in chain"
    )
    # Existing field - now populated
    checksum: Optional[str] = Field(
        default=None,
        description="SHA-256 of event payload"
    )

    def compute_checksum(self) -> str:
        """Compute SHA-256 of event payload (excluding checksum field)."""
        import hashlib
        import json
        data = self.model_dump(exclude={"checksum"})
        payload = json.dumps(data, sort_keys=True, default=str)
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()

    def verify_checksum(self) -> bool:
        """Verify stored checksum matches computed value."""
        if self.checksum is None:
            return False
        return self.checksum == self.compute_checksum()
```

### Chain Verification Result

```python
@dataclass
class ChainVerificationResult:
    """Result of chain integrity verification."""
    valid: bool
    events_checked: int
    first_invalid_sequence: int | None = None
    error_message: str | None = None
```

### AuditTrail Changes

```python
class AuditTrail:
    def __init__(self, ...):
        # ... existing ...
        self._sequence_counter: int = 0
        self._last_hash: str = GENESIS_HASH

    async def log_event(self, ...) -> str:
        # ... create event ...

        async with self._lock:
            self._sequence_counter += 1
            event.sequence_number = self._sequence_counter
            event.previous_hash = self._last_hash
            event.checksum = event.compute_checksum()
            self._last_hash = event.checksum

        await self._event_queue.put(event)
        # ... rest of method ...

    @staticmethod
    def verify_chain(events: list[AuditEvent]) -> ChainVerificationResult:
        """Verify integrity of ordered audit events."""
        if not events:
            return ChainVerificationResult(valid=True, events_checked=0)

        sorted_events = sorted(events, key=lambda e: e.sequence_number)
        expected_prev = GENESIS_HASH

        for i, event in enumerate(sorted_events):
            expected_seq = i + 1

            # Check sequence continuity
            if event.sequence_number != expected_seq:
                return ChainVerificationResult(
                    valid=False,
                    events_checked=i,
                    first_invalid_sequence=event.sequence_number,
                    error_message=f"Gap: expected seq {expected_seq}, got {event.sequence_number}",
                )

            # Verify checksum
            if not event.verify_checksum():
                return ChainVerificationResult(
                    valid=False,
                    events_checked=i,
                    first_invalid_sequence=event.sequence_number,
                    error_message=f"Checksum mismatch at seq {event.sequence_number}",
                )

            # Verify chain linkage
            if event.previous_hash != expected_prev:
                return ChainVerificationResult(
                    valid=False,
                    events_checked=i,
                    first_invalid_sequence=event.sequence_number,
                    error_message=f"Chain broken at seq {event.sequence_number}",
                )

            expected_prev = event.checksum

        return ChainVerificationResult(valid=True, events_checked=len(sorted_events))
```

## Test Cases

| Test                            | Description                                     |
| ------------------------------- | ----------------------------------------------- |
| `test_checksum_populated`       | Logged event has 64-char hex checksum           |
| `test_sequence_increments`      | 3 events have sequence 1, 2, 3                  |
| `test_previous_hash_links`      | `events[1].previous_hash == events[0].checksum` |
| `test_genesis_hash`             | First event has `previous_hash == "0" * 64`     |
| `test_verify_checksum_valid`    | Untampered event passes verification            |
| `test_verify_checksum_tampered` | Modified event fails verification               |
| `test_verify_chain_valid`       | Intact chain returns `valid=True`               |
| `test_verify_chain_gap`         | Missing event detected                          |
| `test_verify_chain_tampered`    | Modified event in chain detected                |
| `test_concurrent_logging`       | 100 concurrent events have unique sequences     |

## Out of Scope

- HMAC/keyed signatures (see Story 4.11)
- Sequence persistence across restarts (application concern)
- Merkle tree aggregation (future optimization)

## Definition of Done

- [ ] `AuditEvent` schema updated with new fields
- [ ] `compute_checksum()` and `verify_checksum()` implemented
- [ ] `AuditTrail.log_event()` populates chain fields
- [ ] `AuditTrail.verify_chain()` implemented
- [ ] Unit tests pass for all acceptance criteria
- [ ] Existing audit tests pass (backwards compatibility)
- [ ] mypy passes with no new errors
- [ ] Documentation updated

## Estimation

**Story Points: 3**

## Labels

`enhancement`, `epic:core-foundation`, `priority:medium`, `status:ready`, `story`, `compliance`

## Change Log

| Date       | Version | Description   | Author   |
| ---------- | ------- | ------------- | -------- |
| 2025-12-23 | 0.1     | Story created | AI Agent |
