Add receipt_format to stamp and verify. Omitted → text; receipt|PDF-Receipt → PDF; JSON-Receipt → JSON; MD|MD-Receipt; CSV|CSV-Receipt; TSV|TSV-Receipt. Stamp always returns receipts[] and adds artifacts[] in the chosen format. Stdio writes ~/.verae/receipts (0600); public MCP returns content_text/content_base64 only. Verify accepts inline receipt or receipt_path (stdio), parses id, and re-emits an artifact. Vendors pdfcert (+ slim statuslang, gofpdf) for CERT-01 PDF. Also lands welcome tool and email-username helpers already wired into register/public paths.
330 lines
9.7 KiB
Go
330 lines
9.7 KiB
Go
package pdfcert
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed logo.png
|
|
var embeddedLogo []byte
|
|
|
|
// Input for the official Verae Timestamp Certificate (CERT-01).
|
|
type Input struct {
|
|
DocumentName string
|
|
SHA256 string
|
|
// Multihash is the content multihash (f1220 + sha2-256 hex) when known.
|
|
// Empty → derive from SHA256 if valid; else omit (never invent).
|
|
Multihash string
|
|
// Blake3 is the optional BLAKE3-256 hex digest when provided upstream.
|
|
// Empty → omit (never invent / never compute from file bytes here).
|
|
Blake3 string
|
|
// Blake3Multihash is the blake3 multihash (f1e20 + hex) when known.
|
|
// Empty → derive from Blake3 if valid 64-hex; else omit.
|
|
Blake3Multihash string
|
|
ReceiptID string
|
|
CreatedAt string
|
|
Merkle string
|
|
Scope string
|
|
VerifyURL string
|
|
Channel string
|
|
ByteLength int64
|
|
// EmbedBrandFonts requests PDF embed of a licensed brand serif when a
|
|
// .ttf/.otf is present under BrandFontsDir(). Default false: core
|
|
// Times/Helvetica only. Also via VERAE_EMBED_BRAND_FONTS=1|true|yes.
|
|
// gofpdf path attempts AddUTF8FontFromBytes; failure is a logged no-op.
|
|
EmbedBrandFonts bool
|
|
}
|
|
|
|
// EmbedBrandFontsEnabled reports whether brand-font PDF embed is requested.
|
|
// Default false. True only when Input.EmbedBrandFonts is set or env
|
|
// VERAE_EMBED_BRAND_FONTS is 1/true/yes. When enabled, gofpdf may register a
|
|
// .ttf/.otf from BrandFontsDir(); failure is a logged no-op (core fonts).
|
|
func EmbedBrandFontsEnabled(in Input) bool {
|
|
if in.EmbedBrandFonts {
|
|
return true
|
|
}
|
|
v := strings.TrimSpace(os.Getenv("VERAE_EMBED_BRAND_FONTS"))
|
|
switch strings.ToLower(v) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// BrandFontsDir is the directory scanned for .ttf/.otf when EmbedBrandFonts is on.
|
|
// Override with VERAE_BRAND_FONTS_DIR; else site/printit.verae.ai/fonts from the
|
|
// module tree. Empty when neither is available (embed no-ops with a log).
|
|
func BrandFontsDir() string {
|
|
if d := strings.TrimSpace(os.Getenv("VERAE_BRAND_FONTS_DIR")); d != "" {
|
|
if st, err := os.Stat(d); err == nil && st.IsDir() {
|
|
return d
|
|
}
|
|
}
|
|
var candidates []string
|
|
if wd, err := os.Getwd(); err == nil {
|
|
dir := wd
|
|
for i := 0; i < 8; i++ {
|
|
candidates = append(candidates, filepath.Join(dir, "site", "printit.verae.ai", "fonts"))
|
|
if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() {
|
|
break
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
candidates = append(candidates, filepath.Join("site", "printit.verae.ai", "fonts"))
|
|
for _, c := range candidates {
|
|
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
|
abs, _ := filepath.Abs(c)
|
|
return abs
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// findBrandFaceFile returns the preferred .ttf/.otf under dir (Fraunces-named
|
|
// first, then any ttf, then otf). Empty when none present.
|
|
func findBrandFaceFile(dir string) string {
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
ents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var frauncesTTF, anyTTF, frauncesOTF, anyOTF string
|
|
for _, e := range ents {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
low := strings.ToLower(name)
|
|
path := filepath.Join(dir, name)
|
|
switch {
|
|
case strings.HasSuffix(low, ".ttf"):
|
|
if strings.Contains(low, "fraunces") && frauncesTTF == "" {
|
|
frauncesTTF = path
|
|
} else if anyTTF == "" {
|
|
anyTTF = path
|
|
}
|
|
case strings.HasSuffix(low, ".otf"):
|
|
if strings.Contains(low, "fraunces") && frauncesOTF == "" {
|
|
frauncesOTF = path
|
|
} else if anyOTF == "" {
|
|
anyOTF = path
|
|
}
|
|
}
|
|
}
|
|
for _, p := range []string{frauncesTTF, anyTTF, frauncesOTF, anyOTF} {
|
|
if p != "" {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ContentMultihash returns sha2-256 multihash hex (code 0x12, len 0x20 → f1220…)
|
|
// when sha256 is a 64-char hex digest; otherwise "" (caller omits the field).
|
|
func ContentMultihash(sha256 string) string {
|
|
return contentMultihashPrefixed("1220", sha256)
|
|
}
|
|
|
|
// ContentBlake3Multihash returns blake3 multihash hex (code 0x1e, len 0x20 → f1e20…)
|
|
// when blake3 is a 64-char hex digest; otherwise "" (caller omits the field).
|
|
func ContentBlake3Multihash(blake3 string) string {
|
|
return contentMultihashPrefixed("1e20", blake3)
|
|
}
|
|
|
|
// contentMultihashPrefixed wraps a 64-char hex digest as multibase-base16 multihash
|
|
// ("f" + code/len prefix + digest). Invalid/empty → "" (never invent).
|
|
func contentMultihashPrefixed(codeLen, hexDigest string) string {
|
|
s := strings.TrimSpace(hexDigest)
|
|
s = strings.TrimPrefix(strings.ToLower(s), "0x")
|
|
if len(s) != 64 {
|
|
return ""
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
|
return ""
|
|
}
|
|
}
|
|
return "f" + codeLen + s
|
|
}
|
|
|
|
// ResolveDigests returns CERT multihash / blake3 display values from Input.
|
|
// Empty strings mean omit that line (never invent digests).
|
|
func ResolveDigests(in Input) (mh, blake3Hex, blake3MH string) {
|
|
mh = strings.TrimSpace(in.Multihash)
|
|
if mh == "" {
|
|
mh = ContentMultihash(in.SHA256)
|
|
}
|
|
blake3Hex = strings.TrimSpace(in.Blake3)
|
|
blake3MH = strings.TrimSpace(in.Blake3Multihash)
|
|
if blake3MH == "" {
|
|
blake3MH = ContentBlake3Multihash(in.Blake3)
|
|
}
|
|
return mh, blake3Hex, blake3MH
|
|
}
|
|
|
|
// LookupURL builds a public VeraeTime verify deep link (same as explorer
|
|
// scan-bar → /time, which auto-fills receipt/sha256 and runs Lookup).
|
|
// https://explorer.verae.ai/time?receipt=…&sha256=…
|
|
func LookupURL(sha256, receiptID string) string {
|
|
sha256 = strings.TrimSpace(sha256)
|
|
sha256 = strings.TrimPrefix(strings.ToLower(sha256), "0x")
|
|
receiptID = strings.TrimSpace(receiptID)
|
|
u := "https://explorer.verae.ai/time"
|
|
sep := "?"
|
|
if receiptID != "" {
|
|
u += sep + "receipt=" + receiptID
|
|
sep = "&"
|
|
}
|
|
if sha256 != "" {
|
|
u += sep + "sha256=" + sha256
|
|
}
|
|
return u
|
|
}
|
|
|
|
// Build returns the branded CERT-01 certificate PDF (plum/gold, final-only
|
|
// evidence copy). Prefers platform/registration-pdf/build.mjs when Node is
|
|
// available; otherwise gofpdf fallback.
|
|
//
|
|
// Typography: default PDF standard fonts (Times title, Helvetica body).
|
|
// EmbedBrandFonts defaults false (env VERAE_EMBED_BRAND_FONTS off). When on,
|
|
// gofpdf fallback may register a licensed .ttf/.otf from BrandFontsDir();
|
|
// failure is a logged no-op. Node path stays core faces (OPEN-ISSUES #7).
|
|
func Build(in Input) ([]byte, error) {
|
|
created := in.CreatedAt
|
|
if created == "" {
|
|
// Do not invent a ledger time — leave blank for the PDF field.
|
|
created = ""
|
|
}
|
|
scope := strings.ToUpper(strings.TrimSpace(in.Scope))
|
|
if scope == "" {
|
|
scope = "GLOBAL"
|
|
}
|
|
lookup := in.VerifyURL
|
|
if lookup == "" || strings.Contains(strings.ToLower(lookup), "mcp.verae.ai") {
|
|
lookup = LookupURL(in.SHA256, in.ReceiptID)
|
|
}
|
|
merkle := in.Merkle
|
|
if merkle == "" {
|
|
merkle = "See ledger lookup via receipt CID"
|
|
}
|
|
docName := in.DocumentName
|
|
if docName == "" {
|
|
docName = "(digest only)"
|
|
}
|
|
|
|
mh, b3, b3mh := ResolveDigests(in)
|
|
row := map[string]any{
|
|
"file": docName,
|
|
"sha256": in.SHA256,
|
|
"scope": scope,
|
|
"receipt_id": in.ReceiptID,
|
|
"created_at": created,
|
|
"merkle": merkle,
|
|
"lookup_url": lookup,
|
|
"size_bytes": in.ByteLength,
|
|
}
|
|
// Omit digest fields when empty — never invent.
|
|
if mh != "" {
|
|
row["multihash"] = mh
|
|
}
|
|
if b3 != "" {
|
|
row["blake3"] = b3
|
|
}
|
|
if b3mh != "" {
|
|
row["blake3_multihash"] = b3mh
|
|
}
|
|
|
|
if b, err := buildWithNode(row); err == nil && len(b) > 1000 {
|
|
return b, nil
|
|
}
|
|
|
|
return buildFallbackGofpdf(in, docName, created, scope, merkle, lookup)
|
|
}
|
|
|
|
func buildWithNode(row map[string]any) ([]byte, error) {
|
|
script := findBuildScript()
|
|
if script == "" {
|
|
return nil, fmt.Errorf("registration-pdf build.mjs not found")
|
|
}
|
|
tmp, err := os.MkdirTemp("", "verae-regpdf-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
inPath := filepath.Join(tmp, "in.json")
|
|
outPath := filepath.Join(tmp, "out.pdf")
|
|
jb, err := json.Marshal(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(inPath, jb, 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
cmd := exec.Command("node", script, inPath, outPath)
|
|
cmd.Dir = filepath.Dir(script)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("node build.mjs: %w (%s)", err, stderr.String())
|
|
}
|
|
return os.ReadFile(outPath)
|
|
}
|
|
|
|
func findBuildScript() string {
|
|
// Prefer tree-local build.mjs (repo / package) so CERT-01 edits win over
|
|
// a stale install under Application Support during tests and dev runs.
|
|
var candidates []string
|
|
if wd, err := os.Getwd(); err == nil {
|
|
dir := wd
|
|
for i := 0; i < 8; i++ {
|
|
candidates = append(candidates, filepath.Join(dir, "platform", "registration-pdf", "build.mjs"))
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
candidates = append(candidates, filepath.Join("platform", "registration-pdf", "build.mjs"))
|
|
if exe, err := os.Executable(); err == nil {
|
|
dir := filepath.Dir(exe)
|
|
candidates = append(candidates,
|
|
filepath.Join(dir, "registration-pdf", "build.mjs"),
|
|
filepath.Join(dir, "..", "registration-pdf", "build.mjs"),
|
|
filepath.Join(dir, "platform", "registration-pdf", "build.mjs"),
|
|
filepath.Join(dir, "..", "platform", "registration-pdf", "build.mjs"),
|
|
filepath.Join(dir, "..", "..", "platform", "registration-pdf", "build.mjs"),
|
|
)
|
|
}
|
|
// Installed helper copy last (production path when no tree is present).
|
|
// No machine-specific absolute research paths — packaging resolves via go.mod walk above.
|
|
candidates = append(candidates,
|
|
"/Library/Application Support/Verae/PrintStamp/registration-pdf/build.mjs",
|
|
)
|
|
for _, c := range candidates {
|
|
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
|
abs, _ := filepath.Abs(c)
|
|
return abs
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Ensure created stays empty when unknown — used by tests.
|
|
var _ = time.Now
|