Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
201
agent/pkg/config/config.go
Normal file
201
agent/pkg/config/config.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Package config holds the agent's own settings: which tenant and site this
|
||||
// install belongs to, how to reach the broker, and how to launch the engine.
|
||||
//
|
||||
// Kept separate from the engine's YAML on purpose. That file describes
|
||||
// recognition — thresholds, cameras, gates — and is edited by whoever tunes a
|
||||
// site. This one describes identity and connectivity, is written by the
|
||||
// installer and the login flow, and holds a secret.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// protectedPrefix marks a value that went through DPAPI, so a config written
|
||||
// on Windows is never mistaken for a plaintext dev one and vice versa.
|
||||
const protectedPrefix = "dpapi:"
|
||||
|
||||
// Config is the agent's on-disk settings.
|
||||
type Config struct {
|
||||
// Tenant identity. The server keys everything on these.
|
||||
ClientID string `json:"client_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
|
||||
// Broker.
|
||||
BrokerURL string `json:"broker_url"`
|
||||
BrokerUsername string `json:"broker_username"`
|
||||
BrokerPassword string `json:"broker_password"` // protected at rest
|
||||
// Pins the broker's issuer. Empty uses the system roots, which is what a
|
||||
// Let's Encrypt certificate needs; a private CA is pinned by path.
|
||||
BrokerCAFile string `json:"broker_ca_file"`
|
||||
|
||||
// Engine process.
|
||||
EngineExe string `json:"engine_exe"`
|
||||
EngineArgs []string `json:"engine_args"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIUser string `json:"api_user"`
|
||||
APIPassword string `json:"api_password"` // protected at rest
|
||||
|
||||
// Session, so a shop PC that reboots overnight is not a login every
|
||||
// morning. Protected at rest like every other secret here.
|
||||
SessionToken string `json:"session_token"`
|
||||
SessionRefresh string `json:"session_refresh"`
|
||||
SessionEmail string `json:"session_email"`
|
||||
|
||||
// CloudBase is the server this site reports to; AgentToken is this PC's
|
||||
// own credential there, issued once at enrolment.
|
||||
//
|
||||
// Deliberately not the same secret as BrokerPassword: they authenticate
|
||||
// different things - one says this site may publish events, the other that
|
||||
// it may ask the API for something - so rotating either must not break the
|
||||
// other. Protected at rest like every other secret here.
|
||||
CloudBase string `json:"cloud_base"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
|
||||
// Standalone marks a PC deliberately run on its own: cameras, recognition
|
||||
// and the local gallery, with nothing reported to head office.
|
||||
//
|
||||
// It exists so that "not linked yet" and "not going to be linked" are
|
||||
// different states. Without it every install was blocked on an enrolment
|
||||
// code, so a shop with one PC and no head office could not add a camera at
|
||||
// all - the software refused to do the thing it is for until a server it
|
||||
// does not need had issued it a credential.
|
||||
Standalone bool `json:"standalone"`
|
||||
|
||||
// Queue.
|
||||
SpoolMax int `json:"spool_max"`
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
// Defaults returns a config that runs a locally installed engine.
|
||||
//
|
||||
// EngineExe is relative to the install root - the directory holding this
|
||||
// executable - and names the installed layout: the engine is a PyInstaller
|
||||
// one-FOLDER build, so it brings its own DLLs and cannot simply sit beside the
|
||||
// app. Windows filenames are case-insensitive too, so `Behavision.exe` (the
|
||||
// app) and `behavision.exe` (the engine) could not share a directory even if
|
||||
// it were tidy to.
|
||||
func Defaults() Config {
|
||||
exe := filepath.Join("engine", "behavision")
|
||||
if runtime.GOOS == "windows" {
|
||||
exe += ".exe"
|
||||
}
|
||||
return Config{
|
||||
EngineExe: exe,
|
||||
EngineArgs: []string{"run"},
|
||||
APIBase: "http://127.0.0.1:8010",
|
||||
SpoolMax: 50000,
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the config, decrypting secrets. A missing file is not an error:
|
||||
// a fresh install has none until the operator logs in, and failing to start
|
||||
// because of that would leave them with no UI to log in from.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Defaults()
|
||||
cfg.path = path
|
||||
blob, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if err := json.Unmarshal(blob, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("config %s: %w", path, err)
|
||||
}
|
||||
cfg.path = path
|
||||
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
|
||||
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
|
||||
plain, err := reveal(*field)
|
||||
if err != nil {
|
||||
// A secret that cannot be decrypted usually means the config was
|
||||
// copied from another machine - DPAPI is machine-scoped. Blank it
|
||||
// rather than failing: the operator can log in again, but they
|
||||
// cannot fix a process that will not start.
|
||||
*field = ""
|
||||
continue
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config atomically, protecting secrets on the way out.
|
||||
func (c Config) Save(path string) error {
|
||||
if path == "" {
|
||||
path = c.path
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("config: no path to save to")
|
||||
}
|
||||
out := c
|
||||
out.path = ""
|
||||
for _, field := range []*string{&out.BrokerPassword, &out.APIPassword,
|
||||
&out.SessionToken, &out.SessionRefresh, &out.AgentToken} {
|
||||
hidden, err := conceal(*field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*field = hidden
|
||||
}
|
||||
blob, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
// Temp-then-rename: a crash mid-write must not leave a config that parses
|
||||
// as valid but is half old and half new.
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Configured reports whether this install has been claimed by a tenant yet.
|
||||
// The UI shows a login screen until it has.
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.SiteID != "" && c.BrokerURL != ""
|
||||
}
|
||||
|
||||
// SecretsProtected is false on a dev machine, where secrets are stored as-is.
|
||||
// Surfaced rather than hidden so nobody ships a build believing otherwise.
|
||||
func SecretsProtected() bool { return protectionAvailable() }
|
||||
|
||||
func conceal(plain string) (string, error) {
|
||||
if plain == "" || !protectionAvailable() {
|
||||
return plain, nil
|
||||
}
|
||||
blob, err := protect([]byte(plain))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return protectedPrefix + base64.StdEncoding.EncodeToString(blob), nil
|
||||
}
|
||||
|
||||
func reveal(stored string) (string, error) {
|
||||
if !strings.HasPrefix(stored, protectedPrefix) {
|
||||
return stored, nil
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(
|
||||
strings.TrimPrefix(stored, protectedPrefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := unprotect(blob)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
179
agent/pkg/config/config_test.go
Normal file
179
agent/pkg/config/config_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAFreshInstallLoadsDefaultsInsteadOfFailing(t *testing.T) {
|
||||
// There is no config until the operator logs in, and refusing to start
|
||||
// would leave them with no UI to log in from.
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing config treated as an error: %v", err)
|
||||
}
|
||||
if cfg.Configured() {
|
||||
t.Fatal("a blank install reported itself as configured")
|
||||
}
|
||||
if cfg.APIBase == "" || cfg.EngineExe == "" {
|
||||
t.Fatal("defaults were not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.ClientID, cfg.SiteID, cfg.BrokerURL = "acme", "store-1", "tls://b:8883"
|
||||
cfg.BrokerPassword, cfg.APIPassword = "broker-secret", "api-secret"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.BrokerPassword != "broker-secret" || back.APIPassword != "api-secret" {
|
||||
t.Fatalf("secrets did not survive the round trip: %+v", back)
|
||||
}
|
||||
if !back.Configured() {
|
||||
t.Fatal("a claimed install reported itself unconfigured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIsAtomic(t *testing.T) {
|
||||
// A crash mid-write must not leave a config that parses but is half old
|
||||
// and half new.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.ClientID = "acme"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUndecryptableSecretBlanksRatherThanBlocksStartup(t *testing.T) {
|
||||
// DPAPI is machine-scoped, so a config copied between PCs cannot be read.
|
||||
// Refusing to start would be unrecoverable without a UI; blanking it means
|
||||
// the operator just logs in again.
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
os.WriteFile(path, []byte(`{"client_id":"acme","site_id":"s1",
|
||||
"broker_url":"tls://b","broker_password":"dpapi:!!!not-base64!!!"}`), 0o600)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unreadable secret blocked startup: %v", err)
|
||||
}
|
||||
if cfg.BrokerPassword != "" {
|
||||
t.Fatal("a secret that could not be decrypted was kept")
|
||||
}
|
||||
if cfg.ClientID != "acme" {
|
||||
t.Fatal("the rest of the config was discarded too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextSecretsAreMarkedDifferentlyFromProtectedOnes(t *testing.T) {
|
||||
// So a dev config is never mistaken for a protected one on inspection.
|
||||
stored, err := conceal("secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if SecretsProtected() && !strings.HasPrefix(stored, protectedPrefix) {
|
||||
t.Fatal("protected value is not marked")
|
||||
}
|
||||
if !SecretsProtected() && strings.HasPrefix(stored, protectedPrefix) {
|
||||
t.Fatal("plaintext value claims to be protected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDoesNotLeakThePathFieldIntoJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
Defaults().Save(path)
|
||||
blob, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(blob), t.TempDir()) {
|
||||
t.Fatal("internal path field was serialised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionSurvivesARestart(t *testing.T) {
|
||||
// A shop PC reboots overnight. Without this someone logs in every morning
|
||||
// before the store can record anything.
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.SessionToken, cfg.SessionRefresh = "access-tok", "refresh-tok"
|
||||
cfg.SessionEmail = "manager@acme.test"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.SessionToken != "access-tok" || back.SessionRefresh != "refresh-tok" {
|
||||
t.Fatalf("session lost: %+v", back)
|
||||
}
|
||||
if back.SessionEmail != "manager@acme.test" {
|
||||
t.Fatal("email not kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokensAreProtectedLikeOtherSecrets(t *testing.T) {
|
||||
// A bearer token in plaintext on disk is a credential anyone with the file
|
||||
// can replay.
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.SessionToken = "super-secret-jwt"
|
||||
cfg.Save(path)
|
||||
raw, _ := os.ReadFile(path)
|
||||
if SecretsProtected() && strings.Contains(string(raw), "super-secret-jwt") {
|
||||
t.Fatal("session token written in plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone has to survive a restart. It is a setup choice made once at a
|
||||
// counter, and a flag that only lives in memory would put the enrolment-code
|
||||
// screen back in front of a shop that already answered "we have no head
|
||||
// office" - which reads as the app forgetting the setup step was ever done.
|
||||
func TestStandaloneSurvivesSaveAndLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.Standalone = true
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
back, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !back.Standalone {
|
||||
t.Fatal("standalone was not persisted")
|
||||
}
|
||||
// Independent of being claimed: a standalone PC has no tenant, and a
|
||||
// claimed one is not standalone even if the flag was once set.
|
||||
if back.Configured() {
|
||||
t.Fatal("a standalone config must not report itself as claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine is a PyInstaller one-FOLDER build living in its own subdirectory,
|
||||
// and on Windows `Behavision.exe` (the app) could not share a directory with
|
||||
// `behavision.exe` (the engine) anyway. Asserted here because the installer
|
||||
// lays the tree out to match, and a rename would otherwise fail only inside
|
||||
// the package - the one place nothing is tested.
|
||||
func TestDefaultEngineExeIsInTheEngineFolder(t *testing.T) {
|
||||
got := Defaults().EngineExe
|
||||
if dir := filepath.Dir(got); dir != "engine" {
|
||||
t.Fatalf("engine exe %q is not under engine/, got dir %q", got, dir)
|
||||
}
|
||||
if filepath.IsAbs(got) {
|
||||
t.Fatalf("engine exe %q must be relative to the install root", got)
|
||||
}
|
||||
}
|
||||
62
agent/pkg/config/credentials.go
Normal file
62
agent/pkg/config/credentials.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EngineCredentials reads the Basic credentials the engine generated for
|
||||
// itself, from the file it writes them to.
|
||||
//
|
||||
// The engine only invents a credential when none is configured and its API
|
||||
// listens on a routable address - which is the DEFAULT configuration, so this
|
||||
// is the ordinary case and not an edge one. `paths.APICredentials` has existed
|
||||
// since the agent was written, with a comment saying the agent reads the file
|
||||
// "rather than storing a second copy, so a regenerated credential does not
|
||||
// silently break the tray". Nothing read it. On a stock install the agent's
|
||||
// api_user was therefore empty and every call it makes to the engine - health,
|
||||
// stats, camera sync, embeddings for a visit - came back 401: the tray red, the
|
||||
// cameras never reconciled, and no error anywhere saying why.
|
||||
//
|
||||
// A missing or unreadable file is not an error. A PC where the operator set
|
||||
// BEHAVISION_API_USER has no such file and needs none.
|
||||
func EngineCredentials(path string) (user, password string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
// `key=value`, and `key: value` too: the file is also read by people,
|
||||
// and which separator the engine used is not worth a support call.
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
k, v, ok = strings.Cut(line, ":")
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "username":
|
||||
user = strings.TrimSpace(v)
|
||||
case "password":
|
||||
password = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return user, password
|
||||
}
|
||||
|
||||
// WithEngineCredentials fills in the engine's Basic credentials from the file
|
||||
// when the config carries none. Configured values always win: an operator who
|
||||
// set BEHAVISION_API_USER means it.
|
||||
func (c Config) WithEngineCredentials(path string) Config {
|
||||
if c.APIUser != "" || c.APIPassword != "" {
|
||||
return c
|
||||
}
|
||||
c.APIUser, c.APIPassword = EngineCredentials(path)
|
||||
return c
|
||||
}
|
||||
58
agent/pkg/config/credentials_test.go
Normal file
58
agent/pkg/config/credentials_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeCreds(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "api_credentials.txt")
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// The shape the engine actually writes. This is the whole point of the file:
|
||||
// on a stock install it is the ONLY place the credential exists.
|
||||
func TestItReadsWhatTheEngineWrites(t *testing.T) {
|
||||
p := writeCreds(t, "username=behavision\npassword=qQTGFpetJ5Py613XwcbARQ\n")
|
||||
u, pw := EngineCredentials(p)
|
||||
if u != "behavision" || pw != "qQTGFpetJ5Py613XwcbARQ" {
|
||||
t.Fatalf("got %q / %q", u, pw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColonSeparatedIsReadToo(t *testing.T) {
|
||||
p := writeCreds(t, " username: behavision\n password: hunter2\n")
|
||||
if u, pw := EngineCredentials(p); u != "behavision" || pw != "hunter2" {
|
||||
t.Fatalf("got %q / %q", u, pw)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing file is normal - an operator who set BEHAVISION_API_USER has none.
|
||||
func TestAMissingFileIsNotAnError(t *testing.T) {
|
||||
if u, pw := EngineCredentials("/nope/nothing.txt"); u != "" || pw != "" {
|
||||
t.Fatalf("got %q / %q", u, pw)
|
||||
}
|
||||
}
|
||||
|
||||
// Configured values win. Reading the file over an operator's own credential
|
||||
// would silently ignore what they set.
|
||||
func TestAConfiguredCredentialIsNotOverwritten(t *testing.T) {
|
||||
p := writeCreds(t, "username=generated\npassword=generated\n")
|
||||
c := Config{APIUser: "mine", APIPassword: "secret"}.WithEngineCredentials(p)
|
||||
if c.APIUser != "mine" || c.APIPassword != "secret" {
|
||||
t.Fatalf("configured credential was replaced: %q / %q", c.APIUser, c.APIPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyCredentialIsFilledIn(t *testing.T) {
|
||||
p := writeCreds(t, "username=behavision\npassword=abc\n")
|
||||
c := Config{}.WithEngineCredentials(p)
|
||||
if c.APIUser != "behavision" || c.APIPassword != "abc" {
|
||||
t.Fatalf("not filled in: %q / %q", c.APIUser, c.APIPassword)
|
||||
}
|
||||
}
|
||||
13
agent/pkg/config/protect.go
Normal file
13
agent/pkg/config/protect.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
// On non-Windows hosts secrets are stored as-is. This exists so the rest of
|
||||
// the agent compiles and tests on a developer machine; the shipping platform
|
||||
// is Windows, where protect.go's DPAPI implementation is used instead.
|
||||
//
|
||||
// It is a passthrough, NOT encryption, and Save() marks such values plainly so
|
||||
// nobody can mistake a dev config for a protected one.
|
||||
func protect(plain []byte) ([]byte, error) { return plain, nil }
|
||||
func unprotect(blob []byte) ([]byte, error) { return blob, nil }
|
||||
func protectionAvailable() bool { return false }
|
||||
68
agent/pkg/config/protect_windows.go
Normal file
68
agent/pkg/config/protect_windows.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Windows DPAPI, reached through crypt32.dll directly rather than pulling in
|
||||
// golang.org/x/sys. Machine scope, matching how the Python side already
|
||||
// protects camera passwords: the agent and the engine may run as different
|
||||
// users on the same PC, and a user-scoped blob written by one cannot be read
|
||||
// by the other.
|
||||
var (
|
||||
crypt32 = syscall.NewLazyDLL("crypt32.dll")
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procProtectData = crypt32.NewProc("CryptProtectData")
|
||||
procUnprotectData = crypt32.NewProc("CryptUnprotectData")
|
||||
procLocalFree = kernel32.NewProc("LocalFree")
|
||||
)
|
||||
|
||||
const cryptprotectLocalMachine = 0x4
|
||||
|
||||
type dataBlob struct {
|
||||
cbData uint32
|
||||
pbData *byte
|
||||
}
|
||||
|
||||
func newBlob(d []byte) dataBlob {
|
||||
if len(d) == 0 {
|
||||
return dataBlob{}
|
||||
}
|
||||
return dataBlob{cbData: uint32(len(d)), pbData: &d[0]}
|
||||
}
|
||||
|
||||
func (b *dataBlob) bytes() []byte {
|
||||
out := make([]byte, b.cbData)
|
||||
copy(out, unsafe.Slice(b.pbData, b.cbData))
|
||||
return out
|
||||
}
|
||||
|
||||
func protect(plain []byte) ([]byte, error) {
|
||||
in, out := newBlob(plain), dataBlob{}
|
||||
r, _, err := procProtectData.Call(
|
||||
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
|
||||
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("CryptProtectData: %w", err)
|
||||
}
|
||||
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
|
||||
return out.bytes(), nil
|
||||
}
|
||||
|
||||
func unprotect(blob []byte) ([]byte, error) {
|
||||
in, out := newBlob(blob), dataBlob{}
|
||||
r, _, err := procUnprotectData.Call(
|
||||
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
|
||||
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
|
||||
}
|
||||
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
|
||||
return out.bytes(), nil
|
||||
}
|
||||
|
||||
func protectionAvailable() bool { return true }
|
||||
Reference in New Issue
Block a user