#!/usr/bin/env python3
"""Verify a Haven record — a Trace dossier or an Estate Record — without installing Haven.

    python3 verify-dossier.py record.docx record.manifest.json [device-key.pub]

Checks, in order:

  1. The manifest's own shape and version.
  2. `documentSha256` against the .docx in front of you — is this the file
     the manifest describes?
  3. Per-exhibit digests are well-formed and unique, and any gathered
     attachments are present in the sidecar folder with matching hashes.
  4. The Ed25519 signature over the canonical manifest bytes ("Haven
     assembled these exhibits"), when a public key is supplied.
  5. The Ed25519 signature over the document hash ("...and this is the file
     that came out of them"). Both are required: the hash alone is not
     evidence, since anyone holding both files could rewrite it.

The key is taken from the manifest unless you pass one explicitly. That is
safe for checking integrity and pointless for checking identity: the
signing key is generated on the machine that produced the document, so this
is a SELF-SIGNED record. Pass a key you obtained independently if you need
to confirm which install produced it.

What a passing check means: the document, its exhibits and its attached
files were signed together and none has been altered since. What it does
NOT mean: that Haven vouched for the contents (nobody but the sender did),
or that the underlying messages are genuine (Haven read them from the mail
provider; it did not witness them being sent).

Step 4 needs `pynacl` or `cryptography`. Steps 1-3 need neither, and are
worth running on their own — a document whose hash doesn't match its
manifest has been altered regardless of any signature.

Exit code 0 = every check that could run passed.
"""

import base64
import hashlib
import json
import os
import sys


def frame(buf: bytearray, field: bytes) -> None:
    """Length-prefixed framing: <byte-len>:<bytes>.

    Must match `frame()` in crates/haven-dossier/src/manifest.rs exactly.
    Length-prefixing rather than a delimiter because message bodies are
    arbitrary text and any delimiter could appear inside one.
    """
    buf.extend(str(len(field)).encode())
    buf.append(ord(":"))
    buf.extend(field)


def canonical_document_bytes(m: dict) -> bytes:
    """Bytes the document signature covers.

    Mirrors `canonical_document_bytes` in manifest.rs. The identity fields
    are included so a document signature cannot be lifted from one
    manifest into another describing the same bytes.
    """
    buf = bytearray()
    frame(buf, b"document")
    frame(buf, str(m["version"]).encode())
    frame(buf, m["investigationId"].encode())
    frame(buf, str(m["generatedAtSecs"]).encode())
    frame(buf, m["documentSha256"].encode())
    return bytes(buf)


def canonical_manifest_bytes(m: dict) -> bytes:
    """Rebuild the exact bytes the signature covers.

    Mirrors `canonical_manifest_bytes` in manifest.rs. `documentSha256`
    is deliberately excluded — it does not exist when the signature is
    made, because the document embeds the signature.
    """
    buf = bytearray()
    frame(buf, str(m["version"]).encode())
    frame(buf, str(m["generatedAtSecs"]).encode())
    frame(buf, m["investigationId"].encode())
    frame(buf, m["agentVersion"].encode())
    frame(buf, str(m["exhibitCount"]).encode())
    for ex in m["exhibits"]:
        frame(buf, str(ex["n"]).encode())
        frame(buf, ex["source"].encode())
        frame(buf, ex["sourceRef"].encode())
        date = ex.get("itemDate")
        frame(buf, b"" if date is None else str(date).encode())
        frame(buf, ex["sha256"].encode())
    # Gathered files are signed alongside the exhibits — a signature over
    # the document but not the files beside it would let someone swap an
    # invoice. Count first, so a truncated list cannot pass.
    attachments = m.get("attachments", [])
    frame(buf, b"attachments")
    frame(buf, str(len(attachments)).encode())
    for a in attachments:
        frame(buf, str(a["exhibitN"]).encode())
        frame(buf, a["relativePath"].encode())
        frame(buf, str(a["size"]).encode())
        frame(buf, a["sha256"].encode())
    return bytes(buf)


def verify_signature(msg: bytes, sig_b64: str, pub: bytes) -> bool:
    """Ed25519 check via whichever library is installed."""
    sig = base64.b64decode(sig_b64)
    try:
        from nacl.signing import VerifyKey  # type: ignore
        from nacl.exceptions import BadSignatureError  # type: ignore

        try:
            VerifyKey(pub).verify(msg, sig)
            return True
        except BadSignatureError:
            return False
    except ImportError:
        pass
    try:
        from cryptography.exceptions import InvalidSignature  # type: ignore
        from cryptography.hazmat.primitives.asymmetric.ed25519 import (  # type: ignore
            Ed25519PublicKey,
        )

        try:
            Ed25519PublicKey.from_public_bytes(pub).verify(sig, msg)
            return True
        except InvalidSignature:
            return False
    except ImportError:
        raise SystemExit(
            "signature check needs `pip install pynacl` (or `cryptography`); "
            "the hash checks above ran without it"
        )


def load_pub(path: str) -> bytes:
    """Read a public key as raw 32 bytes, base64, or hex."""
    raw = open(path, "rb").read().strip()
    if len(raw) == 32:
        return raw
    text = raw.decode("ascii", "ignore").strip().split()[-1]
    for decode in (base64.b64decode, bytes.fromhex):
        try:
            out = decode(text)
            if len(out) == 32:
                return out
        except Exception:
            continue
    raise SystemExit(f"could not read a 32-byte Ed25519 public key from {path}")


def main() -> int:
    if len(sys.argv) < 3:
        print(__doc__)
        return 2
    docx_path, manifest_path = sys.argv[1], sys.argv[2]
    pub_path = sys.argv[3] if len(sys.argv) > 3 else None

    manifest = json.load(open(manifest_path))
    ok = True
    # The manifest carries its own public key, because the signing key is
    # generated per-install and there is nowhere central to look it up.
    embedded_pub = manifest.get("publicKey", "")

    # 1. Shape.
    if manifest.get("version") != 1:
        print(f"FAIL  manifest version {manifest.get('version')!r}, this tool reads version 1")
        return 1
    n_declared = manifest["exhibitCount"]
    n_actual = len(manifest["exhibits"])
    if n_declared != n_actual:
        print(f"FAIL  exhibitCount says {n_declared}, but {n_actual} exhibits are listed")
        ok = False
    else:
        print(f"ok    manifest v1, {n_actual} exhibits, generated {manifest['generatedAt']}")

    # 2. Document hash. On its own this proves only that the two files
    # agree — check 5 is what makes it mean anything.
    actual = hashlib.sha256(open(docx_path, "rb").read()).hexdigest()
    expected = manifest.get("documentSha256", "")
    if not expected:
        print("warn  manifest carries no documentSha256 — cannot bind it to this file")
    elif actual == expected:
        print(f"ok    document matches the manifest ({actual[:16]}…)")
    else:
        print("FAIL  document does NOT match the manifest — it has been modified")
        print(f"      manifest: {expected}")
        print(f"      this file: {actual}")
        ok = False

    # 3. Digest sanity.
    seen = {}
    for ex in manifest["exhibits"]:
        h = ex["sha256"]
        if len(h) != 64 or any(c not in "0123456789abcdef" for c in h):
            print(f"FAIL  exhibit {ex['n']} has a malformed digest")
            ok = False
        if h in seen:
            print(f"warn  exhibits {seen[h]} and {ex['n']} are byte-identical")
        seen[h] = ex["n"]

    # 3b. Gathered files, when the manifest lists any.
    attachments = manifest.get("attachments", [])
    if attachments:
        base = os.path.dirname(os.path.abspath(docx_path))
        missing, bad = 0, 0
        for a in attachments:
            fp = os.path.join(base, a["relativePath"])
            if not os.path.isfile(fp):
                print(f"FAIL  attachment missing from the folder: {a['relativePath']}")
                missing += 1
                continue
            got = hashlib.sha256(open(fp, "rb").read()).hexdigest()
            if got != a["sha256"]:
                print(f"FAIL  attachment does NOT match its fingerprint: {a['relativePath']}")
                bad += 1
        if missing or bad:
            ok = False
        else:
            print(f"ok    {len(attachments)} attachment(s) present and matching")

    # 4. Signature.
    status = manifest.get("signatureStatus")
    signature_checked = False
    if status != "device-signed":
        print(f"warn  not signed ({status}) — provenance cannot be confirmed")
    elif not pub_path and not embedded_pub:
        print("warn  signed, but no public key available — pass one as the third argument")
        print(f"      key fingerprint in manifest: {manifest.get('keyFingerprint')}")
    else:
        if pub_path:
            pub = load_pub(pub_path)
            source = pub_path
        else:
            pub = base64.b64decode(embedded_pub)
            source = "the key embedded in the manifest (self-signed)"
        if verify_signature(canonical_manifest_bytes(manifest), manifest["signature"], pub):
            print(f"ok    exhibit-list signature verifies against {source}")
            signature_checked = True
        else:
            print("FAIL  exhibit-list signature does NOT verify — the list has been altered")
            ok = False

        # 5. Document signature. Without this the recorded hash is
        # unsigned, and a tamperer holding both files could simply
        # rewrite it to match a forged document.
        doc_sig = manifest.get("documentSignature", "")
        if not doc_sig:
            print("warn  no documentSignature — the document hash above is unsigned,")
            print("      so it does not prove the document is the one Haven produced")
            signature_checked = False
        elif verify_signature(canonical_document_bytes(manifest), doc_sig, pub):
            print("ok    document signature verifies — this is the file Haven produced")
        else:
            print("FAIL  document signature does NOT verify — the document or its")
            print("      recorded hash has been altered")
            ok = False

    print()
    if not ok:
        print("FAILED")
        print("Do not rely on this document — a check above did not pass.")
    elif signature_checked:
        print("PASSED")
        print(
            "The document, its exhibits and its files were signed together and none\n"
            "has been altered since."
        )
        if not pub_path:
            print(
                "\nNote: verified against the key inside the manifest, so this shows\n"
                "integrity, not identity. It is a self-signed record — the key belongs\n"
                "to whoever produced it, not to Haven."
            )
        print("It does not attest that the underlying messages are genuine.")
    else:
        # The hash checks alone say the file matches its manifest. They say
        # nothing about who produced that manifest, so don't imply they do.
        print("PASSED (hash checks only)")
        print(
            "The document matches its manifest, but the signature was NOT verified,\n"
            "so nothing here establishes who produced it. Re-run with the public key."
        )
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
