From b59e667a683c55711050b3ad48497248535a6dd1 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Fri, 11 Sep 2026 16:07:49 +0530 Subject: [PATCH] A demo release with the office cameras sealed inside it Wanted: install it and the two office cameras are already there - but without the release carrying their admin password where anyone with the zip can read it. "Encode it" does not achieve that; anything the installer can decode, anyone holding the installer can decode. pkg/demo seals the camera list with AES-256-GCM under a key that is NOT in the package: a 120-bit unlock code minted when the bundle is sealed, given to whoever runs setup by voice or message, typed once. The code is random, so it is key material directly through SHA-256; a human- chosen passphrase would need a KDF and a dependency, 120 random bits do not. The sealed file contains the format marker and noise. Tested: the password and the host do not appear in it, a wrong code and a flipped byte are both refused as ErrWrongCode, every seal differs. behavision-demo-pack seals; it runs on the build machine and is never shipped. The code is printed once and stored nowhere. behavision-setup, on finding demo-cameras.enc beside the engine source, asks for the code BEFORE the ten-minute download so a mistyped one costs seconds, and adds the cameras at the end - through the running engine's own Add Camera endpoint, not by writing its file. The store's save() is what applies DPAPI to the password on Windows, so this is how the credential ends up encrypted and machine-bound on the demo PC rather than in cameras.json for anyone who can read ProgramData. It then marks the PC standalone, so the app opens on Live instead of asking for an installation code it will never get. Which found the gap that DPAPI only works if pywin32 is importable, and nothing had ever pulled it in - every Windows install to date would have logged the warning and written camera passwords in the clear. Added as a Windows-only dependency. Verified in a clean container: a wrong code refused, the right one unlocks two cameras, every install step passes, both cameras added through the API, standalone set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- agent/cmd/behavision-demo-pack/main.go | 80 ++++++++++++++ agent/cmd/behavision-setup/main.go | 139 ++++++++++++++++++++++++- agent/pkg/demo/bundle.go | 114 ++++++++++++++++++++ agent/pkg/demo/bundle_test.go | 87 ++++++++++++++++ installer/INSTALL.txt | 7 ++ pyproject.toml | 4 + requirements.txt | 1 + 7 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 agent/cmd/behavision-demo-pack/main.go create mode 100644 agent/pkg/demo/bundle.go create mode 100644 agent/pkg/demo/bundle_test.go diff --git a/agent/cmd/behavision-demo-pack/main.go b/agent/cmd/behavision-demo-pack/main.go new file mode 100644 index 0000000..f3a20dc --- /dev/null +++ b/agent/cmd/behavision-demo-pack/main.go @@ -0,0 +1,80 @@ +// Command behavision-demo-pack seals a camera list into demo-cameras.enc for a +// demo release. It runs on the machine that builds the release and is never +// shipped. +// +// behavision-demo-pack -cameras cameras.json -out demo-cameras.enc +// +// Prints the unlock code exactly once. It is not stored anywhere; a code you +// can look up later is a code anyone with access to the build machine holds. +// Lose it and seal again. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/loyaly/behavision-agent/pkg/demo" +) + +func main() { + in := flag.String("cameras", "", "JSON array of cameras (id, host, port, path, username, password)") + out := flag.String("out", "demo-cameras.enc", "sealed bundle to write") + flag.Parse() + if *in == "" { + fmt.Fprintln(os.Stderr, "usage: behavision-demo-pack -cameras cameras.json [-out demo-cameras.enc]") + os.Exit(2) + } + + raw, err := os.ReadFile(*in) + if err != nil { + die("read cameras: %v", err) + } + var cams []demo.Camera + if err := json.Unmarshal(raw, &cams); err != nil { + die("cameras.json: %v", err) + } + if len(cams) == 0 { + die("no cameras in %s", *in) + } + for i, c := range cams { + switch { + case c.ID == "": + die("camera %d has no id", i) + case c.Host == "": + die("camera %q has no host", c.ID) + case c.Path == "": + die("camera %q has no path - the stream path is the field nobody can guess", c.ID) + } + } + // Re-marshal so only the fields the engine accepts travel, in a stable + // shape, whatever extra keys the input happened to carry. + plain, err := json.Marshal(cams) + if err != nil { + die("marshal: %v", err) + } + + code, err := demo.NewCode() + if err != nil { + die("code: %v", err) + } + sealed, err := demo.Seal(code, plain) + if err != nil { + die("seal: %v", err) + } + if err := os.WriteFile(*out, sealed, 0o644); err != nil { + die("write: %v", err) + } + + fmt.Printf("\n sealed %d camera(s) into %s (%d bytes)\n\n", len(cams), *out, len(sealed)) + fmt.Printf(" unlock code: %s\n\n", code) + fmt.Println(" Shown once. Give it to whoever runs behavision-setup, by voice") + fmt.Println(" or message - not in the same place as the zip.") + fmt.Println() +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, " "+format+"\n", args...) + os.Exit(1) +} diff --git a/agent/cmd/behavision-setup/main.go b/agent/cmd/behavision-setup/main.go index 78a053e..836cce9 100644 --- a/agent/cmd/behavision-setup/main.go +++ b/agent/cmd/behavision-setup/main.go @@ -32,7 +32,11 @@ import ( "strings" "time" + "bytes" + "encoding/json" + "github.com/loyaly/behavision-agent/pkg/config" + "github.com/loyaly/behavision-agent/pkg/demo" "github.com/loyaly/behavision-agent/pkg/engine" "github.com/loyaly/behavision-agent/pkg/paths" ) @@ -69,6 +73,17 @@ func run() error { return fmt.Errorf("could not create %s: %w", state, err) } + // A demo release ships its cameras sealed. Ask for the code NOW, before + // the ten-minute download, so a mistyped one costs seconds; the cameras + // are actually added at the end, through the running engine. + demoCams, err := unlockDemo(src) + if err != nil { + return err + } + if demoCams != nil { + step("Demo cameras", fmt.Sprintf("%d unlocked", len(demoCams))) + } + py, ver, err := findPython() if err != nil { return err @@ -113,10 +128,19 @@ func run() error { // Proving it starts is the point. An installer that reports success and // leaves a shop with an engine that will not run has done worse than // failing: the failure surfaces later, to someone who did not install it. - if err := smokeTest(vpy); err != nil { + if err := smokeTest(vpy, demoCams); err != nil { return fmt.Errorf("the engine installed but would not start: %w", err) } step("Engine starts and answers", "verified") + if demoCams != nil { + step("Demo cameras", "added to the engine") + // No head office in a demo. Without this the app opens on "type an + // installation code" and sits there; with it, it opens on Live. + if err := markStandalone(); err != nil { + return err + } + step("Head office", "none - running on this PC only") + } fmt.Println() fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.") @@ -347,8 +371,8 @@ func writeConfig(vpy string) error { // smokeTest starts the engine exactly as the app will and waits for its API to // answer. Any reply counts, including 401: the engine invents its own // credential when none is configured, and a refusal proves it is serving. -func smokeTest(vpy string) error { - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) +func smokeTest(vpy string, demoCams []demo.Camera) error { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run") @@ -370,7 +394,15 @@ func smokeTest(vpy string) error { if err == nil { _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() - return nil + if demoCams == nil { + return nil + } + // Through the engine's own Add Camera, not written to its file: + // the store is what applies DPAPI to the password on Windows, so + // this is how the credential ends up encrypted on disk rather + // than sitting in cameras.json for anyone who can read + // ProgramData. + return addCameras(demoCams) } if cmd.ProcessState != nil && cmd.ProcessState.Exited() { break @@ -410,3 +442,102 @@ func pause() { fmt.Print(" Press Enter to close. ") _, _ = bufio.NewReader(os.Stdin).ReadString('\n') } + +// unlockDemo returns the sealed cameras a demo release ships, or nil when this +// is not a demo release. Asks for the unlock code on the console; three tries, +// because a code is read down a phone and typed by hand. +func unlockDemo(src string) ([]demo.Camera, error) { + sealed, err := os.ReadFile(filepath.Join(src, "demo-cameras.enc")) + if err != nil { + return nil, nil // not a demo release + } + fmt.Println() + fmt.Println(" This is a demo release with the cameras already set up.") + fmt.Println(" It needs the unlock code you were given.") + fmt.Println() + in := bufio.NewReader(os.Stdin) + for attempt := 1; attempt <= 3; attempt++ { + fmt.Print(" Unlock code: ") + line, _ := in.ReadString('\n') + plain, err := demo.Open(line, sealed) + if err == nil { + var cams []demo.Camera + if err := json.Unmarshal(plain, &cams); err != nil { + return nil, fmt.Errorf("the bundle unlocked but did not parse: %w", err) + } + fmt.Println() + return cams, nil + } + fmt.Printf(" %v\n", err) + } + return nil, errors.New("no valid unlock code after three tries. Check it " + + "with whoever gave you this release and run setup again") +} + +// addCameras posts each demo camera to the running engine, with the credential +// the engine generated for itself on first start. +func addCameras(cams []demo.Camera) error { + user, pass, err := engineCredential() + if err != nil { + return err + } + client := &http.Client{Timeout: 30 * time.Second} + for _, c := range cams { + if c.Port == 0 { + c.Port = 554 + } + body, _ := json.Marshal(c) + req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8010/api/cameras", + bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if user != "" { + req.SetBasicAuth(user, pass) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("adding camera %s: %w", c.ID, err) + } + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + // 409 is "already there" - a re-run of setup, which is allowed. + if resp.StatusCode >= 300 && resp.StatusCode != http.StatusConflict { + return fmt.Errorf("adding camera %s: %s: %s", c.ID, resp.Status, + strings.TrimSpace(string(msg))) + } + } + return nil +} + +// engineCredential reads the Basic credential the engine wrote on its first +// start. Empty when the engine is configured without one. +func engineCredential() (string, string, error) { + b, err := os.ReadFile(paths.APICredentials()) + if err != nil { + if os.IsNotExist(err) { + return "", "", nil + } + return "", "", err + } + var user, pass string + for _, line := range strings.Split(string(b), "\n") { + if v, ok := strings.CutPrefix(line, "username="); ok { + user = strings.TrimSpace(v) + } + if v, ok := strings.CutPrefix(line, "password="); ok { + pass = strings.TrimSpace(v) + } + } + return user, pass, nil +} + +// markStandalone records that this PC runs on its own, through the same +// config type the app reads. +func markStandalone() error { + path := paths.AgentConfig() + cfg, err := config.Load(path) + if err != nil { + return err + } + cfg.Standalone = true + return cfg.Save(path) +} diff --git a/agent/pkg/demo/bundle.go b/agent/pkg/demo/bundle.go new file mode 100644 index 0000000..8193962 --- /dev/null +++ b/agent/pkg/demo/bundle.go @@ -0,0 +1,114 @@ +// Package demo seals a camera list so a release can carry it without carrying +// the credentials in any usable form. +// +// The need: a demo build that installs with the office cameras already set up, +// handed to people who should not be able to read the cameras' admin password +// out of the zip. "Encode it" does not do that - anything the installer can +// decode, anyone holding the installer can decode. So the bundle is encrypted +// with a key that is NOT in the package: a short unlock code, generated when +// the bundle is sealed, spoken or messaged to whoever runs setup, and typed +// once. Without it the file is noise. +// +// The code is random, not chosen, so it is used as key material directly +// (through SHA-256) rather than stretched with a KDF. A human-chosen +// passphrase would need argon2 and a dependency; 120 random bits do not. +package demo + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base32" + "errors" + "fmt" + "strings" +) + +// Magic identifies the file and the format version, so a future change can be +// told apart from corruption instead of failing as "authentication failed". +const magic = "BVDEMO1\n" + +// Camera is one entry as the engine's Add Camera endpoint accepts it. +type Camera struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Host string `json:"host"` + Port int `json:"port"` + Path string `json:"path"` + Username string `json:"username"` + Password string `json:"password"` + MaxWidth int `json:"max_width,omitempty"` +} + +// NewCode mints an unlock code: 15 random bytes as 24 base32 characters in +// four groups, the same shape as an installation code, for the same reason - +// it gets read down a phone. +func NewCode() (string, error) { + raw := make([]byte, 15) + if _, err := rand.Read(raw); err != nil { + return "", err + } + s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) + return fmt.Sprintf("%s-%s-%s-%s", s[0:6], s[6:12], s[12:18], s[18:24]), nil +} + +// NormalizeCode makes the typed and the printed form hash the same: case, +// spaces and dashes are all noise a person adds or drops. +func NormalizeCode(code string) string { + code = strings.ToUpper(code) + code = strings.NewReplacer("-", "", " ", "", "\t", "", "\r", "", "\n", "").Replace(code) + return code +} + +func keyFor(code string) []byte { + sum := sha256.Sum256([]byte("behavision-demo-bundle:" + NormalizeCode(code))) + return sum[:] +} + +// Seal encrypts plaintext under the code. Output is magic || nonce || ciphertext. +func Seal(code string, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(keyFor(code)) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + out := append([]byte(magic), nonce...) + return gcm.Seal(out, nonce, plaintext, []byte(magic)), nil +} + +// ErrWrongCode is what a mistyped code looks like. GCM cannot tell a wrong key +// from a corrupted file, and neither can we, so both read as this. +var ErrWrongCode = errors.New("that unlock code does not open this bundle") + +// Open decrypts a sealed bundle. +func Open(code string, sealed []byte) ([]byte, error) { + if !strings.HasPrefix(string(sealed), magic) { + return nil, errors.New("not a Behavision demo bundle") + } + body := sealed[len(magic):] + block, err := aes.NewCipher(keyFor(code)) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(body) < gcm.NonceSize() { + return nil, errors.New("bundle is truncated") + } + nonce, ct := body[:gcm.NonceSize()], body[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ct, []byte(magic)) + if err != nil { + return nil, ErrWrongCode + } + return plain, nil +} diff --git a/agent/pkg/demo/bundle_test.go b/agent/pkg/demo/bundle_test.go new file mode 100644 index 0000000..3bc1d86 --- /dev/null +++ b/agent/pkg/demo/bundle_test.go @@ -0,0 +1,87 @@ +package demo + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestSealedBundleRoundTripsWithTheCodeAsTyped(t *testing.T) { + code, err := NewCode() + if err != nil { + t.Fatal(err) + } + if len(NormalizeCode(code)) != 24 { + t.Fatalf("code should be 24 base32 chars, got %q", code) + } + secret := []byte(`[{"id":"cam1","password":"the-camera-admin-password"}]`) + + sealed, err := Seal(code, secret) + if err != nil { + t.Fatal(err) + } + // People type codes in lower case, with the dashes dropped, with a space + // where a dash was. All of those are the same code. + for _, typed := range []string{ + code, + strings.ToLower(code), + strings.ReplaceAll(code, "-", ""), + strings.ReplaceAll(code, "-", " "), + " " + code + "\n", + } { + got, err := Open(typed, sealed) + if err != nil { + t.Fatalf("open with %q: %v", typed, err) + } + if !bytes.Equal(got, secret) { + t.Fatalf("round trip changed the contents") + } + } +} + +// The whole point of the file: the password is not in it. +func TestTheSealedFileDoesNotContainTheSecret(t *testing.T) { + code, _ := NewCode() + sealed, _ := Seal(code, []byte(`{"password":"the-camera-admin-password","host":"192.168.1.121"}`)) + for _, leak := range []string{"the-camera-admin-password", "192.168.1.121", "password"} { + if bytes.Contains(sealed, []byte(leak)) { + t.Fatalf("sealed bundle contains %q in the clear", leak) + } + } +} + +func TestAWrongCodeIsRefusedNotMisread(t *testing.T) { + code, _ := NewCode() + other, _ := NewCode() + sealed, _ := Seal(code, []byte("secret")) + + if _, err := Open(other, sealed); !errors.Is(err, ErrWrongCode) { + t.Fatalf("a different code should be ErrWrongCode, got %v", err) + } + // One flipped byte in the ciphertext is the same answer: GCM refuses + // rather than returning garbage that then gets written into cameras.json. + tampered := append([]byte{}, sealed...) + tampered[len(tampered)-1] ^= 0x01 + if _, err := Open(code, tampered); !errors.Is(err, ErrWrongCode) { + t.Fatalf("a tampered bundle should be refused, got %v", err) + } +} + +func TestSomethingThatIsNotABundleSaysSo(t *testing.T) { + if _, err := Open("ABCDEF-GHIJKL-MNOPQR-STUVWX", []byte("hello")); err == nil || + errors.Is(err, ErrWrongCode) { + t.Fatalf("a non-bundle should be named as such, not blamed on the code: %v", err) + } +} + +// Two seals of the same plaintext under the same code must differ: a fixed +// nonce would let two releases' bundles be compared byte for byte. +func TestEverySealIsDifferent(t *testing.T) { + code, _ := NewCode() + a, _ := Seal(code, []byte("same")) + b, _ := Seal(code, []byte("same")) + if bytes.Equal(a, b) { + t.Fatal("nonce is not random") + } +} diff --git a/installer/INSTALL.txt b/installer/INSTALL.txt index 3e00c6c..e2964c6 100644 --- a/installer/INSTALL.txt +++ b/installer/INSTALL.txt @@ -38,6 +38,13 @@ SETTING UP This takes several minutes. Leave the window open until it says Done. If anything fails it prints why, and running it again is safe. + DEMO RELEASE ONLY: if the release came with the cameras already set up, + setup first asks for an unlock code. Type the code you were given. The + camera details are sealed inside the release and cannot be read without + it; with it, both cameras are added and the PC is set to run on its own, + with no head office. Skip the installation-code screen - it will not + appear. + 3. Double-click Behavision.exe The window opens and an icon appears in the system tray, next to the diff --git a/pyproject.toml b/pyproject.toml index 5f17de6..1e3250e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,10 @@ dependencies = [ "python-dotenv>=1.0", "faiss-cpu>=1.7.4", "requests>=2.31", + # DPAPI for camera passwords at rest (behavision/cameras.py). Without it the + # store logs a warning and writes them in the clear - which is what every + # Windows install had been doing, since nothing pulled this in. + "pywin32>=306; sys_platform == 'win32'", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 8095e75..6551568 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ PyYAML>=6.0 python-dotenv>=1.0 faiss-cpu>=1.7.4 requests>=2.31 +pywin32>=306; sys_platform == "win32"