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"