Files
Behavision/agent/pkg/demo/bundle.go
Suriyakumarvijayanayagam 4bd1718491 A demo build can claim a real shop instead of running on its own
The first demo sealed the office cameras into the package and ran the
PC standalone - a copy of the product with no head office. The bundle
can now carry an installation code instead: setup redeems it exactly as
the app's Setup screen does, the PC joins the shop, and its cameras
arrive from head office on the first sync. The demo then IS the product
- login, Loya, head office - not a local imitation of it. The code is
single-use, so one bundle is one install. release.sh ships the bundle
with DEMO_PACK=.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 13:51:01 +05:30

141 lines
4.7 KiB
Go

// 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"
"encoding/json"
"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"
// Payload is what a sealed bundle carries. Two demo shapes exist:
//
// - cameras only: the PC runs on its own with these cameras (the first
// demo build);
// - an enrolment code: the PC claims a real shop at head office and gets
// its cameras from there, exactly as a customer install would, so the
// demo exercises the whole product rather than a local copy of it. The
// code is single-use, so one bundle is one install.
//
// A bundle from the first build is a bare JSON array; Decode accepts both.
type Payload struct {
Cameras []Camera `json:"cameras,omitempty"`
EnrolCode string `json:"enrol_code,omitempty"`
CloudBase string `json:"cloud_base,omitempty"`
}
// Decode reads either payload shape.
func Decode(plain []byte) (Payload, error) {
var p Payload
if len(plain) > 0 && plain[0] == '[' {
return p, json.Unmarshal(plain, &p.Cameras)
}
return p, json.Unmarshal(plain, &p)
}
// 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
}