256 lines
11 KiB
Go
256 lines
11 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/ed25519"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const maximumSimulatedFirmwareSize = 268435456
|
||
|
|
|
||
|
|
type simulatedDeviceCommand struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Type string `json:"type"`
|
||
|
|
Payload map[string]any `json:"payload"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type simulatedFirmwareManifest struct {
|
||
|
|
ReleaseID string
|
||
|
|
Version string
|
||
|
|
Digest string
|
||
|
|
Signature []byte
|
||
|
|
SigningKeyID string
|
||
|
|
DownloadPath string
|
||
|
|
}
|
||
|
|
|
||
|
|
type firmwareExecutionOutcome string
|
||
|
|
|
||
|
|
const (
|
||
|
|
firmwareCompleted firmwareExecutionOutcome = "completed"
|
||
|
|
firmwareFailed firmwareExecutionOutcome = "failed"
|
||
|
|
firmwareInterrupted firmwareExecutionOutcome = "interrupted"
|
||
|
|
firmwareRebootRequired firmwareExecutionOutcome = "reboot_required"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *simulator) executeFirmwareCommand(ctx context.Context, command simulatedDeviceCommand) (firmwareExecutionOutcome, error) {
|
||
|
|
manifest, err := parseSimulatedFirmwareManifest(command.Payload)
|
||
|
|
if err != nil {
|
||
|
|
if ackErr := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"result_code": "MANIFEST_INVALID", "error_message": err.Error()}); ackErr != nil {
|
||
|
|
return firmwareFailed, ackErr
|
||
|
|
}
|
||
|
|
return firmwareFailed, nil
|
||
|
|
}
|
||
|
|
fault, err := simulatedFirmwareFault()
|
||
|
|
if err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
delay, err := simulatorOptionalDuration("SIM_FIRMWARE_STEP_DELAY", 30*time.Second)
|
||
|
|
if err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
abEnabled, err := simulatedABFirmwareEnabled()
|
||
|
|
if err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
if !abEnabled && isABFirmwareFault(fault) {
|
||
|
|
return firmwareFailed, errors.New("A/B firmware fault injection requires SIM_FIRMWARE_AB_UPDATE=true")
|
||
|
|
}
|
||
|
|
if abEnabled {
|
||
|
|
if handled, outcome, resumeErr := s.resumeABFirmwareCommand(ctx, command, manifest, fault, delay); handled || resumeErr != nil {
|
||
|
|
return outcome, resumeErr
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_STARTED"}); err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
if fault == "download_failed" {
|
||
|
|
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_FAILED"})
|
||
|
|
}
|
||
|
|
content, err := s.downloadFirmware(ctx, manifest.DownloadPath)
|
||
|
|
if err != nil {
|
||
|
|
if ackErr := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_FAILED", "error_message": err.Error()}); ackErr != nil {
|
||
|
|
return firmwareFailed, ackErr
|
||
|
|
}
|
||
|
|
return firmwareFailed, nil
|
||
|
|
}
|
||
|
|
digestValue := sha256.Sum256(content)
|
||
|
|
actualDigest := hex.EncodeToString(digestValue[:])
|
||
|
|
if fault == "digest_mismatch" || actualDigest != manifest.Digest {
|
||
|
|
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "DIGEST_MISMATCH", "expected_sha256": manifest.Digest, "actual_sha256": actualDigest, "bytes_downloaded": len(content)})
|
||
|
|
}
|
||
|
|
publicKey, err := simulatedFirmwareTrustedKey(manifest.SigningKeyID)
|
||
|
|
if err != nil {
|
||
|
|
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "SIGNING_KEY_UNTRUSTED", "signing_key_id": manifest.SigningKeyID, "error_message": err.Error()})
|
||
|
|
}
|
||
|
|
signedManifest := fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", env("SIM_DEVICE_MODEL", "simulated-terminal"), env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a"), manifest.Version, manifest.Digest, len(content))
|
||
|
|
if fault == "signature_mismatch" || !ed25519.Verify(publicKey, []byte(signedManifest), manifest.Signature) {
|
||
|
|
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "SIGNATURE_MISMATCH", "signing_key_id": manifest.SigningKeyID, "verified_sha256": actualDigest, "bytes_downloaded": len(content)})
|
||
|
|
}
|
||
|
|
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "ARTIFACT_VERIFIED", "verified_sha256": actualDigest, "verified_signing_key_id": manifest.SigningKeyID, "bytes_downloaded": len(content)}); err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
if fault == "interrupt_after_download" {
|
||
|
|
return firmwareInterrupted, nil
|
||
|
|
}
|
||
|
|
if err := waitForContext(ctx, delay); err != nil {
|
||
|
|
return firmwareInterrupted, err
|
||
|
|
}
|
||
|
|
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "installing", "progress_percent": 80, "result_code": "INSTALL_STARTED"}); err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
if fault == "install_failed" {
|
||
|
|
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "installing", "progress_percent": 80, "result_code": "INSTALL_FAILED"})
|
||
|
|
}
|
||
|
|
if fault == "interrupt_after_install" {
|
||
|
|
return firmwareInterrupted, nil
|
||
|
|
}
|
||
|
|
if err := waitForContext(ctx, delay); err != nil {
|
||
|
|
return firmwareInterrupted, err
|
||
|
|
}
|
||
|
|
if abEnabled {
|
||
|
|
return s.stageABFirmwareCommand(ctx, command, manifest, fault)
|
||
|
|
}
|
||
|
|
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_OK", "installed_version": manifest.Version, "verified_sha256": actualDigest, "verified_signing_key_id": manifest.SigningKeyID}); err != nil {
|
||
|
|
return firmwareFailed, err
|
||
|
|
}
|
||
|
|
return firmwareCompleted, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseSimulatedFirmwareManifest(payload map[string]any) (simulatedFirmwareManifest, error) {
|
||
|
|
digest := stringValue(payload, "sha256")
|
||
|
|
signature, signatureErr := base64.StdEncoding.DecodeString(stringValue(payload, "signature"))
|
||
|
|
manifest := simulatedFirmwareManifest{
|
||
|
|
ReleaseID: stringValue(payload, "release_id"),
|
||
|
|
Version: stringValue(payload, "version"),
|
||
|
|
Digest: digest,
|
||
|
|
Signature: signature,
|
||
|
|
SigningKeyID: stringValue(payload, "signing_key_id"),
|
||
|
|
DownloadPath: stringValue(payload, "download_path"),
|
||
|
|
}
|
||
|
|
if manifest.ReleaseID == "" || manifest.Version == "" || manifest.SigningKeyID == "" || len(manifest.Digest) != sha256.Size*2 || signatureErr != nil || len(manifest.Signature) != ed25519.SignatureSize {
|
||
|
|
return simulatedFirmwareManifest{}, errors.New("firmware command requires release_id, version, signing_key_id, a 64-character SHA-256 and a 64-byte Ed25519 signature")
|
||
|
|
}
|
||
|
|
if _, err := hex.DecodeString(manifest.Digest); err != nil {
|
||
|
|
return simulatedFirmwareManifest{}, errors.New("firmware command SHA-256 is not lowercase hexadecimal")
|
||
|
|
}
|
||
|
|
if manifest.Digest != strings.ToLower(manifest.Digest) {
|
||
|
|
return simulatedFirmwareManifest{}, errors.New("firmware command SHA-256 is not lowercase hexadecimal")
|
||
|
|
}
|
||
|
|
parsedPath, err := url.Parse(manifest.DownloadPath)
|
||
|
|
if err != nil || parsedPath.IsAbs() || parsedPath.Host != "" || parsedPath.RawQuery != "" || parsedPath.Fragment != "" || strings.Contains(parsedPath.Path, "..") || !strings.HasPrefix(parsedPath.Path, "/device/v2/firmware-releases/") || !strings.HasSuffix(parsedPath.Path, "/download") {
|
||
|
|
return simulatedFirmwareManifest{}, errors.New("firmware command download_path is outside the fixed device firmware boundary")
|
||
|
|
}
|
||
|
|
return manifest, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func stringValue(value map[string]any, key string) string {
|
||
|
|
item, _ := value[key].(string)
|
||
|
|
return strings.TrimSpace(item)
|
||
|
|
}
|
||
|
|
|
||
|
|
func simulatedFirmwareFault() (string, error) {
|
||
|
|
value := strings.ToLower(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_FAULT")))
|
||
|
|
if value == "" || value == "none" {
|
||
|
|
return "none", nil
|
||
|
|
}
|
||
|
|
switch value {
|
||
|
|
case "download_failed", "digest_mismatch", "signature_mismatch", "install_failed", "interrupt_after_download", "interrupt_after_install", "power_loss_after_stage", "power_loss_during_boot", "boot_health_failed":
|
||
|
|
return value, nil
|
||
|
|
default:
|
||
|
|
return "", fmt.Errorf("SIM_FIRMWARE_FAULT must be none, download_failed, digest_mismatch, signature_mismatch, install_failed, interrupt_after_download, interrupt_after_install, power_loss_after_stage, power_loss_during_boot or boot_health_failed")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func isABFirmwareFault(value string) bool {
|
||
|
|
return value == "power_loss_after_stage" || value == "power_loss_during_boot" || value == "boot_health_failed"
|
||
|
|
}
|
||
|
|
|
||
|
|
func simulatedFirmwareTrustedKey(keyID string) (ed25519.PublicKey, error) {
|
||
|
|
raw := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_TRUSTED_KEYS"))
|
||
|
|
if raw == "" {
|
||
|
|
return nil, errors.New("SIM_FIRMWARE_TRUSTED_KEYS is empty")
|
||
|
|
}
|
||
|
|
var encoded map[string]string
|
||
|
|
if err := json.Unmarshal([]byte(raw), &encoded); err != nil {
|
||
|
|
return nil, errors.New("SIM_FIRMWARE_TRUSTED_KEYS must be a JSON object of key IDs to Base64 Ed25519 public keys")
|
||
|
|
}
|
||
|
|
value, exists := encoded[keyID]
|
||
|
|
if !exists {
|
||
|
|
return nil, fmt.Errorf("signing key %s is not provisioned", keyID)
|
||
|
|
}
|
||
|
|
publicKey, err := base64.StdEncoding.DecodeString(value)
|
||
|
|
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||
|
|
return nil, fmt.Errorf("signing key %s is not a 32-byte Base64 Ed25519 public key", keyID)
|
||
|
|
}
|
||
|
|
return ed25519.PublicKey(publicKey), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func simulatorOptionalDuration(key string, maximum time.Duration) (time.Duration, error) {
|
||
|
|
value := strings.TrimSpace(os.Getenv(key))
|
||
|
|
if value == "" {
|
||
|
|
return 0, nil
|
||
|
|
}
|
||
|
|
parsed, err := time.ParseDuration(value)
|
||
|
|
if err != nil || parsed < 0 || parsed > maximum {
|
||
|
|
return 0, fmt.Errorf("%s must be a non-negative duration no greater than %s", key, maximum)
|
||
|
|
}
|
||
|
|
return parsed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *simulator) ackDeviceCommand(ctx context.Context, commandID, status string, result map[string]any) error {
|
||
|
|
body, err := json.Marshal(map[string]any{"status": status, "result_summary": result})
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err = s.request(ctx, http.MethodPost, "/device/v2/commands/"+commandID+"/ack", body, "")
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *simulator) downloadFirmware(ctx context.Context, path string) ([]byte, error) {
|
||
|
|
result, err := s.request(ctx, http.MethodGet, path, nil, "")
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var address struct {
|
||
|
|
DownloadURL string `json:"download_url"`
|
||
|
|
}
|
||
|
|
if err := decodeData(result.Body, &address); err != nil || address.DownloadURL == "" {
|
||
|
|
return nil, errors.New("firmware download response did not contain a private URL")
|
||
|
|
}
|
||
|
|
parsed, err := url.Parse(address.DownloadURL)
|
||
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||
|
|
return nil, errors.New("firmware private download URL is invalid")
|
||
|
|
}
|
||
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address.DownloadURL, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
response, err := s.client.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("download firmware object: %w", err)
|
||
|
|
}
|
||
|
|
defer response.Body.Close()
|
||
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||
|
|
return nil, fmt.Errorf("firmware object returned HTTP %d", response.StatusCode)
|
||
|
|
}
|
||
|
|
content, err := io.ReadAll(io.LimitReader(response.Body, maximumSimulatedFirmwareSize+1))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("read firmware object: %w", err)
|
||
|
|
}
|
||
|
|
if len(content) == 0 || len(content) > maximumSimulatedFirmwareSize {
|
||
|
|
return nil, fmt.Errorf("firmware object size %d is outside 1 to %d bytes", len(content), maximumSimulatedFirmwareSize)
|
||
|
|
}
|
||
|
|
return content, nil
|
||
|
|
}
|