diff --git a/README.md b/README.md index 9265ad3..13deb14 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,65 @@ # PrintToVeraeTimeStamp -A virtual printer for macOS, Linux, and Windows. Instead of (or in addition to) printing, it captures the print job as PDF, computes its SHA-256, timestamps that digest with the Verae Timestamping service, and lets you save or print the document together with its Verae timestamp certificate. +macOS Print → PDF helper that saves `{name}.pdf`, stamps the digest with Verae, and writes `{name}-verae-timestamp-{RECEIPTID}.pdf`. -Only the digest is sent to Verae. The document never leaves your machine. +Only the SHA-256 digest is sent to Verae. Document bytes never leave the machine. + +## Install + +```bash +cd /Users/marchon/research/PrintToVeraeTimeStamp +go build -o "$HOME/bin/verae-print" ./cmd/verae-print +verae-print install-macos +``` + +Ensure `$HOME/bin` is on your `PATH`. Refresh Verae Time login when the JWT expires: + +```bash +uvx verae-mcp-timestamping-server setup +# or session check: +uvx verae-mcp-timestamping-server session +``` + +For the Zapier channel, store an `xVerae-` token from [customer.verae.ai](https://customer.verae.ai/portal/) / [zap.verae.ai](https://zap.verae.ai/portal/) Security tab: + +```bash +mkdir -p ~/.verae +chmod 700 ~/.verae +printenv XVERAE_TOKEN > ~/.verae/xverae-token # or paste once +chmod 600 ~/.verae/xverae-token +``` + +## Usage + +In any app: **File → Print → PDF → Timestamp with Verae** (or **Timestamp with Verae (Zapier)**). + +CLI: + +```bash +verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] file.pdf +``` + +Default output directory: `~/Documents/Verae Timestamps/` + +| File | Content | +|------|---------| +| `{name}.pdf` | Exact printed bytes (proven artifact) | +| `{name}-verae-timestamp-{RECEIPTID}.pdf` | Registration / certificate PDF | +| `{name}.verae.json` | Receipt sidecar | + +Channels: + +- `verae` (default) — `api.veraetime.net` + JWT in `~/.verae/auth.properties` +- `zapier` — `zap.verae.ai` + `xVerae-` + `attachPdf:true` ## Status -Design stage. See: +macOS v0 (PDF Services) is implemented. CUPS virtual printer, Linux, and Windows remain per the [spec](docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md) and [plan](docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md). -- [Spec](docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md) -- [Implementation plan](docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md) +## Contributing -## How it works (short) - -``` -Any app → Print → "Verae Timestamp" printer - → job captured as PDF (CUPS on macOS/Linux, IPP/PDF port on Windows) - → per-user helper: SHA-256 (whole document + per page) - → Verae Timestamp API (digest only) → receipt JSON / certificate - → Save: original PDF + receipt (+ optional certificate page / embedded receipt) - → Optional: forward to a real printer with certificate page -``` +Open questions and design changes in this repository. Pull requests are accepted for review against the spec. ## License -TBD. +UNLICENSED © Verae / George Lambert diff --git a/cmd/verae-print/main.go b/cmd/verae-print/main.go new file mode 100644 index 0000000..406a963 --- /dev/null +++ b/cmd/verae-print/main.go @@ -0,0 +1,221 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "stamp": + os.Exit(runStamp(os.Args[2:])) + case "install-macos": + os.Exit(runInstallMacOS(os.Args[2:])) + case "version", "-version", "--version": + fmt.Println(job.HelperVersion) + case "help", "-h", "--help": + usage() + default: + // PDF Services may invoke the binary with the PDF path as argv[1] + if strings.HasSuffix(strings.ToLower(os.Args[1]), ".pdf") && fileExists(os.Args[1]) { + os.Exit(runStamp([]string{"-channel", envOr("VERAE_PRINT_CHANNEL", "verae"), os.Args[1]})) + } + fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1]) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `verae-print %s — stamp a printed PDF with Verae (digests only) + +Usage: + verae-print stamp [--channel=verae|zapier] [--out-dir DIR] [--name STEM] [--open] + verae-print install-macos # install PDF Services menu items + verae-print version + +Channels: + verae (default) api.veraetime.net + ~/.verae/auth.properties JWT + zapier zap.verae.ai + xVerae- token (XVERAE_TOKEN / ~/.verae/xverae-token / Keychain) + +Outputs in --out-dir (default ~/Documents/Verae Timestamps): + {name}.pdf + {name}-verae-timestamp-{RECEIPTID}.pdf + {name}.verae.json +`, job.HelperVersion) +} + +func runStamp(args []string) int { + fs := flag.NewFlagSet("stamp", flag.ExitOnError) + channel := fs.String("channel", envOr("VERAE_PRINT_CHANNEL", "verae"), "verae | zapier") + outDir := fs.String("out-dir", "", "output directory") + name := fs.String("name", "", "document stem override") + openFolder := fs.Bool("open", false, "open output folder on success (macOS)") + _ = fs.Parse(args) + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "stamp: PDF path required") + return 2 + } + pdfPath := fs.Arg(0) + + ch, err := auth.ParseChannel(*channel) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + + client := &veraeapi.Client{Channel: ch} + switch ch { + case auth.ChannelZapier: + tok, err := auth.LoadXVeraeToken() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + client.XVeraeToken = tok + default: + tok, err := auth.LoadVeraeJWT() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + client.VeraeToken = tok + } + + res, err := job.Run(job.Options{ + SourcePDF: pdfPath, + OutDir: *outDir, + DocumentName: *name, + Channel: ch, + Client: client, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "stamp failed: %v\n", err) + return 1 + } + + fmt.Printf("Saved:\n %s\n %s\n %s\nReceipt: %s\nSHA-256: %s\nChannel: %s\n", + res.OriginalPDF, res.CertificatePDF, res.ReceiptJSON, res.ReceiptID, res.SHA256, res.Channel) + + if *openFolder { + _ = exec.Command("open", filepath.Dir(res.OriginalPDF)).Run() + } + // macOS notification (best-effort) + notify(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF) + return 0 +} + +func runInstallMacOS(args []string) int { + fs := flag.NewFlagSet("install-macos", flag.ExitOnError) + bin := fs.String("bin", "", "path to verae-print binary (default: this executable)") + _ = fs.Parse(args) + + exe := *bin + if exe == "" { + var err error + exe, err = os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + exe, _ = filepath.EvalSymlinks(exe) + } + + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + destDir := filepath.Join(home, "Library", "PDF Services") + if err := os.MkdirAll(destDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + + items := []struct { + name string + channel string + }{ + {"Timestamp with Verae", "verae"}, + {"Timestamp with Verae (Zapier)", "zapier"}, + } + for _, it := range items { + appDir := filepath.Join(destDir, it.name+".app", "Contents", "MacOS") + if err := os.MkdirAll(appDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + plist := filepath.Join(destDir, it.name+".app", "Contents", "Info.plist") + plistBody := fmt.Sprintf(` + + + CFBundleExecutablerunner + CFBundleIdentifierai.verae.print.%s + CFBundleName%s + CFBundlePackageTypeAPPL + CFBundleShortVersionString%s + +`, strings.ReplaceAll(it.channel, " ", ""), it.name, job.HelperVersion) + if err := os.WriteFile(plist, []byte(plistBody), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + runner := filepath.Join(appDir, "runner") + script := fmt.Sprintf(`#!/bin/bash +set -euo pipefail +LOG="${HOME}/Library/Logs/verae-print.log" +mkdir -p "$(dirname "$LOG")" +exec >>"$LOG" 2>&1 +echo "$(date -u +%%Y-%%m-%%dT%%H:%%MZ) channel=%s args: $*" +PDF="" +for a in "$@"; do + case "$a" in + *.pdf|*.PDF) PDF="$a" ;; + esac +done +if [[ -z "${PDF}" && $# -ge 1 ]]; then PDF="$1"; fi +if [[ -z "${PDF}" || ! -f "${PDF}" ]]; then + osascript -e 'display notification "No PDF received from Print dialog" with title "Verae Timestamp"' || true + exit 1 +fi +%q stamp -channel %s -open "$PDF" +`, it.channel, exe, it.channel) + if err := os.WriteFile(runner, []byte(script), 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Println("Installed:", filepath.Join(destDir, it.name+".app")) + } + fmt.Println("Print → PDF → Timestamp with Verae (or Zapier)") + fmt.Println("Binary:", exe) + return 0 +} + +func notify(title, subtitle string) { + script := fmt.Sprintf(`display notification %q with title "Verae Timestamp" subtitle %q`, subtitle, title) + _ = exec.Command("osascript", "-e", script).Run() +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func fileExists(p string) bool { + st, err := os.Stat(p) + return err == nil && !st.IsDir() +} diff --git a/docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md b/docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md index 1742e47..91ef2c9 100644 --- a/docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md +++ b/docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md @@ -53,7 +53,10 @@ - [ ] Commit `feat(pdf): certificate page and embedded receipt`. ### Task 5: macOS v0 — PDF Services -- [ ] App bundle in `~/Library/PDF Services/` calling the core; Save/Print/Both dialog. +- [x] CLI `verae-print stamp` writes `{name}.pdf`, `{name}-verae-timestamp-{RECEIPTID}.pdf`, `{name}.verae.json`. +- [x] Dual channel: `verae` (api.veraetime.net JWT) and `zapier` (zap.verae.ai xVerae- + attachPdf). +- [x] `verae-print install-macos` installs PDF Services apps (Verae + Zapier). +- [ ] Optional Save/Print/Both dialog (v0 auto-saves to `~/Documents/Verae Timestamps`). - [ ] Commit `feat(macos): PDF Services entry`. ### Task 6: Spool protocol + helper diff --git a/docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md b/docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md index 1d6c39c..95db0af 100644 --- a/docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md +++ b/docs/superpowers/specs/2026-10-03-print-to-verae-timestamp.md @@ -26,7 +26,9 @@ Provide a "Verae Timestamp" printer on macOS, Linux, and Windows. Printing to it - **Embedding without breaking the hash.** If the receipt is embedded, it is appended as a PDF *incremental update* (attachment + annotation/certificate page). The original byte range is unchanged, so `sha256(first N bytes)` still matches. The receipt records `N`. - **Per-page hashes.** Each page is extracted as a single-page PDF (deterministic extraction via the same library/version) and hashed; the list is included in a canonical manifest whose SHA-256 is also stamped. This allows proving individual pages. -## 4. Verae Timestamp API (as used today by Datacube-Fuse) +## 4. Verae Timestamp API (dual channel) + +### 4.1 Verae Time (default — non-Zapier) | Call | Purpose | |---|---| @@ -35,8 +37,18 @@ Provide a "Verae Timestamp" printer on macOS, Linux, and Windows. Printing to it | `GET {base}/api/hash/{digest}` | Look up existing stamp | | `POST {base}/api/verify` | Verify | -- Default base: `https://api.veraetime.net`; `Authorization: Bearer ` from `~/.verae/auth.json` (keys `access_token` / `auth_token` / `id_token`). -- **Open question:** endpoint/format for a downloadable certificate PDF. Until confirmed, the helper renders its own certificate page from the receipt JSON (digest, time, certificate id, verify URL, QR code). +- Default base: `https://api.veraetime.net` +- Auth: `Authorization: Bearer ` from `~/.verae/auth.properties` (keys `access_token` / `auth_token` / `id_token`) +- Certificate PDF: render locally from receipt when the API does not return a PDF body + +### 4.2 Zapier / edge channel + +| Call | Purpose | +|---|---| +| `POST https://zap.verae.ai/v1/timestamp` body `{sha256, attachPdf:true, wait:true, documentName}` | Stamp + optional registration PDF | + +- Auth: `x-api-key: xVerae-…` (or Bearer) from Keychain / `~/.verae/xverae-token` / env `XVERAE_TOKEN` +- Certificate PDF: decode `pdfBase64` from the JSON response (or raw PDF when `Accept: application/pdf`) ## 5. Architecture @@ -60,7 +72,10 @@ App ──▶ Print │ macOS/Linux: CUPS queue "Verae Timestamp" │ ### 5.1 macOS -- **v0 (fast path):** PDF Services entry — "Timestamp with Verae" in the Print dialog's PDF menu. Installed to `~/Library/PDF Services/` as an app that receives the PDF path. No driver needed. +- **v0 (fast path):** PDF Services entry — "Timestamp with Verae" in the Print dialog's PDF menu. Installed to `~/Library/PDF Services/` as an app/script that receives the PDF path. No CUPS driver needed. + - Default channel: **Verae Time** (`api.veraetime.net` + MCP JWT from `~/.verae/auth.properties`). + - Optional second menu item **Timestamp with Verae (Zapier)** or `--channel=zapier`: `zap.verae.ai` + `xVerae-` token + `attachPdf: true`. + - Saves to `~/Documents/Verae Timestamps/` by default (override with `--out-dir` / preference). - **v1:** CUPS queue with custom backend `/usr/libexec/cups/backend/verae` + PPD (or IPP Everywhere). The backend is sandboxed and cannot show UI, so it writes to `/Library/Application Support/Verae/spool//` and exits; the menu-bar helper (LaunchAgent) picks it up. - Signed + notarized `.pkg`. @@ -85,16 +100,21 @@ A single local **IPP Everywhere printer service** (e.g. built on PAPPL or a Go I ## 6. Outputs -For a job named `Contract.pdf`: +For a print job whose document name is `Contract` (extension stripped for the stem): | File | Content | |---|---| | `Contract.pdf` | Exact captured bytes (the proven artifact) | -| `Contract.verae.json` | Receipt: doc sha256, page sha256 list, manifest sha256, Verae response(s), time, base URL, helper version, original byte length | -| `Contract.stamped.pdf` (optional) | Original bytes + incremental update containing certificate page and `Contract.verae.json` as attachment | -| `Contract.certificate.pdf` (optional) | Certificate page alone (from API if available, else rendered) | +| `Contract-verae-timestamp-{RECEIPTID}.pdf` | Verae registration / certificate PDF only | +| `Contract.verae.json` | Receipt sidecar: doc sha256, receipt id, channel, API host, time, helper version, original byte length | -Certificate page: document name, SHA-256, Verae timestamp time (UTC + local), certificate/receipt id, verify URL + QR code, page-hash list (first N, rest in attachment). +`RECEIPTID` is the Verae receipt / certificate CID, sanitized for filenames (safe charset). Long CIDs are kept in full when under filesystem limits. + +**macOS v0 naming is fixed to the table above** (approved 2026-10-03). Later optional embed/combined outputs may still use `.stamped.pdf` but are out of scope for v0. + +Certificate PDF source by channel: +- **Zapier channel:** `pdfBase64` from `POST https://zap.verae.ai/v1/timestamp` with `attachPdf: true`. +- **Verae Time / default:** stamp via `api.veraetime.net`; use API/MCP registration PDF when available, otherwise render a local certificate page from the receipt JSON (document name, SHA-256, time UTC + local, receipt id, verify URL). ## 7. Settings diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8727af7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/VeraeLLC/PrintToVeraeTimeStamp + +go 1.26.1 diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..c794b3e --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,94 @@ +package auth + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Channel selects which stamp API to use. +type Channel string + +const ( + ChannelVerae Channel = "verae" // api.veraetime.net + MCP JWT + ChannelZapier Channel = "zapier" // zap.verae.ai + xVerae- +) + +// ParseChannel maps CLI/env values to a Channel. +func ParseChannel(s string) (Channel, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "verae", "veraetime", "mcp", "default": + return ChannelVerae, nil + case "zapier", "zap", "edge": + return ChannelZapier, nil + default: + return "", fmt.Errorf("unknown channel %q (want verae or zapier)", s) + } +} + +// LoadVeraeJWT reads Bearer token from ~/.verae/auth.properties +// (access_token, auth_token, or id_token). +func LoadVeraeJWT() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path := filepath.Join(home, ".verae", "auth.properties") + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + vals := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + vals[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + if err := sc.Err(); err != nil { + return "", err + } + for _, key := range []string{"access_token", "auth_token", "id_token"} { + if t := vals[key]; t != "" { + return t, nil + } + } + return "", fmt.Errorf("no access_token/auth_token/id_token in %s", path) +} + +// LoadXVeraeToken loads a long-lived xVerae- token from, in order: +// XVERAE_TOKEN env, ~/.verae/xverae-token file, macOS Keychain service verae.xVeraeToken. +func LoadXVeraeToken() (string, error) { + if t := strings.TrimSpace(os.Getenv("XVERAE_TOKEN")); t != "" { + return t, nil + } + home, err := os.UserHomeDir() + if err == nil { + path := filepath.Join(home, ".verae", "xverae-token") + if b, err := os.ReadFile(path); err == nil { + t := strings.TrimSpace(string(b)) + if t != "" { + return t, nil + } + } + } + // macOS Keychain (generic password, account optional) + out, err := exec.Command("security", "find-generic-password", "-s", "verae.xVeraeToken", "-w").Output() + if err == nil { + t := strings.TrimSpace(string(out)) + if t != "" { + return t, nil + } + } + return "", fmt.Errorf("no xVerae- token (set XVERAE_TOKEN, ~/.verae/xverae-token, or Keychain verae.xVeraeToken)") +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..9ada3a8 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseChannel(t *testing.T) { + c, err := ParseChannel("zapier") + if err != nil || c != ChannelZapier { + t.Fatalf("%v %v", c, err) + } + c, err = ParseChannel("") + if err != nil || c != ChannelVerae { + t.Fatalf("%v %v", c, err) + } +} + +func TestLoadVeraeJWT(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".verae") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "auth.properties") + body := "# test\naccess_token=tok-abc\nid_token=other\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := LoadVeraeJWT() + if err != nil || got != "tok-abc" { + t.Fatalf("got %q err %v", got, err) + } +} diff --git a/internal/hash/hash.go b/internal/hash/hash.go new file mode 100644 index 0000000..c1a8548 --- /dev/null +++ b/internal/hash/hash.go @@ -0,0 +1,39 @@ +package hash + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "strings" +) + +// FileSHA256 returns the lowercase hex SHA-256 of the file at path. +func FileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// NormalizeDigest lowercases and strips an optional sha256: prefix. +func NormalizeDigest(d string) (string, error) { + s := strings.TrimSpace(strings.ToLower(d)) + s = strings.TrimPrefix(s, "sha256:") + if len(s) != 64 { + return "", fmt.Errorf("digest must be 64 hex chars, got %d", len(s)) + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return "", fmt.Errorf("digest is not hex") + } + } + return s, nil +} diff --git a/internal/hash/hash_test.go b/internal/hash/hash_test.go new file mode 100644 index 0000000..f00d099 --- /dev/null +++ b/internal/hash/hash_test.go @@ -0,0 +1,37 @@ +package hash + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFileSHA256(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "a.pdf") + if err := os.WriteFile(p, []byte("%PDF-1.4 test"), 0o600); err != nil { + t.Fatal(err) + } + got, err := FileSHA256(p) + if err != nil { + t.Fatal(err) + } + if len(got) != 64 { + t.Fatalf("len=%d", len(got)) + } + again, err := FileSHA256(p) + if err != nil || again != got { + t.Fatalf("not deterministic: %v %s vs %s", err, got, again) + } +} + +func TestNormalizeDigest(t *testing.T) { + const hex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + got, err := NormalizeDigest("SHA256:" + hex64) + if err != nil || got != hex64 { + t.Fatalf("got %q err %v", got, err) + } + if _, err := NormalizeDigest("short"); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/job/job.go b/internal/job/job.go new file mode 100644 index 0000000..31e93b2 --- /dev/null +++ b/internal/job/job.go @@ -0,0 +1,179 @@ +package job + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/hash" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/pdfcert" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" +) + +const HelperVersion = "0.1.0" + +// Result paths written for one print job. +type Result struct { + OriginalPDF string + CertificatePDF string + ReceiptJSON string + ReceiptID string + SHA256 string + Channel auth.Channel +} + +// Options for processing a captured print PDF. +type Options struct { + SourcePDF string // temp PDF from macOS PDF Services + OutDir string + DocumentName string // optional override for stem; default from source basename + Channel auth.Channel + Client *veraeapi.Client +} + +var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +// SanitizeReceiptID makes a receipt id safe for filenames. +func SanitizeReceiptID(id string) string { + id = strings.TrimSpace(id) + id = unsafeName.ReplaceAllString(id, "-") + id = strings.Trim(id, "-.") + if id == "" { + id = "unknown" + } + // Keep under common NAME_MAX budget with prefix + const max = 180 + if len(id) > max { + id = id[:max] + } + return id +} + +// StemFromPath returns the document stem without .pdf. +func StemFromPath(path, override string) string { + if override != "" { + s := strings.TrimSuffix(override, filepath.Ext(override)) + return unsafeName.ReplaceAllString(s, "-") + } + base := filepath.Base(path) + stem := strings.TrimSuffix(base, filepath.Ext(base)) + stem = unsafeName.ReplaceAllString(stem, "-") + stem = strings.Trim(stem, "-.") + if stem == "" { + stem = "document" + } + return stem +} + +// DefaultOutDir is ~/Documents/Verae Timestamps. +func DefaultOutDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Documents", "Verae Timestamps"), nil +} + +// Run copies the source PDF, stamps digests-only, writes certificate + JSON. +func Run(opt Options) (Result, error) { + if opt.SourcePDF == "" { + return Result{}, fmt.Errorf("source PDF required") + } + if opt.Client == nil { + return Result{}, fmt.Errorf("client required") + } + outDir := opt.OutDir + if outDir == "" { + d, err := DefaultOutDir() + if err != nil { + return Result{}, err + } + outDir = d + } + if err := os.MkdirAll(outDir, 0o700); err != nil { + return Result{}, err + } + + stem := StemFromPath(opt.SourcePDF, opt.DocumentName) + origPath := filepath.Join(outDir, stem+".pdf") + srcBytes, err := os.ReadFile(opt.SourcePDF) + if err != nil { + return Result{}, err + } + if err := os.WriteFile(origPath, srcBytes, 0o600); err != nil { + return Result{}, err + } + + digest, err := hash.FileSHA256(origPath) + if err != nil { + return Result{}, err + } + + client := *opt.Client + client.Channel = opt.Channel + rec, err := client.Stamp(digest, stem+".pdf") + if err != nil { + return Result{}, err + } + if rec.ReceiptID == "" { + return Result{}, fmt.Errorf("stamp returned empty receipt id") + } + + safeID := SanitizeReceiptID(rec.ReceiptID) + certPath := filepath.Join(outDir, fmt.Sprintf("%s-verae-timestamp-%s.pdf", stem, safeID)) + + pdfBytes := rec.PDF + if len(pdfBytes) == 0 { + pdfBytes, err = pdfcert.Build(pdfcert.Input{ + DocumentName: stem + ".pdf", + SHA256: digest, + ReceiptID: rec.ReceiptID, + CreatedAt: rec.CreatedAt, + Merkle: rec.Merkle, + Channel: string(opt.Channel), + }) + if err != nil { + return Result{}, err + } + } + if err := os.WriteFile(certPath, pdfBytes, 0o600); err != nil { + return Result{}, err + } + + sidecar := map[string]any{ + "helper_version": HelperVersion, + "document_name": stem + ".pdf", + "sha256": digest, + "receipt_id": rec.ReceiptID, + "created_at": rec.CreatedAt, + "merkle": rec.Merkle, + "channel": string(opt.Channel), + "base_url": rec.BaseURL, + "original_pdf": filepath.Base(origPath), + "certificate_pdf": filepath.Base(certPath), + "byte_length": len(srcBytes), + "saved_at": time.Now().UTC().Format(time.RFC3339), + } + jsonPath := filepath.Join(outDir, stem+".verae.json") + jb, err := json.MarshalIndent(sidecar, "", " ") + if err != nil { + return Result{}, err + } + if err := os.WriteFile(jsonPath, jb, 0o600); err != nil { + return Result{}, err + } + + return Result{ + OriginalPDF: origPath, + CertificatePDF: certPath, + ReceiptJSON: jsonPath, + ReceiptID: rec.ReceiptID, + SHA256: digest, + Channel: opt.Channel, + }, nil +} diff --git a/internal/job/job_test.go b/internal/job/job_test.go new file mode 100644 index 0000000..1dec275 --- /dev/null +++ b/internal/job/job_test.go @@ -0,0 +1,96 @@ +package job + +import ( + "encoding/base64" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" +) + +func TestSanitizeAndStem(t *testing.T) { + if SanitizeReceiptID("bafy/xyz!") != "bafy-xyz" { + t.Fatal(SanitizeReceiptID("bafy/xyz!")) + } + if StemFromPath("/tmp/My Contract (1).pdf", "") != "My-Contract-1" { + t.Fatal(StemFromPath("/tmp/My Contract (1).pdf", "")) + } +} + +func TestRunZapierWritesNamedFiles(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.pdf") + if err := os.WriteFile(src, []byte("%PDF-1.4 hello"), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "out") + pdf := []byte("%PDF-1.4 certificate-bytes") + b64 := base64.StdEncoding.EncodeToString(pdf) + + client := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-x", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body := `{"receipt_id":"bafyReceipt99","pdfBase64":"` + b64 + `"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }, + } + + res, err := Run(Options{ + SourcePDF: src, + OutDir: out, + DocumentName: "Contract.pdf", + Channel: auth.ChannelZapier, + Client: client, + }) + if err != nil { + t.Fatal(err) + } + wantCert := filepath.Join(out, "Contract-verae-timestamp-bafyReceipt99.pdf") + if res.CertificatePDF != wantCert { + t.Fatalf("cert path %s want %s", res.CertificatePDF, wantCert) + } + if _, err := os.Stat(res.OriginalPDF); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(res.CertificatePDF) + if string(got) != string(pdf) { + t.Fatalf("cert bytes mismatch") + } + if _, err := os.Stat(res.ReceiptJSON); err != nil { + t.Fatal(err) + } +} + +func TestRunVeraeRendersLocalCert(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "memo.pdf") + _ = os.WriteFile(src, []byte("%PDF-1.4 memo"), 0o600) + out := filepath.Join(dir, "out") + client := &veraeapi.Client{ + Channel: auth.ChannelVerae, + VeraeBase: "https://api.test", + VeraeToken: "jwt", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body := `{"certificate":"cidLocal1","timestamp":"2026-10-03T12:00:00Z","existing":true}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }, + } + res, err := Run(Options{SourcePDF: src, OutDir: out, Channel: auth.ChannelVerae, Client: client}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(res.CertificatePDF, "memo-verae-timestamp-cidLocal1.pdf") { + t.Fatal(res.CertificatePDF) + } + b, _ := os.ReadFile(res.CertificatePDF) + if !strings.HasPrefix(string(b), "%PDF") { + t.Fatal("not a pdf") + } +} diff --git a/internal/pdfcert/pdfcert.go b/internal/pdfcert/pdfcert.go new file mode 100644 index 0000000..ec5f23f --- /dev/null +++ b/internal/pdfcert/pdfcert.go @@ -0,0 +1,103 @@ +package pdfcert + +import ( + "fmt" + "strings" + "time" +) + +// Input for a simple one-page Verae registration certificate PDF. +type Input struct { + DocumentName string + SHA256 string + ReceiptID string + CreatedAt string + Merkle string + VerifyURL string + Channel string +} + +// Build returns a minimal PDF (no external deps) describing the stamp. +func Build(in Input) ([]byte, error) { + created := in.CreatedAt + if created == "" { + created = time.Now().UTC().Format(time.RFC3339) + } + local := "" + if t, err := time.Parse(time.RFC3339, created); err == nil { + local = t.Local().Format(time.RFC1123) + } + verify := in.VerifyURL + if verify == "" && in.SHA256 != "" { + verify = fmt.Sprintf("https://mcp.verae.ai/register.html?sha256=%s&receipt=%s", in.SHA256, in.ReceiptID) + } + + lines := []string{ + "Verae Timestamp Registration", + "", + "Document: " + sanitize(in.DocumentName), + "SHA-256: " + in.SHA256, + "Receipt ID: " + in.ReceiptID, + "UTC: " + created, + } + if local != "" { + lines = append(lines, "Local: "+local) + } + if in.Merkle != "" { + lines = append(lines, "Merkle/block: "+in.Merkle) + } + if in.Channel != "" { + lines = append(lines, "Channel: "+in.Channel) + } + lines = append(lines, "Verify: "+verify) + lines = append(lines, "", "Digests only — document bytes were not uploaded.") + + return buildTextPDF(lines) +} + +func sanitize(s string) string { + s = strings.ReplaceAll(s, "(", "\\(") + s = strings.ReplaceAll(s, ")", "\\)") + s = strings.ReplaceAll(s, "\\", "\\\\") + return s +} + +// Minimal PDF 1.4 with Helvetica text lines. +func buildTextPDF(lines []string) ([]byte, error) { + var content strings.Builder + content.WriteString("BT /F1 12 Tf 50 750 Td 14 TL\n") + for i, line := range lines { + if i > 0 { + content.WriteString("T*\n") + } + content.WriteString("(") + content.WriteString(sanitize(line)) + content.WriteString(") Tj\n") + } + content.WriteString("ET\n") + stream := content.String() + + objects := []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(stream), stream), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + } + + var body strings.Builder + body.WriteString("%PDF-1.4\n") + offsets := make([]int, len(objects)+1) + for i, obj := range objects { + offsets[i+1] = body.Len() + fmt.Fprintf(&body, "%d 0 obj\n%s\nendobj\n", i+1, obj) + } + xrefStart := body.Len() + fmt.Fprintf(&body, "xref\n0 %d\n", len(objects)+1) + body.WriteString("0000000000 65535 f \n") + for i := 1; i <= len(objects); i++ { + fmt.Fprintf(&body, "%010d 00000 n \n", offsets[i]) + } + fmt.Fprintf(&body, "trailer << /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xrefStart) + return []byte(body.String()), nil +} diff --git a/internal/veraeapi/client.go b/internal/veraeapi/client.go new file mode 100644 index 0000000..1763489 --- /dev/null +++ b/internal/veraeapi/client.go @@ -0,0 +1,296 @@ +package veraeapi + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" +) + +const ( + defaultVeraeBase = "https://api.veraetime.net" + defaultZapierBase = "https://zap.verae.ai" + defaultPoll = 250 * time.Millisecond + defaultMaxPolls = 80 +) + +// Receipt is the stamp result used by the print helper. +type Receipt struct { + ReceiptID string + SHA256 string + CreatedAt string + Merkle string + Channel auth.Channel + BaseURL string + PDF []byte // registration / certificate PDF when available + PDFFilename string + RawResponse json.RawMessage +} + +// Client stamps digests on Verae Time or the Zapier edge. +type Client struct { + Channel auth.Channel + VeraeBase string + ZapierBase string + VeraeToken string // JWT + XVeraeToken string + HTTPDo func(*http.Request) (*http.Response, error) + PollInterval time.Duration + MaxPolls int +} + +func (c *Client) do(req *http.Request) (*http.Response, error) { + if c.HTTPDo != nil { + return c.HTTPDo(req) + } + return http.DefaultClient.Do(req) +} + +func (c *Client) veraeBase() string { + if c.VeraeBase != "" { + return strings.TrimRight(c.VeraeBase, "/") + } + return defaultVeraeBase +} + +func (c *Client) zapierBase() string { + if c.ZapierBase != "" { + return strings.TrimRight(c.ZapierBase, "/") + } + return defaultZapierBase +} + +// Stamp submits digest and waits for a receipt. documentName is for PDF metadata. +func (c *Client) Stamp(digest, documentName string) (Receipt, error) { + switch c.Channel { + case auth.ChannelZapier: + return c.stampZapier(digest, documentName) + default: + return c.stampVerae(digest) + } +} + +func (c *Client) stampVerae(digest string) (Receipt, error) { + base := c.veraeBase() + body, _ := json.Marshal(map[string]any{ + "data": digest, + "digest": digest, + "hashAlg": "SHA256", + "restamp": false, + }) + req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body)) + if err != nil { + return Receipt{}, err + } + req.Header.Set("Content-Type", "application/json") + if c.VeraeToken != "" { + req.Header.Set("Authorization", "Bearer "+c.VeraeToken) + } + res, err := c.do(req) + if err != nil { + return Receipt{}, err + } + defer res.Body.Close() + raw, err := io.ReadAll(res.Body) + if err != nil { + return Receipt{}, err + } + parsed := map[string]any{} + _ = json.Unmarshal(raw, &parsed) + + if res.StatusCode == http.StatusOK { + cid := str(parsed["certificate"]) + if cid == "" { + cid = str(parsed["receipt_id"]) + } + if cid != "" || truthy(parsed["existing"]) { + if cid == "" { + cid = str(parsed["certificate"]) + } + return Receipt{ + ReceiptID: cid, + SHA256: digest, + CreatedAt: str(parsed["timestamp"]), + Merkle: str(parsed["blockHash"]), + Channel: auth.ChannelVerae, + BaseURL: base, + RawResponse: raw, + }, nil + } + } + if res.StatusCode >= 300 { + return Receipt{}, fmt.Errorf("verae stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400)) + } + + jobID := str(parsed["jobId"]) + if jobID == "" { + return Receipt{}, fmt.Errorf("verae stamp: missing jobId in %s", truncate(raw, 400)) + } + return c.pollVerae(base, digest, jobID, raw) +} + +func (c *Client) pollVerae(base, digest, jobID string, firstRaw []byte) (Receipt, error) { + interval := c.PollInterval + if interval <= 0 { + interval = defaultPoll + } + max := c.MaxPolls + if max <= 0 { + max = defaultMaxPolls + } + statusURL := base + "/api/status/" + jobID + var last []byte = firstRaw + for i := 0; i < max; i++ { + time.Sleep(interval) + req, err := http.NewRequest(http.MethodGet, statusURL, nil) + if err != nil { + return Receipt{}, err + } + if c.VeraeToken != "" { + req.Header.Set("Authorization", "Bearer "+c.VeraeToken) + } + res, err := c.do(req) + if err != nil { + return Receipt{}, err + } + raw, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return Receipt{}, err + } + last = raw + parsed := map[string]any{} + _ = json.Unmarshal(raw, &parsed) + st := strings.ToLower(str(parsed["status"])) + if st == "failed" || st == "error" { + return Receipt{}, fmt.Errorf("verae job %s failed: %s", jobID, truncate(raw, 400)) + } + cid := str(parsed["receipt_id"]) + if cid == "" { + cid = str(parsed["certificate"]) + } + if cid == "" { + if r, ok := parsed["result"].(map[string]any); ok { + cid = str(r["receipt_id"]) + if cid == "" { + cid = str(r["certificate"]) + } + } + } + if cid != "" || st == "completed" || st == "done" { + if cid == "" { + cid = jobID + } + return Receipt{ + ReceiptID: cid, + SHA256: digest, + CreatedAt: firstNonEmpty(str(parsed["timestamp"]), str(parsed["created_at"]), time.Now().UTC().Format(time.RFC3339)), + Merkle: firstNonEmpty(str(parsed["blockHash"]), str(parsed["merkle"])), + Channel: auth.ChannelVerae, + BaseURL: base, + RawResponse: last, + }, nil + } + } + return Receipt{}, fmt.Errorf("verae job %s timed out; last=%s", jobID, truncate(last, 400)) +} + +func (c *Client) stampZapier(digest, documentName string) (Receipt, error) { + base := c.zapierBase() + body, _ := json.Marshal(map[string]any{ + "sha256": digest, + "attachPdf": true, + "wait": true, + "documentName": documentName, + }) + req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body)) + if err != nil { + return Receipt{}, err + } + req.Header.Set("Content-Type", "application/json") + if c.XVeraeToken != "" { + req.Header.Set("x-api-key", c.XVeraeToken) + } + req.Header.Set("x-billing-channel", "api") + res, err := c.do(req) + if err != nil { + return Receipt{}, err + } + defer res.Body.Close() + raw, err := io.ReadAll(res.Body) + if err != nil { + return Receipt{}, err + } + if res.StatusCode >= 300 { + return Receipt{}, fmt.Errorf("zapier stamp HTTP %d: %s", res.StatusCode, truncate(raw, 400)) + } + parsed := map[string]any{} + _ = json.Unmarshal(raw, &parsed) + cid := firstNonEmpty(str(parsed["receipt_id"]), str(parsed["certificate"])) + var pdf []byte + if b64 := str(parsed["pdfBase64"]); b64 != "" { + pdf, err = decodeBase64(b64) + if err != nil { + return Receipt{}, fmt.Errorf("pdfBase64: %w", err) + } + } + return Receipt{ + ReceiptID: cid, + SHA256: digest, + CreatedAt: firstNonEmpty(str(parsed["created_at"]), str(parsed["timestamp"]), time.Now().UTC().Format(time.RFC3339)), + Merkle: firstNonEmpty(str(parsed["merkle"]), str(parsed["blockHash"])), + Channel: auth.ChannelZapier, + BaseURL: base, + PDF: pdf, + PDFFilename: str(parsed["pdfFilename"]), + RawResponse: raw, + }, nil +} + +func str(v any) string { + switch t := v.(type) { + case string: + return t + case fmt.Stringer: + return t.String() + default: + return "" + } +} + +func truthy(v any) bool { + switch t := v.(type) { + case bool: + return t + case string: + return t != "" && t != "false" && t != "0" + default: + return false + } +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} + +func decodeBase64(s string) ([]byte, error) { + return base64.StdEncoding.DecodeString(s) +} diff --git a/internal/veraeapi/client_test.go b/internal/veraeapi/client_test.go new file mode 100644 index 0000000..327656c --- /dev/null +++ b/internal/veraeapi/client_test.go @@ -0,0 +1,70 @@ +package veraeapi + +import ( + "encoding/base64" + "io" + "net/http" + "strings" + "testing" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" +) + +const digest64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func TestStampVeraeImmediateCertificate(t *testing.T) { + c := &Client{ + Channel: auth.ChannelVerae, + VeraeBase: "https://api.example.test", + VeraeToken: "tok", + HTTPDo: func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/api/timestamp" { + t.Fatalf("path %s", req.URL.Path) + } + body := `{"certificate":"bafyTestReceipt","timestamp":"2026-10-03T00:00:00Z","existing":true}` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }, + } + r, err := c.Stamp(digest64, "Doc.pdf") + if err != nil { + t.Fatal(err) + } + if r.ReceiptID != "bafyTestReceipt" { + t.Fatalf("receipt %q", r.ReceiptID) + } +} + +func TestStampZapierAttachPdf(t *testing.T) { + pdf := []byte("%PDF-1.4 cert") + b64 := base64.StdEncoding.EncodeToString(pdf) + c := &Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.example.test", + XVeraeToken: "xVerae-test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/v1/timestamp" { + t.Fatalf("path %s", req.URL.Path) + } + if req.Header.Get("x-api-key") != "xVerae-test" { + t.Fatal("missing x-api-key") + } + body := `{"receipt_id":"cid123","pdfBase64":"` + b64 + `","pdfFilename":"reg.pdf"}` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }, + } + r, err := c.Stamp(digest64, "Contract.pdf") + if err != nil { + t.Fatal(err) + } + if r.ReceiptID != "cid123" || string(r.PDF) != string(pdf) { + t.Fatalf("%+v", r) + } +}