# Story 4.17: Verification API and CLI

## Status

Ready

## Story

**As a** compliance auditor,  
**I want** tools to verify log file integrity offline,  
**So that** I can detect tampering, gaps, and prove log integrity for regulatory audits.

## Relationship to Other Stories

- **Depends on 4.14**: Uses package types and canonicalization
- **Depends on 4.15**: Verifies chain hash linkage from enricher
- **Depends on 4.16**: Verifies manifests from sealed sink

## Background

Verification is a read-path operation that:
1. Recomputes per-record MACs and compares to stored values
2. Verifies chain hash continuity and sequence monotonicity
3. Compares file root to manifest
4. Validates manifest signature

This can be done programmatically via API or via CLI for audit workflows.

## Acceptance Criteria

### AC1: VerifyReport Data Structure

- [ ] Comprehensive verification result:
  ```python
  @dataclass
  class VerifyReport:
      valid: bool
      file_path: str
      records_checked: int
      records_valid: int
      records_invalid: int
      first_invalid_seq: int | None = None
      chain_valid: bool = True
      chain_breaks: list[int] = field(default_factory=list)  # Seq numbers
      manifest_valid: bool | None = None  # None if no manifest
      manifest_signature_valid: bool | None = None
      errors: list[VerifyError] = field(default_factory=list)
      warnings: list[str] = field(default_factory=list)
      duration_ms: float = 0.0

  @dataclass
  class VerifyError:
      seq: int
      error_type: Literal["mac_mismatch", "chain_break", "seq_gap", "missing_integrity"]
      expected: str | None = None
      actual: str | None = None
      message: str = ""
  ```

### AC2: Per-Record Verification

- [ ] `verify_record(record: dict, key: bytes, algo: str) -> bool`
- [ ] Recompute MAC over canonical payload (excluding integrity field)
- [ ] Compare to stored `integrity.mac`
- [ ] Support HMAC-SHA256 and Ed25519

### AC3: Chain Verification

- [ ] `verify_chain(records: list[dict]) -> ChainVerifyResult`
- [ ] Verify `integrity.prev_chain_hash` links to previous `integrity.chain_hash`
- [ ] Verify sequence numbers are monotonically increasing
- [ ] Detect gaps (missing sequence numbers)
- [ ] First record must have genesis prev_chain_hash (32 zero bytes)

### AC4: File Verification

- [ ] `verify_file(path: Path, keys: KeyStore, manifest_path: Path | None = None) -> VerifyReport`
- [ ] Read JSONL file line by line (streaming, memory efficient)
- [ ] Verify each record's MAC
- [ ] Verify chain continuity
- [ ] If manifest provided:
  - Compare `root_chain_hash` to last record
  - Verify manifest signature
  - Check `record_count` matches

### AC5: Cross-File Chain Verification

- [ ] `verify_chain_across_files(files: list[Path], keys: KeyStore) -> VerifyReport`
- [ ] When `rotate_chain=False`, verify chain continues across files
- [ ] Use `continues_from` manifest field to link chains
- [ ] Detect file reordering or replacement

### AC6: KeyStore Abstraction

- [ ] `KeyStore` protocol for key lookup:
  ```python
  class KeyStore(Protocol):
      def get_key(self, key_id: str) -> bytes | None:
          """Return key for given key_id, or None if not found."""
  ```
- [ ] `EnvKeyStore`: Load keys from environment variables
- [ ] `FileKeyStore`: Load keys from directory (`{dir}/{key_id}.key`)
- [ ] Support multiple active keys for key rotation scenarios

### AC7: CLI Interface

- [ ] Command: `fapilog-tamper verify <path> [options]`
- [ ] Options:
  ```
  --manifest PATH      Path to manifest file (auto-detect if not specified)
  --keys PATH          Path to key file or directory
  --key-env VAR        Environment variable containing key
  --continue-from PATH Continue chain verification from previous manifest
  --format json|text   Output format (default: text)
  --verbose            Show per-record details
  --quiet              Only output errors
  ```
- [ ] Exit codes:
  - `0`: All checks passed
  - `1`: Verification failures detected
  - `2`: Error (file not found, key missing, etc.)

### AC8: Error Reporting

- [ ] Clear, actionable error messages
- [ ] Include sequence number and record offset for failures
- [ ] Suggest remediation (e.g., "key mismatch - check key_id")
- [ ] JSON output mode for programmatic consumption

### AC9: Self-Checker Integration

- [ ] Optional coroutine `run_self_check(paths: list[Path], keys: KeyStore)`:
  - Periodically verify recent files
  - Emit compliance alert via `diagnostics.warn` on failure
  - Configurable interval
- [ ] Integration with `TamperConfig.verify_on_close`

## Technical Design

### Verifier Class

```python
class Verifier:
    """Verification engine for tamper-evident logs."""

    def __init__(self, keys: KeyStore):
        self._keys = keys

    def verify_record(self, record: dict, key: bytes, algo: str) -> bool:
        """Verify MAC for a single record."""
        integrity = record.get("integrity")
        if not integrity:
            return False

        # Exclude integrity field from canonical payload
        payload_record = {k: v for k, v in record.items() if k != "integrity"}
        payload = canonicalize(payload_record)

        stored_mac = b64url_decode(integrity["mac"])
        if algo == "HMAC-SHA256":
            expected_mac = hmac.new(key, payload, hashlib.sha256).digest()
        else:  # Ed25519
            # Verify signature instead of comparing
            try:
                verify_key = VerifyKey(key)
                verify_key.verify(payload, stored_mac)
                return True
            except BadSignatureError:
                return False

        return hmac.compare_digest(stored_mac, expected_mac)

    def verify_chain(self, records: list[dict]) -> list[VerifyError]:
        """Verify chain linkage across records."""
        errors = []
        prev_chain_hash = b64url_encode(b'\x00' * 32)  # Genesis
        prev_seq = 0

        for i, record in enumerate(records):
            integrity = record.get("integrity", {})
            seq = integrity.get("seq", 0)
            stored_prev = integrity.get("prev_chain_hash", "")

            # Check sequence continuity
            if seq != prev_seq + 1:
                errors.append(VerifyError(
                    seq=seq,
                    error_type="seq_gap",
                    expected=str(prev_seq + 1),
                    actual=str(seq),
                    message=f"Sequence gap: expected {prev_seq + 1}, got {seq}",
                ))

            # Check chain linkage
            if stored_prev != prev_chain_hash:
                errors.append(VerifyError(
                    seq=seq,
                    error_type="chain_break",
                    expected=prev_chain_hash[:16] + "...",
                    actual=stored_prev[:16] + "...",
                    message=f"Chain break at seq {seq}",
                ))

            prev_chain_hash = integrity.get("chain_hash", "")
            prev_seq = seq

        return errors

    async def verify_file(
        self,
        path: Path,
        manifest_path: Path | None = None,
    ) -> VerifyReport:
        """Verify a complete log file."""
        start = time.monotonic()
        records = []
        errors = []
        records_valid = 0

        # Stream records from file
        async for line_num, record in self._stream_records(path):
            key_id = record.get("integrity", {}).get("key_id", "")
            key = self._keys.get_key(key_id)
            if not key:
                errors.append(VerifyError(
                    seq=record.get("integrity", {}).get("seq", 0),
                    error_type="missing_key",
                    message=f"Key not found: {key_id}",
                ))
                continue

            algo = record.get("integrity", {}).get("algo", "HMAC-SHA256")
            if self.verify_record(record, key, algo):
                records_valid += 1
            else:
                errors.append(VerifyError(
                    seq=record.get("integrity", {}).get("seq", 0),
                    error_type="mac_mismatch",
                    message=f"MAC verification failed at line {line_num}",
                ))

            records.append(record)

        # Verify chain
        chain_errors = self.verify_chain(records)
        errors.extend(chain_errors)

        # Verify manifest if provided
        manifest_valid = None
        manifest_sig_valid = None
        if manifest_path and manifest_path.exists():
            manifest_valid, manifest_sig_valid = await self._verify_manifest(
                manifest_path, records
            )

        return VerifyReport(
            valid=len(errors) == 0 and manifest_valid in (None, True),
            file_path=str(path),
            records_checked=len(records),
            records_valid=records_valid,
            records_invalid=len(records) - records_valid,
            first_invalid_seq=errors[0].seq if errors else None,
            chain_valid=not chain_errors,
            chain_breaks=[e.seq for e in chain_errors if e.error_type == "chain_break"],
            manifest_valid=manifest_valid,
            manifest_signature_valid=manifest_sig_valid,
            errors=errors,
            duration_ms=(time.monotonic() - start) * 1000,
        )
```

### CLI Implementation

```python
# fapilog_tamper/cli.py
import click

@click.group()
def main():
    """Tamper-evident logging tools for fapilog."""
    pass

@main.command()
@click.argument("path", type=click.Path(exists=True))
@click.option("--manifest", type=click.Path(), help="Manifest file path")
@click.option("--keys", type=click.Path(), help="Key file or directory")
@click.option("--key-env", help="Environment variable with key")
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text")
@click.option("--verbose", is_flag=True, help="Show per-record details")
@click.option("--quiet", is_flag=True, help="Only output errors")
def verify(path, manifest, keys, key_env, output_format, verbose, quiet):
    """Verify integrity of a log file."""
    import asyncio

    key_store = build_key_store(keys, key_env)
    verifier = Verifier(key_store)

    report = asyncio.run(verifier.verify_file(
        Path(path),
        Path(manifest) if manifest else None,
    ))

    if output_format == "json":
        click.echo(json.dumps(asdict(report), indent=2))
    else:
        print_text_report(report, verbose, quiet)

    sys.exit(0 if report.valid else 1)
```

## Test Cases

| Test | Description |
|------|-------------|
| `test_verify_record_valid` | Valid MAC passes verification |
| `test_verify_record_tampered` | Modified payload fails |
| `test_verify_record_wrong_key` | Wrong key fails |
| `test_verify_chain_valid` | Intact chain passes |
| `test_verify_chain_gap` | Missing sequence detected |
| `test_verify_chain_break` | Wrong prev_chain_hash detected |
| `test_verify_chain_reorder` | Reordered records detected |
| `test_verify_file_valid` | Valid file returns `valid=True` |
| `test_verify_file_tampered` | Tampered file returns errors |
| `test_verify_manifest_valid` | Manifest signature verifies |
| `test_verify_manifest_tampered` | Modified manifest fails |
| `test_verify_manifest_root_mismatch` | Wrong root_chain_hash detected |
| `test_cross_file_chain` | Chain continues across files |
| `test_key_store_env` | Keys loaded from environment |
| `test_key_store_file` | Keys loaded from files |
| `test_cli_verify_success` | CLI exits 0 on valid file |
| `test_cli_verify_failure` | CLI exits 1 on tampered file |
| `test_cli_json_output` | JSON output parseable |
| `test_self_checker_alerts` | Self-checker emits diagnostics on failure |

## Out of Scope

- KMS/Vault key sources (Story 4.18)
- Remote transport verification (future story)
- Merkle tree aggregation (future optimization)

## Definition of Done

- [ ] `Verifier` class with all verification methods
- [ ] `VerifyReport` and `VerifyError` data structures
- [ ] `KeyStore` protocol with Env and File implementations
- [ ] CLI with `verify` command
- [ ] Streaming file verification (memory efficient)
- [ ] Cross-file chain verification
- [ ] Self-checker coroutine
- [ ] Unit tests pass with >90% coverage
- [ ] Integration test: tamper detection end-to-end
- [ ] CLI documentation in README

## Estimation

**Story Points: 5**

## Labels

`enhancement`, `epic:enterprise-compliance`, `priority:high`, `status:ready`, `story`, `compliance`, `cli`

## Change Log

| Date       | Version | Description                              | Author   |
|------------|---------|------------------------------------------|----------|
| 2025-12-29 | 0.1     | Story created from addon design document | AI Agent |

