// 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 }