PrintToVeraeTimeStamp/cmd/verae-print/installer_cmds.go

210 lines
7 KiB
Go

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
}