210 lines
7 KiB
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
|
|
}
|