#!/usr/bin/env node
/**
 * Offline verifier for Presence decision dossiers.
 *
 * Usage:
 *   node verify-dossier.mjs bundle.json                  # fetches the JWKS named in the bundle
 *   node verify-dossier.mjs bundle.json --jwks jwks.json # fully offline, no network
 *
 * Checks two things, independently:
 *   1. chain:     sha256( hexDecode(previous_chain_hash) || canonicalJson(sealed_payload) )
 *                 equals chain_hash — the record was not altered.
 *   2. signature: Ed25519 over canonicalJson({ chain_hash, payload: sealed_payload })
 *                 verifies against the JWK whose kid is public_key_id — Presence signed it.
 *
 * Canonical JSON = recursively key-sorted JSON.stringify. This script exists
 * because reproducing those bytes with shell tools is the error-prone step:
 * jq's number formatting is not guaranteed to match JavaScript's.
 *
 * Exit codes: 0 verified · 1 not verified · 2 usage or I/O error.
 * No dependencies beyond node:crypto. Audit it; that is the point.
 */
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

export function canonicalJson(value) {
  return JSON.stringify(normalize(value));
}

function normalize(value) {
  if (Array.isArray(value)) {
    return value.map(normalize);
  }
  if (value !== null && typeof value === "object") {
    const sorted = {};
    for (const key of Object.keys(value).sort()) {
      sorted[key] = normalize(value[key]);
    }
    return sorted;
  }
  return value;
}

export function verifyChain(bundle) {
  const computed = createHash("sha256")
    .update(Buffer.from(bundle.previous_chain_hash, "hex"))
    .update(canonicalJson(bundle.sealed_payload))
    .digest("hex");
  return computed === bundle.chain_hash;
}

export function verifySignature(bundle, jwks) {
  const jwk = (jwks.keys ?? []).find((key) => key.kid === bundle.public_key_id);
  if (jwk === undefined) {
    throw new Error(`JWKS has no key with kid ${bundle.public_key_id}`);
  }
  const prefix = "ed25519:";
  if (!bundle.signature.startsWith(prefix)) {
    return false;
  }
  const signedBytes = Buffer.from(
    canonicalJson({ chain_hash: bundle.chain_hash, payload: bundle.sealed_payload }),
  );
  return cryptoVerify(
    null,
    signedBytes,
    createPublicKey({ key: jwk, format: "jwk" }),
    Buffer.from(bundle.signature.slice(prefix.length), "base64"),
  );
}

async function main(argv) {
  const args = argv.slice(2);
  const jwksFlag = args.indexOf("--jwks");
  const jwksPath = jwksFlag === -1 ? null : args[jwksFlag + 1];
  const bundlePath = args.filter((arg, i) => arg !== "--jwks" && i !== jwksFlag + 1)[0];
  if (bundlePath === undefined || (jwksFlag !== -1 && jwksPath === undefined)) {
    console.error("usage: node verify-dossier.mjs <bundle.json> [--jwks <jwks.json>]");
    return 2;
  }

  let bundle, jwks;
  try {
    bundle = JSON.parse(readFileSync(bundlePath, "utf8"));
    if (jwksPath !== null) {
      jwks = JSON.parse(readFileSync(jwksPath, "utf8"));
    } else {
      const response = await fetch(bundle.public_keys_url);
      if (!response.ok) {
        throw new Error(`fetching ${bundle.public_keys_url} returned ${response.status}`);
      }
      jwks = await response.json();
    }
  } catch (error) {
    console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
    return 2;
  }

  const chainOk = verifyChain(bundle);
  let signatureOk = false;
  try {
    signatureOk = verifySignature(bundle, jwks);
  } catch (error) {
    console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
    return 2;
  }

  console.log(`dossier    ${bundle.dossier_id}`);
  console.log(`chain      ${chainOk ? "VALID — record not altered" : "INVALID — payload does not match chain_hash"}`);
  console.log(`signature  ${signatureOk ? `VALID — signed by ${bundle.public_key_id}` : "INVALID — not signed by the published key"}`);
  console.log(chainOk && signatureOk ? "verified" : "NOT VERIFIED");
  return chainOk && signatureOk ? 0 : 1;
}

if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
  process.exitCode = await main(process.argv);
}
