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:
parent
1cb714b173
commit
6e76bc7b0e
7 changed files with 1857 additions and 61 deletions
210
cmd/verae-print/installer_cmds.go
Normal file
210
cmd/verae-print/installer_cmds.go
Normal 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
|
||||
}
|
||||
23
cmd/verae-print/installer_cmds_test.go
Normal file
23
cmd/verae-print/installer_cmds_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
108
cmd/verae-print/keydialog_windows.go
Normal file
108
cmd/verae-print/keydialog_windows.go
Normal 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)
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ func main() {
|
|||
os.Exit(runServe(os.Args[2:]))
|
||||
case "ipp-selftest":
|
||||
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":
|
||||
os.Exit(runSetup())
|
||||
case "install-macos":
|
||||
|
|
|
|||
|
|
@ -36,3 +36,8 @@ func launchSetupUI() { vlog.Printf("setup: no signing key — run: verae-print s
|
|||
func runInstallPrinterWindows() int { return 1 }
|
||||
|
||||
func debugFromRegistry() bool { return false }
|
||||
|
||||
func runKeyDialog() int {
|
||||
fmt.Println("key-dialog is Windows-only; run: verae-print setup")
|
||||
return 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,17 @@ func notify(title, subtitle string) { notifyUser(title, subtitle) }
|
|||
|
||||
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() {
|
||||
cli := filepath.Join(exeDir(), "verae-print.exe")
|
||||
vlog.Printf("setup: no signing key — launching %q setup in a new console", cli)
|
||||
_ = exec.Command("cmd.exe", "/c", "start", "Verae Print Stamp setup", cli, "setup").Start()
|
||||
go func() {
|
||||
vlog.Printf("setup: no signing key — prompting user (GUI)")
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -102,10 +102,43 @@ func errState(err error) string {
|
|||
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 {
|
||||
return func(j winprint.Job) error {
|
||||
processor = func(j winprint.Job) error {
|
||||
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)
|
||||
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
|
||||
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)
|
||||
}
|
||||
client.XVeraeToken = tok
|
||||
|
|
@ -147,6 +183,7 @@ func makeProcessor(outDir string) winprint.Processor {
|
|||
notifyUser(fmt.Sprintf("Verae timestamp %s", res.ReceiptID), res.CertificatePDF)
|
||||
return nil
|
||||
}
|
||||
return processor
|
||||
}
|
||||
|
||||
func loggingDo(lj vlog.Job) func(*http.Request) (*http.Response, error) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue