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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
115 lines
3.7 KiB
Go
115 lines
3.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"
|
|
"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
|
|
}
|