windows-debug: elevated GUI installer with Verae key pages (sign in / create account + email code / paste / skip), hidden scripts, per-step finish page, user-scoped key save + helper scheduled task; installer registration API spec

This commit is contained in:
Grok Bot (for George) 2026-10-03 19:14:49 -04:00
commit 6e76bc7b0e
18 changed files with 1857 additions and 61 deletions

View file

@ -0,0 +1,210 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"runtime"
"strings"
"unicode/utf16"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// writeINI writes a one-section INI (ASCII) for NSIS ReadINIStr.
func writeINI(path, section string, kv [][2]string) error {
var b strings.Builder
fmt.Fprintf(&b, "[%s]\r\n", section)
for _, p := range kv {
v := strings.NewReplacer("\r", " ", "\n", " ", "=", ":").Replace(p[1])
fmt.Fprintf(&b, "%s=%s\r\n", p[0], v)
}
return os.WriteFile(path, []byte(b.String()), 0o600)
}
func tokenName() string {
h, _ := os.Hostname()
return fmt.Sprintf("Verae Print Stamp - %s (%s)", h, runtime.GOOS)
}
// runInstallerAccount is driven (hidden) by the NSIS key pages.
//
// --op capabilities → [account] installApi, emailVerification, policy*
// --op login|register|verify|resend --in req.json (deleted immediately)
//
// Secrets arrive only via the request file; minted key goes to --token-out.
// Passwords and keys are never logged (keys masked, emails masked).
func runInstallerAccount(args []string) int {
fs := flag.NewFlagSet("installer-account", flag.ContinueOnError)
op := fs.String("op", "", "capabilities|login|register|verify|resend")
in := fs.String("in", "", "request json (deleted after read)")
out := fs.String("out", "", "result ini")
tokOut := fs.String("token-out", "", "file to receive minted key")
if err := fs.Parse(args); err != nil || *op == "" || *out == "" {
return 2
}
vlog.Init("installer", false)
res := func(kv ...[2]string) int { _ = writeINI(*out, "account", kv); return 0 }
fail := func(err error) int {
vlog.Errorf("installer-account %s: %v", *op, err)
if errors.Is(err, auth.ErrTOTPRequired) {
return res([2]string{"result", "totp_required"}, [2]string{"detail", "This account uses two-factor authentication. Enter the 6-digit code from your authenticator app."})
}
return res([2]string{"result", "error"}, [2]string{"detail", err.Error()})
}
caps := auth.GetCapabilities()
if *op == "capabilities" {
p := caps.Policy
vlog.Printf("installer-account: capabilities installApi=%v emailVerification=%v policy=%+v", caps.InstallAPI, caps.EmailVerification, p)
return res([2]string{"result", "ok"}, [2]string{"installApi", b2s(caps.InstallAPI)}, [2]string{"emailVerification", b2s(caps.EmailVerification)},
[2]string{"policyText", p.Describe()}, [2]string{"minLength", fmt.Sprint(p.MinLength)})
}
var r struct{ Name, Email, Password, Confirm, Totp, Code, PendingID string }
if *in != "" {
raw, err := readTextFile(*in)
_ = os.Remove(*in)
if err != nil {
return fail(err)
}
if strings.HasPrefix(strings.TrimSpace(raw), "{") {
if err := json.Unmarshal([]byte(raw), &r); err != nil {
return fail(fmt.Errorf("bad request"))
}
} else {
// NSIS writes "Key=Value" lines (UTF-16LE); split on the first '=' only.
for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch strings.ToLower(strings.TrimSpace(k)) {
case "name":
r.Name = v
case "email":
r.Email = v
case "password":
r.Password = v
case "confirm":
r.Confirm = v
case "totp":
r.Totp = v
case "code":
r.Code = v
case "pendingid":
r.PendingID = v
}
}
}
}
r.Email = strings.TrimSpace(r.Email)
saveKey := func(key string) int {
if *tokOut == "" {
return fail(errors.New("no --token-out"))
}
if err := os.WriteFile(*tokOut, []byte(key+"\n"), 0o600); err != nil {
return fail(err)
}
st, det := auth.ValidateXVeraeKey(key)
vlog.Printf("installer-account %s: key issued %s validate=%s %s", *op, auth.MaskKey(key), st, det)
return res([2]string{"result", "ok"}, [2]string{"masked", auth.MaskKey(key)}, [2]string{"validate", st}, [2]string{"detail", det})
}
switch *op {
case "login":
if !auth.ValidEmail(r.Email) || r.Password == "" {
return fail(errors.New("enter your email and password"))
}
vlog.Printf("installer-account: login %s installApi=%v", auth.MaskEmail(r.Email), caps.InstallAPI)
key, err := auth.InstallLogin(caps, r.Email, r.Password, strings.TrimSpace(r.Totp), tokenName())
if err != nil {
return fail(err)
}
return saveKey(key)
case "register":
switch {
case strings.TrimSpace(r.Name) == "":
return fail(errors.New("enter your name"))
case !auth.ValidEmail(r.Email):
return fail(errors.New("enter a valid email address"))
case r.Password != r.Confirm:
return fail(errors.New("passwords do not match"))
}
if err := caps.Policy.Check(r.Password, r.Email); err != nil {
return fail(err)
}
vlog.Printf("installer-account: register %s installApi=%v", auth.MaskEmail(r.Email), caps.InstallAPI)
rr, err := auth.InstallRegister(caps, strings.TrimSpace(r.Name), r.Email, r.Password, tokenName())
if err != nil {
return fail(err)
}
if rr.PendingID != "" {
vlog.Printf("installer-account: verification code sent to %s", auth.MaskEmail(r.Email))
return res([2]string{"result", "code_sent"}, [2]string{"pendingId", rr.PendingID}, [2]string{"detail", "We emailed a 6-digit code to " + auth.MaskEmail(r.Email)})
}
return saveKey(rr.Key)
case "verify":
if r.PendingID == "" || strings.TrimSpace(r.Code) == "" {
return fail(errors.New("enter the code from the email"))
}
key, err := auth.InstallVerify(r.PendingID, r.Code, tokenName())
if err != nil {
return fail(err)
}
return saveKey(key)
case "resend":
if err := auth.InstallResend(r.PendingID); err != nil {
return fail(err)
}
return res([2]string{"result", "ok"}, [2]string{"detail", "A new code was sent."})
}
return fail(fmt.Errorf("unknown op %q", *op))
}
func b2s(b bool) string {
if b {
return "1"
}
return "0"
}
// runValidateKey: --token-file F --out INI → [validate] result=valid|invalid|badformat|unreachable|unknown
func runValidateKey(args []string) int {
fs := flag.NewFlagSet("validate-key", flag.ContinueOnError)
tf := fs.String("token-file", "", "file with key")
out := fs.String("out", "", "result ini (else stdout)")
if err := fs.Parse(args); err != nil || *tf == "" {
return 2
}
b, err := readTextFile(*tf)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
key := strings.TrimSpace(b)
st, det := auth.ValidateXVeraeKey(key)
if *out != "" {
_ = writeINI(*out, "validate", [][2]string{{"result", st}, {"detail", det}, {"masked", auth.MaskKey(key)}})
} else {
fmt.Printf("%s %s %s\n", auth.MaskKey(key), st, det)
}
return 0
}
// readTextFile decodes UTF-16LE (with BOM, as NSIS FileWriteUTF16LE writes),
// UTF-8 with/without BOM.
func readTextFile(p string) (string, error) {
b, err := os.ReadFile(p)
if err != nil {
return "", err
}
if len(b) >= 2 && b[0] == 0xFF && b[1] == 0xFE {
u := make([]uint16, 0, len(b)/2)
for i := 2; i+1 < len(b); i += 2 {
u = append(u, uint16(b[i])|uint16(b[i+1])<<8)
}
return string(utf16.Decode(u)), nil
}
return strings.TrimPrefix(string(b), "\uFEFF"), nil
}

View file

@ -0,0 +1,23 @@
package main
import (
"os"
"path/filepath"
"testing"
"unicode/utf16"
)
func TestReadTextFileUTF16(t *testing.T) {
s := "Email=a@b.co\r\nPassword=p=\"w\\x\r\n"
u := utf16.Encode([]rune(s))
b := []byte{0xFF, 0xFE}
for _, c := range u {
b = append(b, byte(c), byte(c>>8))
}
p := filepath.Join(t.TempDir(), "r.txt")
_ = os.WriteFile(p, b, 0o600)
got, err := readTextFile(p)
if err != nil || got != s {
t.Fatalf("%q %v", got, err)
}
}

View file

@ -0,0 +1,108 @@
//go:build windows
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unsafe"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
)
// WinForms dialog (powershell, console hidden): masked key field + "Get a key"
// button that opens the customer portal. Writes the key to the file in $env:VERAE_KEY_OUT.
const keyDialogPS = `
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
$f = New-Object Windows.Forms.Form
$f.Text = 'Verae TimeStamp PDF - signing key'; $f.Width = 520; $f.Height = 300; $f.StartPosition = 'CenterScreen'; $f.TopMost = $true
$f.FormBorderStyle = 'FixedDialog'; $f.MaximizeBox = $false; $f.MinimizeBox = $false
$l = New-Object Windows.Forms.Label; $l.Left = 15; $l.Top = 12; $l.Width = 480; $l.Height = 95
$l.Text = $env:VERAE_KEY_MSG
$f.Controls.Add($l)
$b = New-Object Windows.Forms.Button; $b.Text = 'Get a Verae key (opens customer.verae.ai)'; $b.Left = 15; $b.Top = 110; $b.Width = 300
$b.Add_Click({ Start-Process 'https://customer.verae.ai/portal/' }); $f.Controls.Add($b)
$k = New-Object Windows.Forms.Label; $k.Text = 'Paste your xVerae- key:'; $k.Left = 15; $k.Top = 150; $k.Width = 300; $f.Controls.Add($k)
$t = New-Object Windows.Forms.TextBox; $t.Left = 15; $t.Top = 172; $t.Width = 475; $t.UseSystemPasswordChar = $true; $f.Controls.Add($t)
$ok = New-Object Windows.Forms.Button; $ok.Text = 'Save key'; $ok.Left = 300; $ok.Top = 215; $ok.Width = 90; $ok.DialogResult = 'OK'; $f.AcceptButton = $ok; $f.Controls.Add($ok)
$c = New-Object Windows.Forms.Button; $c.Text = 'Not now'; $c.Left = 400; $c.Top = 215; $c.Width = 90; $c.DialogResult = 'Cancel'; $f.CancelButton = $c; $f.Controls.Add($c)
if ($f.ShowDialog() -eq 'OK' -and $t.Text.Trim()) { [IO.File]::WriteAllText($env:VERAE_KEY_OUT, $t.Text.Trim()) ; exit 0 }
exit 1
`
func showKeyForm(msg string) (string, bool) {
tmp, err := os.CreateTemp("", "verae-key-*.txt")
if err != nil {
return "", false
}
path := tmp.Name()
tmp.Close()
defer os.Remove(path)
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", keyDialogPS)
cmd.Env = append(os.Environ(), "VERAE_KEY_OUT="+path, "VERAE_KEY_MSG="+msg)
hideWindow(cmd)
if err := cmd.Run(); err != nil {
return "", false
}
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
// keyDialogFlow loops until a valid key is saved or the user cancels.
func keyDialogFlow(reason string) bool {
msg := reason + "\r\n\r\nClick 'Get a Verae key', sign in (or create an account), open Security and create an xVerae- token, then paste it below."
for i := 0; i < 5; i++ {
key, ok := showKeyForm(msg)
if !ok {
vlog.Printf("key-dialog: cancelled by user")
return false
}
st, det := auth.ValidateXVeraeKey(key)
vlog.Printf("key-dialog: entered %s validate=%s %s", auth.MaskKey(key), st, det)
switch st {
case "valid", "unreachable", "unknown":
if err := auth.SaveXVeraeToken(key); err != nil {
msg = "Could not save the key: " + err.Error()
continue
}
home, _ := os.UserHomeDir()
vlog.Printf("key-dialog: saved %s to %s", auth.MaskKey(key), filepath.Join(home, ".verae", "xverae-token"))
messageBox("Verae TimeStamp PDF", "Signing key saved ("+auth.MaskKey(key)+").", 0x40)
return true
case "invalid":
msg = "Verae rejected that key (" + det + "). Please check it and paste again."
default:
msg = "That does not look like a Verae key (" + det + "). Keys start with xVerae-."
}
}
return false
}
func runKeyDialog() int {
vlog.Init("helper", false)
if keyDialogFlow("Set up your Verae signing key for the 'Verae TimeStamp PDF' printer.") {
return 0
}
return 1
}
var (
user32 = syscall.NewLazyDLL("user32.dll")
pMessageBox = user32.NewProc("MessageBoxW")
)
// messageBox shows a native message box; flags e.g. 0x40 info, 0x30 warning, 0x4 yes/no. Returns button id (6 = Yes).
func messageBox(title, text string, flags uintptr) int {
t, _ := syscall.UTF16PtrFromString(title)
m, _ := syscall.UTF16PtrFromString(text)
r, _, _ := pMessageBox.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), flags|0x00040000 /*MB_TOPMOST*/)
return int(r)
}

View file

@ -31,6 +31,12 @@ func main() {
os.Exit(runServe(os.Args[2:])) os.Exit(runServe(os.Args[2:]))
case "ipp-selftest": case "ipp-selftest":
os.Exit(runIPPSelftest(os.Args[2:])) os.Exit(runIPPSelftest(os.Args[2:]))
case "installer-account":
os.Exit(runInstallerAccount(os.Args[2:]))
case "validate-key":
os.Exit(runValidateKey(os.Args[2:]))
case "key-dialog":
os.Exit(runKeyDialog())
case "setup", "login": case "setup", "login":
os.Exit(runSetup()) os.Exit(runSetup())
case "install-macos": case "install-macos":

View file

@ -36,3 +36,8 @@ func launchSetupUI() { vlog.Printf("setup: no signing key — run: verae-print s
func runInstallPrinterWindows() int { return 1 } func runInstallPrinterWindows() int { return 1 }
func debugFromRegistry() bool { return false } func debugFromRegistry() bool { return false }
func runKeyDialog() int {
fmt.Println("key-dialog is Windows-only; run: verae-print setup")
return 1
}

View file

@ -37,11 +37,17 @@ func notify(title, subtitle string) { notifyUser(title, subtitle) }
func openFolderPath(p string) { _ = exec.Command("explorer.exe", p).Start() } func openFolderPath(p string) { _ = exec.Command("explorer.exe", p).Start() }
// launchSetupUI opens a console running "verae-print setup" for the user. // launchSetupUI asks the user (GUI, no console) to set up a key, then
// re-processes jobs that were held because no key was present.
func launchSetupUI() { func launchSetupUI() {
cli := filepath.Join(exeDir(), "verae-print.exe") go func() {
vlog.Printf("setup: no signing key — launching %q setup in a new console", cli) vlog.Printf("setup: no signing key — prompting user (GUI)")
_ = exec.Command("cmd.exe", "/c", "start", "Verae Print Stamp setup", cli, "setup").Start() if keyDialogFlow("Your print job was saved but not yet timestamped because no Verae signing key is set up on this PC.") {
retryHeldJobs()
} else {
notifyUser("Verae key not set", "Print jobs are kept and will be timestamped after you set up a key (Start > Verae > Setup signing key).")
}
}()
} }
func runInstallPrinterWindows() int { func runInstallPrinterWindows() int {

View file

@ -102,10 +102,43 @@ func errState(err error) string {
return "missing (" + err.Error() + ")" return "missing (" + err.Error() + ")"
} }
var setupOnce sync.Once var (
setupMu sync.Mutex
setupActive bool
heldMu sync.Mutex
heldJobs []winprint.Job
processor winprint.Processor
)
// promptSetup shows the key dialog at most once at a time.
func promptSetup() {
setupMu.Lock()
defer setupMu.Unlock()
if setupActive {
return
}
setupActive = true
launchSetupUI()
}
func retryHeldJobs() {
setupMu.Lock()
setupActive = false
setupMu.Unlock()
heldMu.Lock()
jobs := heldJobs
heldJobs = nil
heldMu.Unlock()
for _, j := range jobs {
vlog.Job(j.ID).Printf("retry: re-processing held job after key setup")
if err := processor(j); err != nil {
vlog.Job(j.ID).Errorf("retry failed: %v", err)
}
}
}
func makeProcessor(outDir string) winprint.Processor { func makeProcessor(outDir string) winprint.Processor {
return func(j winprint.Job) error { processor = func(j winprint.Job) error {
lj := vlog.Job(j.ID) lj := vlog.Job(j.ID)
lj.Printf("process: source=%s title=%q user=%q format=%s bytes=%d path=%q", j.Source, j.Title, j.User, j.Format, j.Bytes, j.Path) lj.Printf("process: source=%s title=%q user=%q format=%s bytes=%d path=%q", j.Source, j.Title, j.User, j.Format, j.Bytes, j.Path)
if !strings.Contains(j.Format, "pdf") && sniffPDF(j.Path) == false { if !strings.Contains(j.Format, "pdf") && sniffPDF(j.Path) == false {
@ -122,7 +155,10 @@ func makeProcessor(outDir string) winprint.Processor {
ch, client.Channel, client.VeraeToken = auth.ChannelVerae, auth.ChannelVerae, jwt ch, client.Channel, client.VeraeToken = auth.ChannelVerae, auth.ChannelVerae, jwt
break break
} }
setupOnce.Do(launchSetupUI) heldMu.Lock()
heldJobs = append(heldJobs, j)
heldMu.Unlock()
promptSetup()
return fmt.Errorf("no signing key: %v — run 'verae-print setup' then reprint (PDF kept at %s)", err, j.Path) return fmt.Errorf("no signing key: %v — run 'verae-print setup' then reprint (PDF kept at %s)", err, j.Path)
} }
client.XVeraeToken = tok client.XVeraeToken = tok
@ -147,6 +183,7 @@ func makeProcessor(outDir string) winprint.Processor {
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF) notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
return nil return nil
} }
return processor
} }
func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) { func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) {

View file

@ -0,0 +1,204 @@
# Verae installer registration API (proposed)
Status: **proposal + client implementation**. The client side is implemented in
`internal/auth/portal_install.go` and driven by the Windows installer
(`platform/windows/installer-debug.nsi` → `verae-print installer-account`).
The server side does **not exist yet**. The portal backend source was not
available, so nothing was changed or deployed on the server. Until the server
implements it, the client automatically falls back to the endpoints the portal
web client already uses (see §5).
## 1. Why a new API
How Verae accounts work today (from `https://customer.verae.ai/portal/app.js`):
| Today | Endpoint | Notes |
|---|---|---|
| Sign in | `POST /portal/api/login` `{email,password,totpCode?}` | → `{token:<session>}`; error `totp_required` when 2FA is on |
| Sign up | `POST /portal/api/signup` `{name,email,password}` | → session **immediately**; **no email verification** |
| Mint key | `POST /portal/api/tokens` (Bearer session) `{name,expiresInDays?}` | → `{token:"xVerae-…"}` (shown once) |
| Sign out | `POST /portal/api/logout` | |
| Validate key | `GET https://zap.verae.ai/v1/status` with `x-api-key` | free; 401 = invalid |
Gaps for a desktop installer:
1. No email-ownership check at signup. Anyone can register any address.
2. No published password policy. The installer cannot show or enforce the rule the server uses.
3. Getting a key takes three calls (login → tokens → logout), and a full web session is handed to a desktop client.
4. No device/installer-specific rate limiting or audit field.
## 2. Endpoints (all under `https://customer.verae.ai/portal/api`)
```yaml
openapi: 3.0.3
info: { title: Verae installer registration, version: 1.0.0 }
servers: [ { url: https://customer.verae.ai/portal/api } ]
paths:
/install/capabilities:
get:
summary: Feature discovery + password policy (no auth)
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Capabilities" }
"404": { description: Not implemented → client uses legacy flow (§5) }
/install/register:
post:
summary: Start account creation; emails a 6-digit code
requestBody:
content:
application/json:
schema:
type: object
required: [name, email, password]
properties:
name: { type: string, maxLength: 200 }
email: { type: string, format: email }
password: { type: string, format: password }
client: { $ref: "#/components/schemas/Client" }
responses:
"202":
description: Code sent (also returned when the email already exists, to avoid account enumeration; in that case the email says "you already have an account – sign in instead")
content:
application/json:
schema:
type: object
properties:
pendingId: { type: string, description: "opaque, ≥128-bit random" }
expiresInSeconds: { type: integer, example: 900 }
resendAfterSeconds: { type: integer, example: 60 }
"400": { $ref: "#/components/responses/Error" } # weak_password, invalid_email, name_required
"429": { $ref: "#/components/responses/Error" }
/install/verify:
post:
summary: Confirm the code; creates the account and issues a key
requestBody:
content:
application/json:
schema:
type: object
required: [pendingId, code]
properties:
pendingId: { type: string }
code: { type: string, pattern: "^[0-9]{6}$" }
tokenName: { type: string, example: "Verae Print Stamp - NIMO (windows)" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/Issued" }
"400": { $ref: "#/components/responses/Error" } # invalid_code (attemptsLeft), expired
"410": { $ref: "#/components/responses/Error" } # too many attempts / expired pendingId
/install/resend:
post:
requestBody:
content:
application/json:
schema: { type: object, required: [pendingId], properties: { pendingId: { type: string } } }
responses:
"202": { description: New code sent; previous code invalidated }
"429": { $ref: "#/components/responses/Error" }
/install/login:
post:
summary: Existing account → issue a key in one call (no web session returned)
requestBody:
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: { type: string }
password: { type: string, format: password }
totpCode: { type: string }
tokenName: { type: string }
client: { $ref: "#/components/schemas/Client" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/Issued" }
"401":
description: "error = invalid_credentials | totp_required | totp_invalid | email_unverified"
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"429": { $ref: "#/components/responses/Error" }
components:
responses:
Error:
description: Error
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
schemas:
Error:
type: object
properties:
error: { type: string, description: machine code }
message: { type: string, description: human text shown verbatim in the installer }
attemptsLeft: { type: integer }
retryAfterSeconds: { type: integer }
Capabilities:
type: object
properties:
installApi: { type: boolean }
emailVerification: { type: boolean }
passwordPolicy:
type: object
properties:
minLength: { type: integer, example: 10 }
requireLetter: { type: boolean }
requireDigit: { type: boolean }
requireSymbol: { type: boolean }
disallowEmail: { type: boolean }
Client:
type: object
properties:
product: { type: string, example: verae-print }
version: { type: string, example: 0.3.0-debug }
os: { type: string, example: windows }
host: { type: string }
Issued:
type: object
properties:
token: { type: string, example: "xVerae-…" }
tokenId: { type: string }
name: { type: string }
expiresAt: { type: string, format: date-time, nullable: true }
```
## 3. Password policy
* The server is the source of truth. It publishes the policy in `capabilities.passwordPolicy`, enforces it in `/install/register` (and in `/signup` too, for consistency), and rejects a failing password with `400 {error:"weak_password", message:"…"}`.
* The client checks the same rule before sending (`PasswordPolicy.Check`) and shows `Describe()` text on the Create page. Password and confirm must match (checked in the installer and again in the CLI).
* Client default when the server publishes nothing: **≥10 characters, at least one letter and one digit, must not contain the email's local part**. Recommend also checking against a breached-password list (k-anonymity HIBP range API) on the server.
## 4. Security requirements
* **HTTPS only.** The client refuses non-https portal URLs (`VERAE_PORTAL_BASE` override, plus `VERAE_ALLOW_INSECURE_PORTAL=1` only for local tests). Server: HSTS, TLS 1.2+.
* **Secrets are never logged** by either side. Mask request bodies on `/install/*`, `/login`, `/signup`, `/tokens` in access logs and APM. Client: passwords are never written to any log. Keys are only logged masked (`xVerae-AbC1…9xYz`). The installer passes secrets to the helper only through a temp file in its private `$PLUGINSDIR`, which is deleted right after it is read. Secrets are never passed on a command line.
* **The password is never stored on the client.** Only the issued key is saved, to `<target user profile>\.verae\xverae-token` (`token + "\n"`, the format of `auth.SaveXVeraeToken`). The ACL is the user + SYSTEM + Administrators, with inheritance removed. If the installer is elevated with a different admin account, the key still goes to the *logged-in* user (owner of explorer.exe).
* **Codes**: 6 digits, CSPRNG, stored hashed. TTL 15 min, 5 attempts per pendingId, resend at most once per 60 s and 5 per hour. A new code invalidates the old one.
* **Rate limits** (suggested): register 5/h per IP and 3/h per email; login 10 per 15 min per IP+email, with exponential back-off; verify 5 per pendingId. Return 429 with `retryAfterSeconds`.
* **No enumeration**: register answers 202 whether or not the email exists. Login uses one generic `invalid_credentials`.
* **The key is scoped and auditable**: name it after `tokenName`, record `client` (product/version/os/host), and let users list and revoke it in the portal Security tab as usual.
* `/install/login` does not return a session cookie or token, only the key. It does not create a web session (or revokes it immediately).
## 5. Client fallback (today's server)
`GET /install/capabilities` → 404 (or a network error) ⇒ `installApi=0`:
* Existing account: `POST /login` → `POST /tokens {name}` → `POST /logout`. `totp_required` is shown to the user and the TOTP field is used.
* New account: the password policy is checked client-side, then `POST /signup` → `POST /tokens` → `POST /logout`. **No email code** (the Create page says so). The Verify page is skipped.
* The key is validated afterwards with `GET zap.verae.ai/v1/status` (free).
## 6. Installer UI mapping
| Page | Calls |
|---|---|
| Key status | `user-token.ps1 -Mode status` (masked key + free validation), `installer-account --op capabilities` |
| Sign in | `installer-account --op login` (email, password, optional TOTP) |
| Create account | `--op register` → `code_sent` (Verify page) or `ok` (legacy) |
| Verify email | `--op verify`; "Send a new code" → `--op resend` |
| Paste key | button opens customer.verae.ai; `validate-key` before Next |
| Skip | warning; the helper shows a key dialog on first print and holds the job until a key is saved |

143
internal/auth/portal.go Normal file
View file

@ -0,0 +1,143 @@
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Portal account API — the same JSON endpoints the customer.verae.ai/portal web
// client (portal/app.js) uses: POST /portal/api/login|signup → {token: session},
// POST /portal/api/tokens {name} (Bearer session) → {token: "xVerae-…"},
// POST /portal/api/logout. There is no device-code / email-code flow, so the
// installer uses email+password (+ optional authenticator code) and mints a key.
// PortalBase is overridable for testing via VERAE_PORTAL_BASE.
func PortalBase() string {
if v := strings.TrimRight(os.Getenv("VERAE_PORTAL_BASE"), "/"); v != "" {
return v
}
return "https://customer.verae.ai"
}
// ErrTOTPRequired means the account has 2FA and a 6-digit code is needed.
var ErrTOTPRequired = fmt.Errorf("totp_required")
var portalHTTP = &http.Client{Timeout: 20 * time.Second}
func decodeJSON(res *http.Response, v any) error {
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
}
func portalPost(path, bearer string, body any) (map[string]any, int, error) {
if !strings.HasPrefix(PortalBase(), "https://") && os.Getenv("VERAE_ALLOW_INSECURE_PORTAL") != "1" {
return nil, 0, fmt.Errorf("refusing non-HTTPS portal base %q", PortalBase())
}
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, PortalBase()+"/portal/api"+path, bytes.NewReader(b))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "VeraePrintStamp-Installer")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
res, err := portalHTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
out := map[string]any{}
_ = json.Unmarshal(raw, &out)
if res.StatusCode >= 300 {
msg, _ := out["error"].(string)
if msg == "totp_required" {
return out, res.StatusCode, ErrTOTPRequired
}
if msg == "" {
msg = strings.TrimSpace(string(raw))
if len(msg) > 200 {
msg = msg[:200]
}
}
return out, res.StatusCode, fmt.Errorf("HTTP %d: %s", res.StatusCode, msg)
}
return out, res.StatusCode, nil
}
// PortalMintKey signs in (or signs up), mints a long-lived xVerae- token named
// tokenName, and signs out. Credentials are never stored.
func PortalMintKey(signup bool, name, email, password, totp, tokenName string) (string, error) {
var body map[string]any
path := "/login"
if signup {
path = "/signup"
body = map[string]any{"name": name, "email": email, "password": password}
} else {
body = map[string]any{"email": email, "password": password}
if totp != "" {
body["totpCode"] = totp
}
}
out, _, err := portalPost(path, "", body)
if err != nil {
return "", err
}
session, _ := out["token"].(string)
if session == "" {
return "", fmt.Errorf("portal %s returned no session", path)
}
defer func() { _, _, _ = portalPost("/logout", session, map[string]any{}) }()
tk, _, err := portalPost("/tokens", session, map[string]any{"name": tokenName})
if err != nil {
return "", fmt.Errorf("mint key: %w", err)
}
key, _ := tk["token"].(string)
if !strings.HasPrefix(key, "xVerae-") && !strings.HasPrefix(key, "key-") {
return "", fmt.Errorf("mint key: unexpected response (no xVerae- token)")
}
return key, nil
}
// ValidateXVeraeKey checks a key against zap.verae.ai GET /v1/status (documented
// as free). Returns "valid", "invalid", "badformat", "unreachable" or "unknown".
func ValidateXVeraeKey(key string) (string, string) {
key = strings.TrimSpace(key)
if !(strings.HasPrefix(key, "xVerae-") || strings.HasPrefix(key, "key-")) || len(key) < 16 || strings.ContainsAny(key, " \t\r\n") {
return "badformat", "must start with xVerae- (or legacy key-)"
}
req, _ := http.NewRequest(http.MethodGet, "https://zap.verae.ai/v1/status", nil)
req.Header.Set("x-api-key", key)
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "unreachable", err.Error()
}
res.Body.Close()
switch {
case res.StatusCode < 300:
return "valid", fmt.Sprintf("HTTP %d", res.StatusCode)
case res.StatusCode == 401 || res.StatusCode == 403:
return "invalid", fmt.Sprintf("HTTP %d from Verae", res.StatusCode)
default:
return "unknown", fmt.Sprintf("HTTP %d from Verae", res.StatusCode)
}
}
// MaskKey renders xVerae-...abcd.
func MaskKey(k string) string {
k = strings.TrimSpace(k)
if len(k) <= 12 {
if len(k) > 4 {
return k[:4] + "..."
}
return "..."
}
return k[:7] + "..." + k[len(k)-4:]
}

View file

@ -0,0 +1,192 @@
package auth
import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"unicode"
)
// Installer-friendly registration API (proposed; spec in
// docs/installer-registration-api.md). When the server does not implement it
// (GET /portal/api/install/capabilities → 404) the client falls back to the
// endpoints the portal web client uses today (/login, /signup, /tokens), which
// have no email-verification step.
// PasswordPolicy mirrors GET /portal/api/install/capabilities .passwordPolicy.
type PasswordPolicy struct {
MinLength int `json:"minLength"`
RequireLetter bool `json:"requireLetter"`
RequireDigit bool `json:"requireDigit"`
RequireSymbol bool `json:"requireSymbol"`
DisallowEmail bool `json:"disallowEmail"`
}
// DefaultPolicy is used when the server does not publish one (conservative).
var DefaultPolicy = PasswordPolicy{MinLength: 10, RequireLetter: true, RequireDigit: true, DisallowEmail: true}
// Capabilities describes what the server supports for installers.
type Capabilities struct {
InstallAPI bool
EmailVerification bool
Policy PasswordPolicy
}
// Describe renders the policy for UI text.
func (p PasswordPolicy) Describe() string {
parts := []string{fmt.Sprintf("at least %d characters", p.MinLength)}
if p.RequireLetter {
parts = append(parts, "a letter")
}
if p.RequireDigit {
parts = append(parts, "a number")
}
if p.RequireSymbol {
parts = append(parts, "a symbol")
}
if p.DisallowEmail {
parts = append(parts, "not containing your email")
}
return "Password: " + strings.Join(parts, ", ")
}
// Check validates a password client-side.
func (p PasswordPolicy) Check(pw, email string) error {
if len([]rune(pw)) < p.MinLength {
return fmt.Errorf("password must be at least %d characters", p.MinLength)
}
var l, d, s bool
for _, r := range pw {
switch {
case unicode.IsLetter(r):
l = true
case unicode.IsDigit(r):
d = true
case !unicode.IsSpace(r):
s = true
}
}
if p.RequireLetter && !l {
return errors.New("password must contain a letter")
}
if p.RequireDigit && !d {
return errors.New("password must contain a number")
}
if p.RequireSymbol && !s {
return errors.New("password must contain a symbol")
}
if p.DisallowEmail && email != "" {
local := strings.ToLower(strings.SplitN(email, "@", 2)[0])
if len(local) >= 3 && strings.Contains(strings.ToLower(pw), local) {
return errors.New("password must not contain your email name")
}
}
return nil
}
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
// ValidEmail is a light syntax check.
func ValidEmail(e string) bool { return emailRe.MatchString(strings.TrimSpace(e)) }
// MaskEmail renders g***@example.com for logs.
func MaskEmail(e string) string {
at := strings.LastIndex(e, "@")
if at < 1 {
return "***"
}
return e[:1] + "***" + e[at:]
}
// GetCapabilities probes GET /portal/api/install/capabilities.
func GetCapabilities() Capabilities {
c := Capabilities{Policy: DefaultPolicy}
req, _ := http.NewRequest(http.MethodGet, PortalBase()+"/portal/api/install/capabilities", nil)
req.Header.Set("User-Agent", "VeraePrintStamp-Installer")
res, err := portalHTTP.Do(req)
if err != nil {
return c
}
defer res.Body.Close()
if res.StatusCode != 200 || !strings.Contains(res.Header.Get("Content-Type"), "json") {
return c
}
var body struct {
InstallAPI bool `json:"installApi"`
EmailVerification bool `json:"emailVerification"`
PasswordPolicy *PasswordPolicy `json:"passwordPolicy"`
}
if err := decodeJSON(res, &body); err != nil {
return c
}
c.InstallAPI, c.EmailVerification = body.InstallAPI, body.EmailVerification
if body.PasswordPolicy != nil && body.PasswordPolicy.MinLength > 0 {
c.Policy = *body.PasswordPolicy
}
return c
}
// RegisterResult is returned by InstallRegister.
type RegisterResult struct {
PendingID string // non-empty → email code sent; call InstallVerify
Key string // non-empty → account created and key minted (fallback path)
}
// InstallRegister starts a new-account registration.
func InstallRegister(caps Capabilities, name, email, password, tokenName string) (RegisterResult, error) {
if !caps.InstallAPI {
// Fallback: current portal API creates the account immediately (no email code).
k, err := PortalMintKey(true, name, email, password, "", tokenName)
return RegisterResult{Key: k}, err
}
out, _, err := portalPost("/install/register", "", map[string]any{"name": name, "email": email, "password": password, "client": "VeraePrintStamp-Windows"})
if err != nil {
return RegisterResult{}, err
}
id, _ := out["pendingId"].(string)
if id == "" {
return RegisterResult{}, errors.New("register: server returned no pendingId")
}
return RegisterResult{PendingID: id}, nil
}
// InstallVerify submits the emailed code and returns the minted key.
func InstallVerify(pendingID, code, tokenName string) (string, error) {
out, _, err := portalPost("/install/verify", "", map[string]any{"pendingId": pendingID, "code": strings.TrimSpace(code), "tokenName": tokenName})
if err != nil {
return "", err
}
return keyFrom(out)
}
// InstallResend asks for a new code.
func InstallResend(pendingID string) error {
_, _, err := portalPost("/install/resend", "", map[string]any{"pendingId": pendingID})
return err
}
// InstallLogin signs in and mints a key (install API or fallback).
func InstallLogin(caps Capabilities, email, password, totp, tokenName string) (string, error) {
if !caps.InstallAPI {
return PortalMintKey(false, "", email, password, totp, tokenName)
}
body := map[string]any{"email": email, "password": password, "tokenName": tokenName}
if totp != "" {
body["totpCode"] = totp
}
out, _, err := portalPost("/install/login", "", body)
if err != nil {
return "", err
}
return keyFrom(out)
}
func keyFrom(out map[string]any) (string, error) {
k, _ := out["token"].(string)
if !strings.HasPrefix(k, "xVerae-") && !strings.HasPrefix(k, "key-") {
return "", errors.New("server returned no xVerae- token")
}
return k, nil
}

View file

@ -0,0 +1,116 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func fakePortal(t *testing.T, installAPI bool) *httptest.Server {
mux := http.NewServeMux()
js := func(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
body := func(r *http.Request) map[string]any {
m := map[string]any{}
_ = json.NewDecoder(r.Body).Decode(&m)
return m
}
if installAPI {
mux.HandleFunc("/portal/api/install/capabilities", func(w http.ResponseWriter, r *http.Request) {
js(w, 200, map[string]any{"installApi": true, "emailVerification": true, "passwordPolicy": map[string]any{"minLength": 12, "requireLetter": true, "requireDigit": true}})
})
mux.HandleFunc("/portal/api/install/register", func(w http.ResponseWriter, r *http.Request) { js(w, 202, map[string]any{"pendingId": "p1"}) })
mux.HandleFunc("/portal/api/install/verify", func(w http.ResponseWriter, r *http.Request) {
if body(r)["code"] != "123456" {
js(w, 400, map[string]any{"error": "invalid_code"})
return
}
js(w, 201, map[string]any{"token": "xVerae-verified-abcdefgh"})
})
mux.HandleFunc("/portal/api/install/login", func(w http.ResponseWriter, r *http.Request) {
b := body(r)
if b["totpCode"] == nil {
js(w, 401, map[string]any{"error": "totp_required"})
return
}
js(w, 200, map[string]any{"token": "xVerae-login-abcdefgh"})
})
}
mux.HandleFunc("/portal/api/login", func(w http.ResponseWriter, r *http.Request) { js(w, 200, map[string]any{"token": "sess"}) })
mux.HandleFunc("/portal/api/signup", func(w http.ResponseWriter, r *http.Request) { js(w, 200, map[string]any{"token": "sess"}) })
mux.HandleFunc("/portal/api/tokens", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sess" {
js(w, 401, map[string]any{"error": "no"})
return
}
js(w, 200, map[string]any{"token": "xVerae-minted-abcdefgh"})
})
mux.HandleFunc("/portal/api/logout", func(w http.ResponseWriter, r *http.Request) { js(w, 200, map[string]any{}) })
s := httptest.NewServer(mux)
t.Cleanup(s.Close)
t.Setenv("VERAE_PORTAL_BASE", s.URL)
t.Setenv("VERAE_ALLOW_INSECURE_PORTAL", "1")
return s
}
func TestPortalFallback(t *testing.T) {
fakePortal(t, false)
caps := GetCapabilities()
if caps.InstallAPI || caps.Policy.MinLength != DefaultPolicy.MinLength {
t.Fatalf("caps %+v", caps)
}
k, err := InstallLogin(caps, "a@b.co", "pw", "", "n")
if err != nil || k != "xVerae-minted-abcdefgh" {
t.Fatal(k, err)
}
rr, err := InstallRegister(caps, "N", "a@b.co", "pw", "n")
if err != nil || rr.Key == "" || rr.PendingID != "" {
t.Fatal(rr, err)
}
}
func TestPortalInstallAPI(t *testing.T) {
fakePortal(t, true)
caps := GetCapabilities()
if !caps.InstallAPI || caps.Policy.MinLength != 12 {
t.Fatalf("caps %+v", caps)
}
if _, err := InstallLogin(caps, "a@b.co", "pw", "", "n"); !errors.Is(err, ErrTOTPRequired) {
t.Fatal("want totp_required", err)
}
if k, err := InstallLogin(caps, "a@b.co", "pw", "123456", "n"); err != nil || k != "xVerae-login-abcdefgh" {
t.Fatal(k, err)
}
rr, err := InstallRegister(caps, "N", "a@b.co", "pw", "n")
if err != nil || rr.PendingID != "p1" {
t.Fatal(rr, err)
}
if _, err := InstallVerify("p1", "000000", "n"); err == nil {
t.Fatal("bad code accepted")
}
if k, err := InstallVerify("p1", "123456", "n"); err != nil || k != "xVerae-verified-abcdefgh" {
t.Fatal(k, err)
}
}
func TestPolicyAndHTTPS(t *testing.T) {
p := DefaultPolicy
for pw, ok := range map[string]bool{"short1": false, "longenoughnodigit": false, "longenough123": true, "george12345x": false} {
if (p.Check(pw, "george@x.com") == nil) != ok {
t.Errorf("policy %q want ok=%v", pw, ok)
}
}
t.Setenv("VERAE_PORTAL_BASE", "http://example.com")
t.Setenv("VERAE_ALLOW_INSECURE_PORTAL", "")
if _, _, err := portalPost("/login", "", map[string]any{}); err == nil {
t.Fatal("non-https portal allowed")
}
if MaskKey("xVerae-abcdefghijklmnop") != "xVerae-...mnop" || MaskEmail("george@x.com") != "g***@x.com" {
t.Fatal("masking")
}
}

View file

@ -12,7 +12,9 @@ What this build adds
C:\ProgramData\Verae\spool\fallback\verae-job.pdf. The same helper picks the C:\ProgramData\Verae\spool\fallback\verae-job.pdf. The same helper picks the
file up. file up.
* Heavy logging in C:\ProgramData\Verae\logs: * Heavy logging in C:\ProgramData\Verae\logs:
install-<time>.log full installer transcript (every step OK/FAIL) installer-<time>.log installer page/step log (no secrets)
installer-details-<time>.log copy of the installer's details pane
install-<time>.log printer-script transcript (every step OK/FAIL)
install-summary-<time>.json install-summary-<time>.json
helper-<user>.log per-job IDs like J20261003-190000-ipp-1 helper-<user>.log per-job IDs like J20261003-190000-ipp-1
cli-<user>.log cli-<user>.log
@ -21,17 +23,32 @@ What this build adds
Test steps Test steps
---------- ----------
1. Run VeraePrintStamp-windows-amd64-0.2.0-debug-setup.exe and accept the UAC 1. Double-click VeraePrintStamp-windows-amd64-0.3.0-debug-setup.exe and accept
prompt. If SmartScreen appears, choose More info > Run anyway (the build is the UAC prompt. If SmartScreen appears, choose More info > Run anyway (the
unsigned). build is unsigned). No console windows should appear at any point.
The install details pane should end with "Verae printer install: OK". 2. "Verae signing key" page: it shows your Windows user and any key it found
(The old per-user 0.1.2 install is removed automatically.) (masked, e.g. xVerae-AbC1...9xYz), and whether Verae accepted it. Choose:
2. Open PowerShell (not elevated) and check: * Keep the existing key, or
* Sign in: email + password (+ authenticator code if you use 2FA). A new
key named "Verae Print Stamp - <PC name> (windows)" is created and saved, or
* Create a new Verae account: name, email, password + confirm (the strength
rule is shown on the page). If the server supports it you get an emailed
6-digit code on the next page ("Send a new code" resends it), or
* Paste a key: "Open customer.verae.ai" > Security > create an xVerae- token,
then paste it. The key is checked with Verae before you can continue, or
* Skip for now: you are warned, and the helper asks for a key in a small
window the first time you print.
Your password goes only to https://customer.verae.ai and is never saved or
logged. Only the key is saved, in %USERPROFILE%\.verae\xverae-token,
readable only by you (and SYSTEM/Administrators).
3. The progress page shows each step. The finish page lists every step as
OK / FAIL / SKIP (files, key, spooler, autostart task, helper running as
you, both printers), with a link to open the logs folder. If something
failed, a verae-diag-*.zip is already in the logs folder.
Quick check in a normal PowerShell:
Get-Printer | ? Name -like 'Verae*' | ft Name,DriverName,PortName Get-Printer | ? Name -like 'Verae*' | ft Name,DriverName,PortName
verae-print ipp-selftest & "C:\Program Files\Verae\PrintStamp\bin\verae-print.exe" ipp-selftest
Expected: two printers, and "IPP helper OK". Expected: two printers, and "IPP helper OK".
3. If you have never saved a signing key: Start > Verae > Setup signing key.
This box already has %USERPROFILE%\.verae\xverae-token.
4. Open Notepad, type something, File > Print, choose "Verae TimeStamp PDF", 4. Open Notepad, type something, File > Print, choose "Verae TimeStamp PDF",
click Print. click Print.
Within about 10 s you should get a balloon "Verae timestamp <receipt>", and Within about 10 s you should get a balloon "Verae timestamp <receipt>", and

View file

@ -1,6 +1,6 @@
# Build the Windows DEBUG installer natively on Windows (Go + NSIS). # Build the Windows DEBUG installer natively on Windows (Go + NSIS).
# Usage: powershell -ExecutionPolicy Bypass -File platform\windows\build-windows-debug.ps1 [-Arch amd64|arm64] # Usage: powershell -ExecutionPolicy Bypass -File platform\windows\build-windows-debug.ps1 [-Arch amd64|arm64]
param([string]$Arch = "amd64", [string]$Version = "0.2.0-debug") param([string]$Arch = "amd64", [string]$Version = "0.3.0-debug")
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$Out = Join-Path $Root "dist\windows-debug" $Out = Join-Path $Root "dist\windows-debug"
@ -13,7 +13,7 @@ Push-Location $Root
try { try {
$env:CGO_ENABLED = "0"; $env:GOOS = "windows"; $env:GOARCH = $Arch $env:CGO_ENABLED = "0"; $env:GOOS = "windows"; $env:GOARCH = $Arch
& $Go version & $Go version
& $Go test ./internal/... ; if ($LASTEXITCODE) { throw "go test failed" } & $Go test ./internal/... ./cmd/... ; if ($LASTEXITCODE) { throw "go test failed" }
& $Go build -trimpath -o (Join-Path $Stage "verae-print.exe") ./cmd/verae-print; if ($LASTEXITCODE) { throw "build cli failed" } & $Go build -trimpath -o (Join-Path $Stage "verae-print.exe") ./cmd/verae-print; if ($LASTEXITCODE) { throw "build cli failed" }
& $Go build -trimpath -ldflags "-H windowsgui" -o (Join-Path $Stage "verae-printhelper.exe") ./cmd/verae-print; if ($LASTEXITCODE) { throw "build helper failed" } & $Go build -trimpath -ldflags "-H windowsgui" -o (Join-Path $Stage "verae-printhelper.exe") ./cmd/verae-print; if ($LASTEXITCODE) { throw "build helper failed" }
} finally { Pop-Location; Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue } } finally { Pop-Location; Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue }

View file

@ -1,7 +1,11 @@
; Verae Print Stamp — Windows DEBUG installer with "Verae TimeStamp PDF" printer. ; Verae Print Stamp — Windows DEBUG installer.
; Build: platform\windows\build-windows-debug.ps1 (on Windows) or makensis with -DSRCDIR. ; One interactive, elevated install: files, Verae key (detect / sign in / create
; account / paste / skip), IPP printer + Local Port fallback printer, helper as the
; logged-in user (scheduled task), autostart, diagnostics on failure, per-step
; results on the finish page. All scripts run hidden (nsExec); secrets are passed
; only through files in $PLUGINSDIR that are deleted immediately and are never logged.
!ifndef VERSION !ifndef VERSION
!define VERSION "0.2.0-debug" !define VERSION "0.3.0-debug"
!endif !endif
!ifndef ARCH !ifndef ARCH
!define ARCH "amd64" !define ARCH "amd64"
@ -15,6 +19,7 @@
!define PRODUCT_NAME "Verae Print Stamp (debug)" !define PRODUCT_NAME "Verae Print Stamp (debug)"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraePrintStampDebug" !define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraePrintStampDebug"
!define PORTAL_URL "https://customer.verae.ai/portal/"
Name "${PRODUCT_NAME} ${VERSION}" Name "${PRODUCT_NAME} ${VERSION}"
OutFile "${OUTDIR}\VeraePrintStamp-windows-${ARCH}-${VERSION}-setup.exe" OutFile "${OUTDIR}\VeraePrintStamp-windows-${ARCH}-${VERSION}-setup.exe"
@ -24,56 +29,558 @@ Unicode true
SetCompressor /SOLID lzma SetCompressor /SOLID lzma
ShowInstDetails show ShowInstDetails show
ShowUninstDetails show ShowUninstDetails show
BrandingText "Verae TimeStamp PDF ${VERSION}"
!include "MUI2.nsh" !include "MUI2.nsh"
!include "nsDialogs.nsh"
!include "x64.nsh" !include "x64.nsh"
!include "LogicLib.nsh" !include "LogicLib.nsh"
!include "FileFunc.nsh"
!include "WinMessages.nsh"
Var LogDir
Var LogFile
Var PS
Var Exe
; key page state
Var TargetUser
Var KeyPresent
Var KeyMasked
Var KeyCheck
Var KeyChoice ; keep|login|create|paste|skip
Var TokenReady ; 1 when $PLUGINSDIR\newtoken.txt holds a key to save
Var TokenMasked
Var PendingId
Var PolicyText
Var InstallApi
Var Checked
; controls
Var Dlg
Var hR1
Var hR2
Var hR3
Var hR4
Var hR5
Var hName
Var hEmail
Var hPass
Var hConfirm
Var hTotp
Var hCode
Var hKey
; results
Var FinishTitle
Var FinishText
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN "$INSTDIR\bin\verae-print.exe" !define MUI_FINISHPAGE_TITLE "$FinishTitle"
!define MUI_FINISHPAGE_RUN_PARAMETERS "setup" !define MUI_FINISHPAGE_TEXT "$FinishText"
!define MUI_FINISHPAGE_RUN_TEXT "Run signing-key setup (only needed if not done before)" !define MUI_FINISHPAGE_TEXT_LARGE
!define MUI_FINISHPAGE_RUN_NOTCHECKED !define MUI_FINISHPAGE_LINK "Open the logs folder"
!define MUI_FINISHPAGE_LINK_LOCATION "$LogDir"
!define MUI_FINISHPAGE_SHOWREADME "$INSTDIR\README-TEST.txt" !define MUI_FINISHPAGE_SHOWREADME "$INSTDIR\README-TEST.txt"
!define MUI_FINISHPAGE_SHOWREADME_TEXT "Open test instructions" !define MUI_FINISHPAGE_SHOWREADME_TEXT "Open test instructions"
!define MUI_FINISHPAGE_SHOWREADME_NOTCHECKED
!define MUI_FINISHPAGE_NOREBOOTSUPPORT
!insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "${SRCDIR}\LICENSE.txt" !insertmacro MUI_PAGE_LICENSE "${SRCDIR}\LICENSE.txt"
Page custom KeyPageCreate KeyPageLeave
Page custom LoginPageCreate LoginPageLeave
Page custom CreatePageCreate CreatePageLeave
Page custom VerifyPageCreate VerifyPageLeave
Page custom PastePageCreate PastePageLeave
!insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH !insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English" !insertmacro MUI_LANGUAGE "English"
!define PS '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass' ; ---------- logging (never pass secrets to these) ----------
!macro LOG text
Push `${text}`
Call WriteLog
!macroend
!define LOG "!insertmacro LOG"
Var LogDir Function WriteLog
Exch $0
Push $1
Push $2
${GetTime} "" "L" $R1 $R2 $R3 $R4 $R5 $R6 $R7
FileOpen $1 $LogFile a
${If} $1 != ""
FileSeek $1 0 END
FileWriteUTF16LE $1 "$R3-$R2-$R1 $R5:$R6:$R7 $0$\r$\n"
FileClose $1
${EndIf}
Pop $2
Pop $1
Pop $0
FunctionEnd
; Copy the install details list (incl. nsExec output) to a file.
!ifndef LVM_GETITEMCOUNT
!define LVM_GETITEMCOUNT 0x1004
!endif
!ifndef LVM_GETITEMTEXTW
!define LVM_GETITEMTEXTW 0x1073
!endif
Function DumpLog
Exch $5
Push $0
Push $1
Push $2
Push $3
Push $4
Push $6
FindWindow $0 "#32770" "" $HWNDPARENT
GetDlgItem $0 $0 1016
StrCmp $0 0 dl_exit
FileOpen $5 $5 "w"
StrCmp $5 "" dl_exit
FileWriteWord $5 0xFEFF
SendMessage $0 ${LVM_GETITEMCOUNT} 0 0 $6
System::Call '*(&t${NSIS_MAX_STRLEN})p.r3'
StrCpy $2 0
System::Call "*(i, i, i, i, i, p, i, i, i) p (0, 0, 0, 0, 0, r3, ${NSIS_MAX_STRLEN}) .r1"
dl_loop:
StrCmp $2 $6 dl_done
System::Call "User32::SendMessage(p, i, p, p) p ($0, ${LVM_GETITEMTEXTW}, $2, r1)"
System::Call "*$3(&t${NSIS_MAX_STRLEN} .r4)"
FileWriteUTF16LE $5 "$4$\r$\n"
IntOp $2 $2 + 1
Goto dl_loop
dl_done:
FileClose $5
System::Free $1
System::Free $3
dl_exit:
Pop $6
Pop $4
Pop $3
Pop $2
Pop $1
Pop $0
Pop $5
FunctionEnd
; write "Key=Value" request lines (UTF-16LE + BOM) — values may contain any chars
!macro REQ_OPEN path
FileOpen $9 `${path}` w
FileWriteWord $9 0xFEFF
!macroend
!macro REQ_KV key val
FileWriteUTF16LE $9 `${key}=${val}$\r$\n`
!macroend
!macro REQ_CLOSE
FileClose $9
!macroend
Function .onInit Function .onInit
ReadEnvStr $LogDir "ProgramData" ${IfNot} ${RunningX64}
StrCpy $LogDir "$LogDir\Verae\logs" MessageBox MB_ICONSTOP "This build targets 64-bit Windows."
${If} ${RunningX64}
SetRegView 64
${Else}
MessageBox MB_ICONSTOP "This debug build targets 64-bit Windows."
Abort Abort
${EndIf} ${EndIf}
SetRegView 64
SetShellVarContext all
ReadEnvStr $LogDir "ProgramData"
StrCpy $LogDir "$LogDir\Verae\logs"
CreateDirectory $LogDir
${GetTime} "" "L" $R1 $R2 $R3 $R4 $R5 $R6 $R7
StrCpy $LogFile "$LogDir\installer-$R3$R2$R1-$R5$R6$R7.log"
StrCpy $PS '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden'
InitPluginsDir
SetOutPath $PLUGINSDIR
File "${SRCDIR}\verae-print.exe"
File "${SRCDIR}\scripts\common.ps1"
File "${SRCDIR}\scripts\user-token.ps1"
StrCpy $Exe "$PLUGINSDIR\verae-print.exe"
StrCpy $Checked 0
StrCpy $TokenReady 0
StrCpy $KeyChoice "skip"
${LOG} "installer ${VERSION} start; elevated process; logdir=$LogDir"
FunctionEnd FunctionEnd
Function un.onInit Function un.onInit
SetRegView 64 SetRegView 64
SetShellVarContext all
FunctionEnd FunctionEnd
; ======================= Page: Verae key status / choice =======================
Function KeyPageCreate
!insertmacro MUI_HEADER_TEXT "Verae signing key" "Timestamps are registered with your Verae account."
${If} $Checked == 0
Banner::show /NOUNLOAD "Checking for an existing Verae key..."
nsExec::Exec '$PS -File "$PLUGINSDIR\user-token.ps1" -Mode status -IniOut "$PLUGINSDIR\tok.ini"'
Pop $0
nsExec::Exec '"$Exe" installer-account --op capabilities --out "$PLUGINSDIR\caps.ini"'
Pop $1
Banner::destroy
ReadINIStr $TargetUser "$PLUGINSDIR\tok.ini" token account
ReadINIStr $KeyPresent "$PLUGINSDIR\tok.ini" token present
ReadINIStr $KeyMasked "$PLUGINSDIR\tok.ini" token masked
ReadINIStr $KeyCheck "$PLUGINSDIR\tok.ini" token check
ReadINIStr $PolicyText "$PLUGINSDIR\caps.ini" account policyText
ReadINIStr $InstallApi "$PLUGINSDIR\caps.ini" account installApi
${LOG} "key status: user=$TargetUser present=$KeyPresent key=$KeyMasked check=$KeyCheck (ps exit $0); installApi=$InstallApi (exit $1)"
StrCpy $Checked 1
${EndIf}
nsDialogs::Create 1018
Pop $Dlg
${NSD_CreateLabel} 0 0 100% 12u "Windows user: $TargetUser"
Pop $0
${If} $KeyPresent == "1"
${If} $KeyCheck == "valid"
StrCpy $1 "An existing Verae key was found: $KeyMasked (verified with Verae)."
${ElseIf} $KeyCheck == "invalid"
StrCpy $1 "A saved key was found ($KeyMasked) but Verae REJECTED it. Please sign in or paste a new key."
${Else}
StrCpy $1 "An existing Verae key was found: $KeyMasked (could not verify right now: $KeyCheck)."
${EndIf}
${Else}
StrCpy $1 "No Verae key was found for this user. You need one to timestamp documents."
${EndIf}
${NSD_CreateLabel} 0 14u 100% 20u "$1"
Pop $0
${NSD_CreateRadioButton} 10u 38u 95% 12u "Keep the existing key ($KeyMasked)"
Pop $hR1
${NSD_CreateRadioButton} 10u 52u 95% 12u "Sign in to my Verae account (email + password) and create a key"
Pop $hR2
${NSD_CreateRadioButton} 10u 66u 95% 12u "Create a new Verae account"
Pop $hR3
${NSD_CreateRadioButton} 10u 80u 95% 12u "I already have an xVerae- key - paste it"
Pop $hR4
${NSD_CreateRadioButton} 10u 94u 95% 12u "Skip for now (the printer will ask for a key on first print)"
Pop $hR5
${NSD_CreateLabel} 0 112u 100% 24u "Your password is sent only to customer.verae.ai over HTTPS and is never stored; only the issued key is saved in $TargetUser's profile (.verae\xverae-token)."
Pop $0
${If} $KeyPresent == "1"
${AndIf} $KeyCheck != "invalid"
${NSD_Check} $hR1
${Else}
EnableWindow $hR1 0
${NSD_Check} $hR2
${EndIf}
nsDialogs::Show
FunctionEnd
Function KeyPageLeave
${NSD_GetState} $hR1 $0
${NSD_GetState} $hR2 $1
${NSD_GetState} $hR3 $2
${NSD_GetState} $hR4 $3
${If} $0 == ${BST_CHECKED}
StrCpy $KeyChoice "keep"
${ElseIf} $1 == ${BST_CHECKED}
StrCpy $KeyChoice "login"
${ElseIf} $2 == ${BST_CHECKED}
StrCpy $KeyChoice "create"
${ElseIf} $3 == ${BST_CHECKED}
StrCpy $KeyChoice "paste"
${Else}
StrCpy $KeyChoice "skip"
MessageBox MB_YESNO|MB_ICONEXCLAMATION "Continue without a Verae key?$\r$\n$\r$\nThe printer will be installed, but documents cannot be timestamped until you add a key. On your first print you will be asked for one (or use Start > Verae > Setup signing key)." IDYES +2
Abort
${EndIf}
StrCpy $TokenReady 0
StrCpy $PendingId ""
${LOG} "key choice: $KeyChoice"
FunctionEnd
; ======================= Page: sign in =======================
Function LoginPageCreate
${If} $KeyChoice != "login"
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Sign in to Verae" "A new signing key will be created for this PC."
nsDialogs::Create 1018
Pop $Dlg
${NSD_CreateLabel} 0 0 30% 12u "Email"
Pop $0
${NSD_CreateText} 30% 0 70% 12u ""
Pop $hEmail
${NSD_CreateLabel} 0 18u 30% 12u "Password"
Pop $0
${NSD_CreatePassword} 30% 18u 70% 12u ""
Pop $hPass
${NSD_CreateLabel} 0 36u 30% 12u "Authenticator code"
Pop $0
${NSD_CreateText} 30% 36u 30% 12u ""
Pop $hTotp
${NSD_CreateLabel} 62% 37u 38% 12u "(only if you use 2FA)"
Pop $0
${NSD_CreateLabel} 0 60u 100% 24u "Forgot your password or prefer the website? Open the customer portal, create an xVerae- key under Security, then go Back and choose 'paste it'."
Pop $0
${NSD_CreateLink} 0 86u 100% 12u "Open customer.verae.ai in your browser"
Pop $0
${NSD_OnClick} $0 OpenPortal
${NSD_SetFocus} $hEmail
nsDialogs::Show
FunctionEnd
Function OpenPortal
Pop $0
ExecShell "open" "${PORTAL_URL}"
FunctionEnd
Function LoginPageLeave
${NSD_GetText} $hEmail $1
${NSD_GetText} $hPass $2
${NSD_GetText} $hTotp $3
${If} $1 == ""
${OrIf} $2 == ""
MessageBox MB_ICONEXCLAMATION "Please enter your email and password."
Abort
${EndIf}
!insertmacro REQ_OPEN "$PLUGINSDIR\req.txt"
!insertmacro REQ_KV "Email" "$1"
!insertmacro REQ_KV "Password" "$2"
!insertmacro REQ_KV "Totp" "$3"
!insertmacro REQ_CLOSE
StrCpy $2 ""
${LOG} "sign-in attempt"
Banner::show /NOUNLOAD "Signing in to Verae..."
nsExec::Exec '"$Exe" installer-account --op login --in "$PLUGINSDIR\req.txt" --out "$PLUGINSDIR\acct.ini" --token-out "$PLUGINSDIR\newtoken.txt"'
Pop $0
Banner::destroy
Delete "$PLUGINSDIR\req.txt"
Call HandleAccountResult
FunctionEnd
; reads acct.ini; on success sets TokenReady, else Abort (stay on page)
Function HandleAccountResult
ReadINIStr $4 "$PLUGINSDIR\acct.ini" account result
ReadINIStr $5 "$PLUGINSDIR\acct.ini" account detail
ReadINIStr $TokenMasked "$PLUGINSDIR\acct.ini" account masked
Delete "$PLUGINSDIR\acct.ini"
${LOG} "account result: $4 ($5)"
${If} $4 == "ok"
StrCpy $TokenReady 1
MessageBox MB_ICONINFORMATION "A new Verae key was created for this PC.$\r$\nIt will be saved for $TargetUser."
${ElseIf} $4 == "code_sent"
; handled by caller
${ElseIf} $4 == "totp_required"
MessageBox MB_ICONINFORMATION "$5"
${NSD_SetFocus} $hTotp
Abort
${Else}
${If} $4 == ""
StrCpy $5 "No response from the helper (exit $0)."
${EndIf}
MessageBox MB_ICONEXCLAMATION "Verae said: $5"
Abort
${EndIf}
FunctionEnd
; ======================= Page: create account =======================
Function CreatePageCreate
${If} $KeyChoice != "create"
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Create a Verae account" "Your account holds your timestamps and signing keys."
nsDialogs::Create 1018
Pop $Dlg
${NSD_CreateLabel} 0 0 30% 12u "Name"
Pop $0
${NSD_CreateText} 30% 0 70% 12u ""
Pop $hName
${NSD_CreateLabel} 0 16u 30% 12u "Email"
Pop $0
${NSD_CreateText} 30% 16u 70% 12u ""
Pop $hEmail
${NSD_CreateLabel} 0 32u 30% 12u "Password"
Pop $0
${NSD_CreatePassword} 30% 32u 70% 12u ""
Pop $hPass
${NSD_CreateLabel} 0 48u 30% 12u "Confirm password"
Pop $0
${NSD_CreatePassword} 30% 48u 70% 12u ""
Pop $hConfirm
${If} $PolicyText == ""
StrCpy $PolicyText "Password: at least 10 characters, a letter and a number"
${EndIf}
${NSD_CreateLabel} 30% 64u 70% 20u "$PolicyText"
Pop $0
${If} $InstallApi == "1"
StrCpy $1 "We will email you a 6-digit code to verify your address."
${Else}
StrCpy $1 "Your account is created immediately (this Verae server does not send verification codes yet)."
${EndIf}
${NSD_CreateLabel} 0 88u 100% 20u "$1"
Pop $0
${NSD_SetFocus} $hName
nsDialogs::Show
FunctionEnd
Function CreatePageLeave
${NSD_GetText} $hName $1
${NSD_GetText} $hEmail $2
${NSD_GetText} $hPass $3
${NSD_GetText} $hConfirm $6
${If} $3 != $6
MessageBox MB_ICONEXCLAMATION "The passwords do not match."
Abort
${EndIf}
!insertmacro REQ_OPEN "$PLUGINSDIR\req.txt"
!insertmacro REQ_KV "Name" "$1"
!insertmacro REQ_KV "Email" "$2"
!insertmacro REQ_KV "Password" "$3"
!insertmacro REQ_KV "Confirm" "$6"
!insertmacro REQ_CLOSE
StrCpy $3 ""
StrCpy $6 ""
${LOG} "create-account attempt"
Banner::show /NOUNLOAD "Creating your Verae account..."
nsExec::Exec '"$Exe" installer-account --op register --in "$PLUGINSDIR\req.txt" --out "$PLUGINSDIR\acct.ini" --token-out "$PLUGINSDIR\newtoken.txt"'
Pop $0
Banner::destroy
Delete "$PLUGINSDIR\req.txt"
ReadINIStr $4 "$PLUGINSDIR\acct.ini" account result
${If} $4 == "code_sent"
ReadINIStr $PendingId "$PLUGINSDIR\acct.ini" account pendingId
ReadINIStr $5 "$PLUGINSDIR\acct.ini" account detail
${LOG} "verification code sent"
Delete "$PLUGINSDIR\acct.ini"
${Else}
Call HandleAccountResult
${EndIf}
FunctionEnd
; ======================= Page: email verification code =======================
Function VerifyPageCreate
${If} $PendingId == ""
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Verify your email" "Enter the code we sent you."
nsDialogs::Create 1018
Pop $Dlg
${NSD_CreateLabel} 0 0 100% 24u "We emailed a 6-digit verification code to the address you entered. It may take a minute; check your spam folder."
Pop $0
${NSD_CreateLabel} 0 30u 30% 12u "Code"
Pop $0
${NSD_CreateText} 30% 30u 30% 12u ""
Pop $hCode
${NSD_CreateButton} 30% 50u 30% 14u "Send a new code"
Pop $0
${NSD_OnClick} $0 ResendCode
${NSD_SetFocus} $hCode
nsDialogs::Show
FunctionEnd
Function ResendCode
Pop $0
!insertmacro REQ_OPEN "$PLUGINSDIR\req.txt"
!insertmacro REQ_KV "PendingId" "$PendingId"
!insertmacro REQ_CLOSE
nsExec::Exec '"$Exe" installer-account --op resend --in "$PLUGINSDIR\req.txt" --out "$PLUGINSDIR\acct.ini"'
Pop $0
ReadINIStr $5 "$PLUGINSDIR\acct.ini" account detail
Delete "$PLUGINSDIR\acct.ini"
MessageBox MB_ICONINFORMATION "$5"
FunctionEnd
Function VerifyPageLeave
${NSD_GetText} $hCode $1
!insertmacro REQ_OPEN "$PLUGINSDIR\req.txt"
!insertmacro REQ_KV "PendingId" "$PendingId"
!insertmacro REQ_KV "Code" "$1"
!insertmacro REQ_CLOSE
Banner::show /NOUNLOAD "Verifying..."
nsExec::Exec '"$Exe" installer-account --op verify --in "$PLUGINSDIR\req.txt" --out "$PLUGINSDIR\acct.ini" --token-out "$PLUGINSDIR\newtoken.txt"'
Pop $0
Banner::destroy
Delete "$PLUGINSDIR\req.txt"
Call HandleAccountResult
FunctionEnd
; ======================= Page: paste key =======================
Function PastePageCreate
${If} $KeyChoice != "paste"
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Paste your Verae key" "Keys start with xVerae- and are shown once when created."
nsDialogs::Create 1018
Pop $Dlg
${NSD_CreateLabel} 0 0 100% 30u "1. Click the button to open the Verae customer portal and sign in.$\r$\n2. Open Security and create an xVerae- token (it is shown only once).$\r$\n3. Copy it and paste it below, then click Next."
Pop $0
${NSD_CreateButton} 0 34u 60% 14u "Open customer.verae.ai"
Pop $0
${NSD_OnClick} $0 OpenPortal
${NSD_CreateLabel} 0 56u 100% 12u "xVerae- key"
Pop $0
${NSD_CreatePassword} 0 70u 100% 12u ""
Pop $hKey
${NSD_SetFocus} $hKey
nsDialogs::Show
FunctionEnd
Function PastePageLeave
${NSD_GetText} $hKey $1
${If} $1 == ""
MessageBox MB_ICONEXCLAMATION "Please paste your key (or go Back and choose Skip)."
Abort
${EndIf}
FileOpen $9 "$PLUGINSDIR\newtoken.txt" w
FileWriteWord $9 0xFEFF
FileWriteUTF16LE $9 "$1"
FileClose $9
StrCpy $1 ""
Banner::show /NOUNLOAD "Checking the key with Verae..."
nsExec::Exec '"$Exe" validate-key --token-file "$PLUGINSDIR\newtoken.txt" --out "$PLUGINSDIR\val.ini"'
Pop $0
Banner::destroy
ReadINIStr $4 "$PLUGINSDIR\val.ini" validate result
ReadINIStr $5 "$PLUGINSDIR\val.ini" validate detail
ReadINIStr $TokenMasked "$PLUGINSDIR\val.ini" validate masked
Delete "$PLUGINSDIR\val.ini"
${LOG} "pasted key $TokenMasked: $4 ($5)"
${If} $4 == "valid"
StrCpy $TokenReady 1
${ElseIf} $4 == "invalid"
Delete "$PLUGINSDIR\newtoken.txt"
MessageBox MB_ICONEXCLAMATION "Verae rejected this key ($5). Please check it and paste again."
Abort
${ElseIf} $4 == "badformat"
Delete "$PLUGINSDIR\newtoken.txt"
MessageBox MB_ICONEXCLAMATION "That does not look like a Verae key: $5"
Abort
${Else}
MessageBox MB_YESNO|MB_ICONQUESTION "Could not reach Verae to check the key ($5).$\r$\nSave it anyway?" IDYES +3
Delete "$PLUGINSDIR\newtoken.txt"
Abort
StrCpy $TokenReady 1
${EndIf}
FunctionEnd
; ======================= Install =======================
!macro STEP_RESULT label value
StrCpy $FinishText "$FinishText${label}: ${value}$\r$\n"
!macroend
Section "Verae Print Stamp + printer" SecMain Section "Verae Print Stamp + printer" SecMain
SectionIn RO SectionIn RO
; stop a running helper so files can be replaced SetDetailsPrint both
DetailPrint "Installer log: $LogFile"
${LOG} "section start; target=$TargetUser keyChoice=$KeyChoice tokenReady=$TokenReady"
DetailPrint "Stopping running helper (if any)"
nsExec::ExecToLog 'schtasks /End /TN "Verae Print Helper"'
Pop $0
nsExec::ExecToLog 'taskkill /F /IM verae-printhelper.exe' nsExec::ExecToLog 'taskkill /F /IM verae-printhelper.exe'
Pop $0 Pop $0
StrCpy $R9 "OK"
ClearErrors
SetOutPath "$INSTDIR\bin" SetOutPath "$INSTDIR\bin"
File "${SRCDIR}\verae-print.exe" File "${SRCDIR}\verae-print.exe"
File "${SRCDIR}\verae-printhelper.exe" File "${SRCDIR}\verae-printhelper.exe"
SetOutPath "$INSTDIR\scripts" SetOutPath "$INSTDIR\scripts"
File "${SRCDIR}\scripts\common.ps1"
File "${SRCDIR}\scripts\user-token.ps1"
File "${SRCDIR}\scripts\install-printer.ps1" File "${SRCDIR}\scripts\install-printer.ps1"
File "${SRCDIR}\scripts\uninstall-printer.ps1" File "${SRCDIR}\scripts\uninstall-printer.ps1"
File "${SRCDIR}\scripts\verae-diag.ps1" File "${SRCDIR}\scripts\verae-diag.ps1"
@ -81,37 +588,85 @@ Section "Verae Print Stamp + printer" SecMain
File "${SRCDIR}\README-TEST.txt" File "${SRCDIR}\README-TEST.txt"
File "${SRCDIR}\LICENSE.txt" File "${SRCDIR}\LICENSE.txt"
WriteUninstaller "$INSTDIR\Uninstall.exe" WriteUninstaller "$INSTDIR\Uninstall.exe"
${If} ${Errors}
; Remove the old per-user 0.1.2 CLI-only install (it shadows PATH and has no printer) StrCpy $R9 "FAIL"
DetailPrint "Removing old per-user install in $LOCALAPPDATA\Verae\PrintStamp (if any)" ${EndIf}
Delete "$LOCALAPPDATA\Verae\PrintStamp\bin\verae-print.exe" ${LOG} "files: $R9"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraePrintStamp"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}" WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall.exe"' WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall.exe"'
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae" WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae"
CreateDirectory "$SMPROGRAMS\Verae" CreateDirectory "$SMPROGRAMS\Verae"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Collect diagnostics.lnk" "$WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" '-NoProfile -ExecutionPolicy Bypass -NoExit -File "$INSTDIR\scripts\verae-diag.ps1"' CreateShortCut "$SMPROGRAMS\Verae\Verae - Setup signing key.lnk" "$INSTDIR\bin\verae-printhelper.exe" "key-dialog"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Setup signing key.lnk" "$INSTDIR\bin\verae-print.exe" "setup"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Start print helper.lnk" "$INSTDIR\bin\verae-printhelper.exe" CreateShortCut "$SMPROGRAMS\Verae\Verae - Start print helper.lnk" "$INSTDIR\bin\verae-printhelper.exe"
CreateShortCut "$SMPROGRAMS\Verae\Verae - Logs folder.lnk" "$WINDIR\explorer.exe" '"$LogDir"' CreateShortCut "$SMPROGRAMS\Verae\Verae - Logs folder.lnk" "$WINDIR\explorer.exe" '"$LogDir"'
CreateShortCut "$SMPROGRAMS\Verae\Verae - Collect diagnostics.lnk" "$WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\scripts\verae-diag.ps1"'
CreateShortCut "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" "$INSTDIR\Uninstall.exe" CreateShortCut "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" "$INSTDIR\Uninstall.exe"
DetailPrint "Installing printer(s) — full transcript in $LogDir" StrCpy $FinishText ""
nsExec::ExecToLog '${PS} -File "$INSTDIR\scripts\install-printer.ps1" -InstallDir "$INSTDIR"' !insertmacro STEP_RESULT "Program files" "$R9"
Pop $0
${If} $0 == "0" ; ---- key ----
DetailPrint "Verae printer install: OK" ${If} $TokenReady == "1"
DetailPrint "Saving Verae key ($TokenMasked) for $TargetUser"
nsExec::ExecToLog '$PS -File "$INSTDIR\scripts\user-token.ps1" -Mode save -TokenFile "$PLUGINSDIR\newtoken.txt" -IniOut "$PLUGINSDIR\save.ini"'
Pop $0
Delete "$PLUGINSDIR\newtoken.txt"
ReadINIStr $1 "$PLUGINSDIR\save.ini" save result
ReadINIStr $2 "$PLUGINSDIR\save.ini" save path
ReadINIStr $3 "$PLUGINSDIR\save.ini" save detail
${LOG} "key save: $1 $2 $3"
!insertmacro STEP_RESULT "Verae key" "$1 - $TokenMasked saved to $2 $3"
${ElseIf} $KeyChoice == "keep"
!insertmacro STEP_RESULT "Verae key" "OK - kept existing $KeyMasked"
${Else} ${Else}
DetailPrint "Verae printer install: FAILED (exit $0) — run Start menu > Verae > Collect diagnostics" !insertmacro STEP_RESULT "Verae key" "SKIPPED - you will be asked on first print"
MessageBox MB_ICONEXCLAMATION "Printer setup reported a problem (exit $0).$\r$\nLogs: $LogDir$\r$\nPlease run Start > Verae > Collect diagnostics and send the zip from your Desktop."
${EndIf} ${EndIf}
; ---- printers + helper ----
DetailPrint "Installing printers and starting the helper (transcript in $LogDir)"
nsExec::ExecToLog '$PS -File "$INSTDIR\scripts\install-printer.ps1" -InstallDir "$INSTDIR" -ResultIni "$PLUGINSDIR\printer.ini"'
Pop $0
${LOG} "install-printer.ps1 exit=$0"
ReadINIStr $1 "$PLUGINSDIR\printer.ini" printer task
ReadINIStr $2 "$PLUGINSDIR\printer.ini" printer helper
ReadINIStr $3 "$PLUGINSDIR\printer.ini" printer ipp
ReadINIStr $4 "$PLUGINSDIR\printer.ini" printer fallback
ReadINIStr $5 "$PLUGINSDIR\printer.ini" printer overall
ReadINIStr $6 "$PLUGINSDIR\printer.ini" printer spooler
ReadINIStr $7 "$PLUGINSDIR\printer.ini" printer oplog
${If} $5 == ""
StrCpy $5 "FAIL"
StrCpy $3 "FAIL - script did not report (exit $0)"
${EndIf}
!insertmacro STEP_RESULT "Print Spooler" "$6"
!insertmacro STEP_RESULT "PrintService log" "$7"
!insertmacro STEP_RESULT "Helper autostart task" "$1"
!insertmacro STEP_RESULT "Helper running as you" "$2"
!insertmacro STEP_RESULT "Printer 'Verae TimeStamp PDF'" "$3"
!insertmacro STEP_RESULT "Printer '(Local Port)'" "$4"
${LOG} "results: spooler=$6 oplog=$7 task=$1 helper=$2 ipp=$3 fallback=$4 overall=$5"
${If} $5 == "OK"
StrCpy $FinishTitle "Verae TimeStamp PDF is installed"
StrCpy $FinishText "$FinishText$\r$\nPrint from any app and choose 'Verae TimeStamp PDF'. Results go to Documents\Verae Timestamps."
${Else}
StrCpy $FinishTitle "Installed with problems"
DetailPrint "Problem detected - collecting diagnostics"
nsExec::ExecToLog '$PS -File "$INSTDIR\scripts\verae-diag.ps1" -OutDir "$LogDir"'
Pop $0
${LOG} "diagnostics collected (exit $0)"
StrCpy $FinishText "$FinishText$\r$\nA diagnostics zip (verae-diag-*.zip) was saved in the logs folder. Please send it to support."
${EndIf}
${LOG} "section end"
${GetTime} "" "L" $R1 $R2 $R3 $R4 $R5 $R6 $R7
Push "$LogDir\installer-details-$R3$R2$R1-$R5$R6$R7.log"
Call DumpLog
SectionEnd SectionEnd
Section "Uninstall" Section "Uninstall"
nsExec::ExecToLog '${PS} -File "$INSTDIR\scripts\uninstall-printer.ps1"' nsExec::ExecToLog '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "$INSTDIR\scripts\uninstall-printer.ps1"'
Pop $0 Pop $0
nsExec::ExecToLog 'taskkill /F /IM verae-printhelper.exe' nsExec::ExecToLog 'taskkill /F /IM verae-printhelper.exe'
Pop $0 Pop $0
@ -128,5 +683,5 @@ Section "Uninstall"
RMDir "$SMPROGRAMS\Verae" RMDir "$SMPROGRAMS\Verae"
DeleteRegKey HKLM "${UNINST_KEY}" DeleteRegKey HKLM "${UNINST_KEY}"
DeleteRegKey HKLM "Software\Verae\PrintStamp" DeleteRegKey HKLM "Software\Verae\PrintStamp"
; logs in %ProgramData%\Verae\logs are kept on purpose ; logs and the user's .verae\xverae-token are kept on purpose
SectionEnd SectionEnd

View file

@ -0,0 +1,53 @@
# Shared helpers for the Verae installer scripts (dot-source me).
# The interactive (logged-in, non-elevated) user of THIS session — not the admin
# account that may have been used for UAC elevation. Owner of explorer.exe in our
# session; falls back to Win32_ComputerSystem.UserName (console user).
function Get-VeraeTargetUser {
$sess = (Get-Process -Id $PID).SessionId
$res = $null
foreach ($p in @(Get-CimInstance Win32_Process -Filter "Name='explorer.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq $sess })) {
$o = Invoke-CimMethod -InputObject $p -MethodName GetOwner -ErrorAction SilentlyContinue
$s = Invoke-CimMethod -InputObject $p -MethodName GetOwnerSid -ErrorAction SilentlyContinue
if ($o -and $o.User) { $res = [pscustomobject]@{ Domain = $o.Domain; User = $o.User; Sid = $s.Sid; Source = "explorer(session $sess)" }; break }
}
if (-not $res) {
$cu = (Get-CimInstance Win32_ComputerSystem).UserName
if ($cu) {
$d, $u = $cu -split '\\', 2
$sid = (New-Object Security.Principal.NTAccount($cu)).Translate([Security.Principal.SecurityIdentifier]).Value
$res = [pscustomobject]@{ Domain = $d; User = $u; Sid = $sid; Source = "console user" }
}
}
if (-not $res) {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$d, $u = $id.Name -split '\\', 2
$res = [pscustomobject]@{ Domain = $d; User = $u; Sid = $id.User.Value; Source = "current process (fallback)" }
}
$prof = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$($res.Sid)" -ErrorAction SilentlyContinue).ProfileImagePath
if (-not $prof) { $prof = Join-Path (Split-Path $env:USERPROFILE -Parent) $res.User }
$res | Add-Member -NotePropertyName Profile -NotePropertyValue $prof
$res | Add-Member -NotePropertyName Account -NotePropertyValue "$($res.Domain)\$($res.User)"
return $res
}
function Get-MaskedToken([string]$t) {
if (-not $t) { return "" }
$t = $t.Trim()
if ($t.Length -le 12) { return ($t.Substring(0, [Math]::Min(4, $t.Length)) + "...") }
return ($t.Substring(0, 7) + "..." + $t.Substring($t.Length - 4))
}
# Write a flat INI (ASCII) readable by NSIS ReadINIStr.
function Write-VeraeIni([string]$Path, [string]$Section, [System.Collections.IDictionary]$Values) {
$lines = @("[$Section]")
foreach ($k in $Values.Keys) { $v = "$($Values[$k])" -replace "[\r\n]+", " "; $lines += "$k=$v" }
if (Test-Path $Path) {
# keep other sections
$old = Get-Content $Path -ErrorAction SilentlyContinue
$keep = @(); $skip = $false
foreach ($l in $old) { if ($l -match '^\[(.+)\]$') { $skip = ($Matches[1] -eq $Section) }; if (-not $skip) { $keep += $l } }
$lines = $keep + $lines
}
Set-Content -Path $Path -Value $lines -Encoding ASCII
}

View file

@ -5,7 +5,8 @@ param(
[string]$InstallDir = (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)), [string]$InstallDir = (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)),
[switch]$SkipIpp, [switch]$SkipIpp,
[switch]$SkipFallback, [switch]$SkipFallback,
[string]$IppAddr = "127.0.0.1:8631" [string]$IppAddr = "127.0.0.1:8631",
[string]$ResultIni = ""
) )
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
$PrinterName = "Verae TimeStamp PDF" $PrinterName = "Verae TimeStamp PDF"
@ -18,6 +19,8 @@ $Helper = Join-Path $Bin "verae-printhelper.exe"
$Cli = Join-Path $Bin "verae-print.exe" $Cli = Join-Path $Bin "verae-print.exe"
$IppUrl = "http://$IppAddr/ipp/print" $IppUrl = "http://$IppAddr/ipp/print"
$ts = Get-Date -Format "yyyyMMdd-HHmmss" $ts = Get-Date -Format "yyyyMMdd-HHmmss"
. (Join-Path $PSScriptRoot "common.ps1")
$TaskName = "Verae Print Helper"
New-Item -ItemType Directory -Force -Path $LogDir, $DropDir | Out-Null New-Item -ItemType Directory -Force -Path $LogDir, $DropDir | Out-Null
$Transcript = Join-Path $LogDir "install-$ts.log" $Transcript = Join-Path $LogDir "install-$ts.log"
Start-Transcript -Path $Transcript -Force | Out-Null Start-Transcript -Path $Transcript -Force | Out-Null
@ -39,6 +42,8 @@ Write-Host "Verae printer install (debug) $ts"
Write-Host "InstallDir=$InstallDir User=$(whoami) PS=$($PSVersionTable.PSVersion) 64bitProcess=$([Environment]::Is64BitProcess)" Write-Host "InstallDir=$InstallDir User=$(whoami) PS=$($PSVersionTable.PSVersion) 64bitProcess=$([Environment]::Is64BitProcess)"
$os = Get-CimInstance Win32_OperatingSystem $os = Get-CimInstance Win32_OperatingSystem
Write-Host "OS: $($os.Caption) $($os.Version) build $($os.BuildNumber) $($os.OSArchitecture)" Write-Host "OS: $($os.Caption) $($os.Version) build $($os.BuildNumber) $($os.OSArchitecture)"
$Target = Get-VeraeTargetUser
Write-Host "Target (logged-in) user: $($Target.Account) sid=$($Target.Sid) profile=$($Target.Profile) via $($Target.Source)"
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
Write-Host "Elevated: $isAdmin" Write-Host "Elevated: $isAdmin"
if (-not $isAdmin) { Write-Host "ERROR: not elevated — printer/port creation will fail (Access denied)." } if (-not $isAdmin) { Write-Host "ERROR: not elevated — printer/port creation will fail (Access denied)." }
@ -63,9 +68,19 @@ Step "Debug flag HKLM\Software\Verae\PrintStamp\Debug=1" {
Set-ItemProperty 'HKLM:\Software\Verae\PrintStamp' -Name Debug -Value 1 -Type DWord Set-ItemProperty 'HKLM:\Software\Verae\PrintStamp' -Name Debug -Value 1 -Type DWord
Set-ItemProperty 'HKLM:\Software\Verae\PrintStamp' -Name InstallDir -Value $InstallDir Set-ItemProperty 'HKLM:\Software\Verae\PrintStamp' -Name InstallDir -Value $InstallDir
} { (Get-ItemProperty 'HKLM:\Software\Verae\PrintStamp').Debug -eq 1 } | Out-Null } { (Get-ItemProperty 'HKLM:\Software\Verae\PrintStamp').Debug -eq 1 } | Out-Null
Step "Helper autostart (HKLM Run)" { $script:taskOk = Step "Helper autostart: scheduled task '$TaskName' for $($Target.Account) (at logon, non-elevated)" {
Set-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name VeraePrintHelper -Value "`"$Helper`"" Remove-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name VeraePrintHelper -ErrorAction SilentlyContinue
} { (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run').VeraePrintHelper -like "*verae-printhelper.exe*" } | Out-Null $action = New-ScheduledTaskAction -Execute $Helper -WorkingDirectory $Bin
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $Target.Account
$principal = New-ScheduledTaskPrincipal -UserId $Target.Account -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Verae TimeStamp PDF print helper (IPP loopback 127.0.0.1:8631)" -Force | Out-Null
} { $t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue; if ($t) { Write-Host " task principal=$($t.Principal.UserId) runlevel=$($t.Principal.RunLevel)" }; [bool]$t }
Step "Remove old per-user 0.1.2 install for $($Target.Account)" {
$old = Join-Path $Target.Profile "AppData\Local\Verae\PrintStamp\bin\verae-print.exe"
if (Test-Path $old) { Remove-Item -Force $old; Write-Host " removed $old" } else { Write-Host " none" }
Remove-Item "Registry::HKEY_USERS\$($Target.Sid)\Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraePrintStamp" -Recurse -ErrorAction SilentlyContinue
} $null | Out-Null
Step "Add bin to machine PATH" { Step "Add bin to machine PATH" {
$p = [Environment]::GetEnvironmentVariable("Path","Machine") $p = [Environment]::GetEnvironmentVariable("Path","Machine")
if ($p -notlike "*$Bin*") { [Environment]::SetEnvironmentVariable("Path", ($p.TrimEnd(';') + ";" + $Bin), "Machine") } if ($p -notlike "*$Bin*") { [Environment]::SetEnvironmentVariable("Path", ($p.TrimEnd(';') + ";" + $Bin), "Machine") }
@ -75,22 +90,24 @@ function Test-Ipp { try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3
# ---------------- Primary: IPP class driver → loopback helper ---------------- # ---------------- Primary: IPP class driver → loopback helper ----------------
if (-not $SkipIpp) { if (-not $SkipIpp) {
$helperUp = Step "Helper listening on $IppAddr" { $script:tempHelper = $null
Get-Process verae-printhelper -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " existing helper pid=$($_.Id)" } $helperUp = Step "Start helper as $($Target.Account) and wait for $IppAddr" {
if (-not (Test-Ipp)) { if (-not (Test-Ipp)) {
# explorer.exe launches the exe with the (non-elevated) shell token = the real user session if ($script:taskOk) { Write-Host " Start-ScheduledTask '$TaskName'"; Start-ScheduledTask -TaskName $TaskName }
Write-Host " starting helper un-elevated via explorer.exe" for ($i = 0; $i -lt 30 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 }
}
if (-not (Test-Ipp)) {
Write-Host " task did not bring IPP up; trying explorer.exe (shell token) launch"
Start-Process explorer.exe -ArgumentList "`"$Helper`"" Start-Process explorer.exe -ArgumentList "`"$Helper`""
for ($i = 0; $i -lt 20 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 } for ($i = 0; $i -lt 20 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 }
} }
if (-not (Test-Ipp)) { if (-not (Test-Ipp)) {
Write-Host " explorer launch did not bring IPP up; starting elevated temporary helper for discovery" Write-Host " still down; starting a TEMPORARY elevated helper only for printer discovery (stopped afterwards)"
Start-Process -FilePath $Helper -WindowStyle Hidden $script:tempHelper = Start-Process -FilePath $Cli -ArgumentList "serve --no-watch" -WindowStyle Hidden -PassThru
for ($i = 0; $i -lt 20 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 } for ($i = 0; $i -lt 20 -and -not (Test-Ipp); $i++) { Start-Sleep -Milliseconds 500 }
} }
& $Cli ipp-selftest --addr $IppAddr | Out-Host & $Cli ipp-selftest --addr $IppAddr | Out-Host
} { Test-Ipp } } { Test-Ipp }
if ($helperUp) { if ($helperUp) {
Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " removing stale '$($_.Name)'"; Remove-Printer -Name $_.Name } Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " removing stale '$($_.Name)'"; Remove-Printer -Name $_.Name }
$before = @(Get-Printer | Select-Object -ExpandProperty Name) $before = @(Get-Printer | Select-Object -ExpandProperty Name)
@ -117,6 +134,17 @@ if (-not $SkipIpp) {
} else { $summary.ipp = "FAIL: helper not reachable" } } else { $summary.ipp = "FAIL: helper not reachable" }
} }
if ($script:tempHelper) { Write-Host "Stopping temporary elevated helper pid=$($script:tempHelper.Id)"; Stop-Process -Id $script:tempHelper.Id -Force -ErrorAction SilentlyContinue }
$script:helperOwner = ""
$helperRunning = Step "Helper running as logged-in user $($Target.Account)" { } {
$p = Get-CimInstance Win32_Process -Filter "Name='verae-printhelper.exe'" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $p) { Write-Host " helper process not found"; return $false }
$o = Invoke-CimMethod -InputObject $p -MethodName GetOwner
$script:helperOwner = "$($o.Domain)\$($o.User)"
Write-Host " helper pid=$($p.ProcessId) owner=$script:helperOwner session=$($p.SessionId)"
($script:helperOwner -eq $Target.Account) -and (Test-Ipp)
}
# ---------------- Fallback: Microsoft Print To PDF → Local file port ---------------- # ---------------- Fallback: Microsoft Print To PDF → Local file port ----------------
if (-not $SkipFallback) { if (-not $SkipFallback) {
$drv = Step "Driver 'Microsoft Print To PDF' present" { $drv = Step "Driver 'Microsoft Print To PDF' present" {
@ -142,6 +170,8 @@ foreach ($ln in 'Microsoft-Windows-PrintService/Admin','Microsoft-Windows-PrintS
Get-WinEvent -FilterHashtable @{LogName=$ln; StartTime=(Get-Date).AddMinutes(-15)} -ErrorAction SilentlyContinue | Get-WinEvent -FilterHashtable @{LogName=$ln; StartTime=(Get-Date).AddMinutes(-15)} -ErrorAction SilentlyContinue |
Format-Table TimeCreated,Id,LevelDisplayName,Message -Wrap | Out-String -Width 250 | Write-Host Format-Table TimeCreated,Id,LevelDisplayName,Message -Wrap | Out-String -Width 250 | Write-Host
} }
$summary.target = $Target.Account
$summary.helperOwner = $script:helperOwner
$summary.finished = (Get-Date).ToString("o") $summary.finished = (Get-Date).ToString("o")
$summary.transcript = $Transcript $summary.transcript = $Transcript
$summaryPath = Join-Path $LogDir "install-summary-$ts.json" $summaryPath = Join-Path $LogDir "install-summary-$ts.json"
@ -150,5 +180,19 @@ $okAny = ($summary.ipp -like "OK*") -or ($summary.fallback -eq "OK")
Write-Host "`nVerae printer install: $(if ($okAny) {'OK'} else {'FAIL'}) ipp=[$($summary.ipp)] fallback=[$($summary.fallback)]" Write-Host "`nVerae printer install: $(if ($okAny) {'OK'} else {'FAIL'}) ipp=[$($summary.ipp)] fallback=[$($summary.fallback)]"
Write-Host "Log: $Transcript" Write-Host "Log: $Transcript"
Write-Host "Summary: $summaryPath" Write-Host "Summary: $summaryPath"
if ($ResultIni) {
$stepOk = { param($pat) $x = $summary.steps | Where-Object { $_.step -like $pat } | Select-Object -Last 1; if ($null -eq $x) { "SKIP" } elseif ($x.ok) { "OK" } else { "FAIL" } }
Write-VeraeIni $ResultIni "printer" ([ordered]@{
target = $Target.Account
spooler = (& $stepOk "Spooler running")
oplog = (& $stepOk "Enable PrintService*")
task = (& $stepOk "Helper autostart*")
helper = $(if ($helperRunning) { "OK ($script:helperOwner)" } else { "FAIL ($script:helperOwner)" })
ipp = $summary.ipp
fallback = $summary.fallback
overall = $(if ($okAny) { "OK" } else { "FAIL" })
transcript = $Transcript
summary = $summaryPath })
}
Stop-Transcript | Out-Null Stop-Transcript | Out-Null
if ($okAny) { exit 0 } else { exit 1 } if ($okAny) { exit 0 } else { exit 1 }

View file

@ -8,6 +8,7 @@ Get-Printer | Where-Object { $_.Name -like "Verae TimeStamp PDF*" } | ForEach-Ob
Start-Sleep 1 Start-Sleep 1
$port = Join-Path $env:ProgramData "Verae\spool\fallback\verae-job.pdf" $port = Join-Path $env:ProgramData "Verae\spool\fallback\verae-job.pdf"
if (Get-PrinterPort -Name $port -ErrorAction SilentlyContinue) { Write-Host "Remove-PrinterPort $port"; Remove-PrinterPort -Name $port } if (Get-PrinterPort -Name $port -ErrorAction SilentlyContinue) { Write-Host "Remove-PrinterPort $port"; Remove-PrinterPort -Name $port }
Unregister-ScheduledTask -TaskName "Verae Print Helper" -Confirm:$false -ErrorAction SilentlyContinue
Remove-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name VeraePrintHelper -ErrorAction SilentlyContinue Remove-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' -Name VeraePrintHelper -ErrorAction SilentlyContinue
Get-Printer | Format-Table Name,DriverName,PortName -AutoSize | Out-String | Write-Host Get-Printer | Format-Table Name,DriverName,PortName -AutoSize | Out-String | Write-Host
Stop-Transcript | Out-Null Stop-Transcript | Out-Null

View file

@ -0,0 +1,76 @@
# Verae signing key handling for the installer (runs elevated, acts for the LOGGED-IN user).
# -Mode status : detect target user + existing key → INI [token]
# -Mode validate : check key in -TokenFile against Verae (free) → INI [validate]
# -Mode save : validate-format + write <profile>\.verae\xverae-token with user-only ACL → INI [save]
# The key is never printed; only a masked form (xVerae-...abcd) is logged.
param(
[ValidateSet("status","validate","save")][string]$Mode = "status",
[string]$TokenFile,
[Parameter(Mandatory=$true)][string]$IniOut
)
$ErrorActionPreference = "Continue"
. (Join-Path $PSScriptRoot "common.ps1")
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ValidateUrl = "https://zap.verae.ai/v1/status" # documented as free; 401 = bad key
function Test-VeraeKey([string]$tok) {
if ($tok -notmatch '^(xVerae-|key-)\S{8,}$') { return @{ result = "badformat"; detail = "must start with xVerae- (or legacy key-)" } }
try {
$r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 -Uri $ValidateUrl -Headers @{ "x-api-key" = $tok }
return @{ result = "valid"; detail = "HTTP $($r.StatusCode)" }
} catch {
$code = $null
if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode }
if ($code -eq 401 -or $code -eq 403) { return @{ result = "invalid"; detail = "HTTP $code from Verae" } }
if ($code) { return @{ result = "unknown"; detail = "HTTP $code from Verae" } }
return @{ result = "unreachable"; detail = $_.Exception.Message }
}
}
$u = Get-VeraeTargetUser
$tokPath = Join-Path $u.Profile ".verae\xverae-token"
Write-Host "user-token.ps1 mode=$Mode target=$($u.Account) sid=$($u.Sid) profile=$($u.Profile) via=$($u.Source) runningAs=$([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
switch ($Mode) {
"status" {
$existing = $null
if (Test-Path $tokPath) { $existing = (Get-Content $tokPath -Raw -ErrorAction SilentlyContinue) }
if ($existing) { $existing = $existing.Trim() }
$v = @{ result = "none"; detail = "" }
if ($existing) { $v = Test-VeraeKey $existing }
Write-Host "existing key: $([bool]$existing) $(Get-MaskedToken $existing) check=$($v.result) $($v.detail)"
Write-VeraeIni $IniOut "token" ([ordered]@{
account = $u.Account; sid = $u.Sid; profile = $u.Profile; source = $u.Source; path = $tokPath
present = $(if ($existing) { 1 } else { 0 }); masked = (Get-MaskedToken $existing)
check = $v.result; detail = $v.detail })
}
"validate" {
$tok = (Get-Content $TokenFile -Raw).Trim()
$v = Test-VeraeKey $tok
Write-Host "validate $(Get-MaskedToken $tok): $($v.result) $($v.detail)"
Write-VeraeIni $IniOut "validate" ([ordered]@{ result = $v.result; detail = $v.detail; masked = (Get-MaskedToken $tok) })
}
"save" {
$tok = (Get-Content $TokenFile -Raw).Trim()
$ok = $false; $err = ""
try {
if ($tok -notmatch '^(xVerae-|key-)\S{8,}$') { throw "bad token format" }
$dir = Split-Path $tokPath -Parent
New-Item -ItemType Directory -Force -Path $dir | Out-Null
# same format as internal/auth.SaveXVeraeToken: token + "\n", no BOM
[IO.File]::WriteAllText($tokPath, $tok + "`n", (New-Object Text.UTF8Encoding($false)))
# ACL: the logged-in user owns it; only user, SYSTEM, Administrators have access
icacls $dir /grant "*$($u.Sid):(OI)(CI)F" /C | Out-Host
icacls $dir /setowner "*$($u.Sid)" /C | Out-Host
icacls $tokPath /inheritance:r /grant:r "*$($u.Sid):F" "*S-1-5-18:F" "*S-1-5-32-544:F" /C | Out-Host
icacls $tokPath /setowner "*$($u.Sid)" /C | Out-Host
$back = (Get-Content $tokPath -Raw).Trim()
$ok = ($back -eq $tok)
if (-not $ok) { $err = "read-back mismatch" }
icacls $tokPath | Out-Host
} catch { $err = $_.Exception.Message }
Write-Host "save $(Get-MaskedToken $tok) -> $tokPath : $(if ($ok) {'OK'} else {"FAIL $err"})"
Write-VeraeIni $IniOut "save" ([ordered]@{ result = $(if ($ok) { "OK" } else { "FAIL" }); detail = $err; path = $tokPath; masked = (Get-MaskedToken $tok); account = $u.Account })
}
}
exit 0