feat(macos): PDF Services stamp helper with dual-channel Verae auth
Implements macOS v0: verae-print stamp + install-macos PDF menu items.
Saves {name}.pdf, {name}-verae-timestamp-{RECEIPTID}.pdf, and .verae.json.
Channels: verae (api.veraetime.net JWT) and zapier (zap.verae.ai xVerae- + attachPdf).
This commit is contained in:
parent
e91275a67c
commit
46f2bcd43e
14 changed files with 1259 additions and 24 deletions
70
README.md
70
README.md
|
|
@ -1,27 +1,65 @@
|
||||||
# PrintToVeraeTimeStamp
|
# 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
|
## 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)
|
## Contributing
|
||||||
- [Implementation plan](docs/superpowers/plans/2026-10-03-print-to-verae-timestamp.md)
|
|
||||||
|
|
||||||
## How it works (short)
|
Open questions and design changes in this repository. Pull requests are accepted for review against the spec.
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TBD.
|
UNLICENSED © Verae / George Lambert
|
||||||
|
|
|
||||||
221
cmd/verae-print/main.go
Normal file
221
cmd/verae-print/main.go
Normal file
|
|
@ -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] <file.pdf>
|
||||||
|
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>CFBundleExecutable</key><string>runner</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>ai.verae.print.%s</string>
|
||||||
|
<key>CFBundleName</key><string>%s</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>%s</string>
|
||||||
|
</dict></plist>
|
||||||
|
`, 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()
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,10 @@
|
||||||
- [ ] Commit `feat(pdf): certificate page and embedded receipt`.
|
- [ ] Commit `feat(pdf): certificate page and embedded receipt`.
|
||||||
|
|
||||||
### Task 5: macOS v0 — PDF Services
|
### 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`.
|
- [ ] Commit `feat(macos): PDF Services entry`.
|
||||||
|
|
||||||
### Task 6: Spool protocol + helper
|
### Task 6: Spool protocol + helper
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
- **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.
|
- **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 |
|
| 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 |
|
| `GET {base}/api/hash/{digest}` | Look up existing stamp |
|
||||||
| `POST {base}/api/verify` | Verify |
|
| `POST {base}/api/verify` | Verify |
|
||||||
|
|
||||||
- Default base: `https://api.veraetime.net`; `Authorization: Bearer <token>` from `~/.verae/auth.json` (keys `access_token` / `auth_token` / `id_token`).
|
- Default base: `https://api.veraetime.net`
|
||||||
- **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).
|
- Auth: `Authorization: Bearer <token>` 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
|
## 5. Architecture
|
||||||
|
|
||||||
|
|
@ -60,7 +72,10 @@ App ──▶ Print │ macOS/Linux: CUPS queue "Verae Timestamp" │
|
||||||
|
|
||||||
### 5.1 macOS
|
### 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/<user>/` and exits; the menu-bar helper (LaunchAgent) picks it up.
|
- **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/<user>/` and exits; the menu-bar helper (LaunchAgent) picks it up.
|
||||||
- Signed + notarized `.pkg`.
|
- 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
|
## 6. Outputs
|
||||||
|
|
||||||
For a job named `Contract.pdf`:
|
For a print job whose document name is `Contract` (extension stripped for the stem):
|
||||||
|
|
||||||
| File | Content |
|
| File | Content |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Contract.pdf` | Exact captured bytes (the proven artifact) |
|
| `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-verae-timestamp-{RECEIPTID}.pdf` | Verae registration / certificate PDF only |
|
||||||
| `Contract.stamped.pdf` (optional) | Original bytes + incremental update containing certificate page and `Contract.verae.json` as attachment |
|
| `Contract.verae.json` | Receipt sidecar: doc sha256, receipt id, channel, API host, time, helper version, original byte length |
|
||||||
| `Contract.certificate.pdf` (optional) | Certificate page alone (from API if available, else rendered) |
|
|
||||||
|
|
||||||
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
|
## 7. Settings
|
||||||
|
|
||||||
|
|
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/VeraeLLC/PrintToVeraeTimeStamp
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
94
internal/auth/auth.go
Normal file
94
internal/auth/auth.go
Normal file
|
|
@ -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)")
|
||||||
|
}
|
||||||
36
internal/auth/auth_test.go
Normal file
36
internal/auth/auth_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
internal/hash/hash.go
Normal file
39
internal/hash/hash.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
37
internal/hash/hash_test.go
Normal file
37
internal/hash/hash_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
179
internal/job/job.go
Normal file
179
internal/job/job.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
96
internal/job/job_test.go
Normal file
96
internal/job/job_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
103
internal/pdfcert/pdfcert.go
Normal file
103
internal/pdfcert/pdfcert.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
296
internal/veraeapi/client.go
Normal file
296
internal/veraeapi/client.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
70
internal/veraeapi/client_test.go
Normal file
70
internal/veraeapi/client_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue