You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

485 lines
17 KiB

// tera-update-tool creates signing keys and deterministic, signed local update packages.
// Private keys are host-only artifacts and are never installed on a target device.
package main
import (
"archive/tar"
"compress/gzip"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"tera-clone/services/tera-api/internal/update"
)
type privateKeyEnvelope struct {
FormatVersion int `json:"format_version"`
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
PrivateKey string `json:"private_key"`
}
type publicKeyEnvelope struct {
FormatVersion int `json:"format_version"`
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
PublicKey string `json:"public_key"`
AllowedProducts []string `json:"allowed_products"`
AllowedProfiles []string `json:"allowed_profiles"`
}
type signatureEnvelope struct {
Algorithm string `json:"algorithm"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch os.Args[1] {
case "keygen":
err = keygen(os.Args[2:])
case "pack":
err = pack(os.Args[2:])
case "pack-recovery":
err = packRecovery(os.Args[2:])
case "inspect":
err = inspect(os.Args[2:])
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, "tera-update-tool:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: tera-update-tool {keygen|pack|pack-recovery|inspect} [flags]")
os.Exit(2)
}
func keygen(arguments []string) error {
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
outputDirectory := flags.String("output-dir", "", "directory for the host private key and target public anchor")
keyID := flags.String("key-id", "", "bounded trust anchor id")
product := flags.String("product", "tera-core-x48-pro", "allowed product")
profile := flags.String("profile", "fet3568j-c", "allowed hardware profile")
if err := flags.Parse(arguments); err != nil {
return err
}
if !safeName(*keyID) || strings.TrimSpace(*outputDirectory) == "" || strings.TrimSpace(*product) == "" || strings.TrimSpace(*profile) == "" {
return errors.New("output-dir, a safe key-id, product, and profile are required")
}
if err := os.MkdirAll(*outputDirectory, 0o700); err != nil {
return err
}
privatePath := filepath.Join(*outputDirectory, *keyID+".private.json")
publicPath := filepath.Join(*outputDirectory, *keyID+".json")
if exists(privatePath) || exists(publicPath) {
return errors.New("refusing to overwrite an existing key")
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
privateValue := privateKeyEnvelope{1, *keyID, "ed25519", base64.StdEncoding.EncodeToString(privateKey)}
publicValue := publicKeyEnvelope{1, *keyID, "ed25519", base64.StdEncoding.EncodeToString(publicKey), []string{*product}, []string{*profile}}
if err := writeExclusiveJSON(privatePath, privateValue, 0o600); err != nil {
return err
}
if err := writeExclusiveJSON(publicPath, publicValue, 0o644); err != nil {
_ = os.Remove(privatePath)
return err
}
fmt.Printf("host private key: %s\ntarget public anchor: %s\n", privatePath, publicPath)
return nil
}
func pack(arguments []string) error {
flags := flag.NewFlagSet("pack", flag.ContinueOnError)
privatePath := flags.String("private-key", "", "host private key JSON")
outputPath := flags.String("output", "", "new package path")
payloadPath := flags.String("payload", "", "payload file")
payloadClass := flags.String("payload-class", "application", "config, application, boot, or full-rootfs")
packageID := flags.String("package-id", "", "package id")
version := flags.String("version", "", "package semantic version")
minimumVersion := flags.String("minimum-current-version", "", "minimum device version")
product := flags.String("product", "tera-core-x48-pro", "target product")
profile := flags.String("profile", "fet3568j-c", "target hardware profile")
createdAtValue := flags.String("created-at", "", "RFC3339 creation time (defaults to now)")
recovery := flags.String("recovery", "Upload a known-good signed package; running firmware remains unchanged.", "operator recovery guidance")
if err := flags.Parse(arguments); err != nil {
return err
}
if !safeName(*packageID) || *version == "" || *privatePath == "" || *outputPath == "" || *payloadPath == "" {
return errors.New("private-key, output, payload, package-id, and version are required")
}
if *payloadClass != "config" && *payloadClass != "application" && *payloadClass != "boot" && *payloadClass != "full-rootfs" {
return errors.New("payload-class is invalid")
}
if exists(*outputPath) {
return errors.New("refusing to overwrite an existing package")
}
createdAt := time.Now().UTC()
if *createdAtValue != "" {
parsed, err := time.Parse(time.RFC3339, *createdAtValue)
if err != nil {
return fmt.Errorf("created-at: %w", err)
}
createdAt = parsed.UTC()
}
privateKeyValue, err := readPrivateKey(*privatePath)
if err != nil {
return err
}
payloadInfo, err := safeRegularFile(*payloadPath)
if err != nil {
return err
}
payloadDigest, err := fileSHA256(*payloadPath)
if err != nil {
return err
}
payloadArchivePath := "payload/" + filepath.Base(*payloadPath)
manifest := update.Manifest{
FormatVersion: 1, PackageID: *packageID, Product: *product, HardwareProfile: *profile,
Version: *version, MinimumCurrentVersion: *minimumVersion, CreatedAt: createdAt, SigningKeyID: privateKeyValue.KeyID,
Payloads: []update.Payload{{Name: "primary", Path: payloadArchivePath, Class: *payloadClass, Size: payloadInfo.Size(), SHA256: payloadDigest}},
}
manifest.Apply.Capability = "validation-only"
manifest.Apply.RebootRequired = false
manifest.Recovery.Capability = "external-service"
manifest.Recovery.Instructions = strings.TrimSpace(*recovery)
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return err
}
privateKeyBytes, _ := base64.StdEncoding.DecodeString(privateKeyValue.PrivateKey)
signatureBytes, err := json.Marshal(signatureEnvelope{Algorithm: "ed25519", KeyID: privateKeyValue.KeyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(privateKeyBytes), manifestBytes))})
if err != nil {
return err
}
return writePackage(*outputPath, []packagePayload{{sourcePath: *payloadPath, archivePath: payloadArchivePath, info: payloadInfo}}, manifestBytes, signatureBytes, createdAt, "validation-only")
}
func packRecovery(arguments []string) error {
flags := flag.NewFlagSet("pack-recovery", flag.ContinueOnError)
privatePath := flags.String("private-key", "", "host private key JSON")
outputPath := flags.String("output", "", "new recovery package path")
rootfsPath := flags.String("rootfs-payload", "", "raw or gzip-compressed rootfs image")
rootfsEncoding := flags.String("rootfs-encoding", "gzip", "raw or gzip")
bootPath := flags.String("boot-payload", "", "optional raw signed normal boot FIT")
packageID := flags.String("package-id", "", "package id")
version := flags.String("version", "", "package semantic version")
minimumVersion := flags.String("minimum-current-version", "", "minimum device version")
product := flags.String("product", "tera-core-x48-pro", "target product")
profile := flags.String("profile", "fet3568j-c", "target hardware profile")
createdAtValue := flags.String("created-at", "", "RFC3339 creation time (defaults to now)")
recovery := flags.String("recovery", "Keep both power inputs connected. If recovery reports a failure, leave the device in recovery and follow the COM5 service procedure.", "operator recovery guidance")
if err := flags.Parse(arguments); err != nil {
return err
}
if !safeName(*packageID) || *version == "" || *privatePath == "" || *outputPath == "" || *rootfsPath == "" {
return errors.New("private-key, output, rootfs-payload, package-id, and version are required")
}
if *rootfsEncoding != "raw" && *rootfsEncoding != "gzip" {
return errors.New("rootfs-encoding must be raw or gzip")
}
if exists(*outputPath) {
return errors.New("refusing to overwrite an existing package")
}
createdAt := time.Now().UTC()
if *createdAtValue != "" {
parsed, err := time.Parse(time.RFC3339, *createdAtValue)
if err != nil {
return fmt.Errorf("created-at: %w", err)
}
createdAt = parsed.UTC()
}
privateKeyValue, err := readPrivateKey(*privatePath)
if err != nil {
return err
}
rootfsInfo, err := safeRegularFile(*rootfsPath)
if err != nil {
return err
}
rootfsDigest, err := fileSHA256(*rootfsPath)
if err != nil {
return err
}
expandedSize, expandedDigest, err := expandedMetadata(*rootfsPath, *rootfsEncoding)
if err != nil {
return err
}
rootfsArchivePath := "payload/" + filepath.Base(*rootfsPath)
manifest := update.Manifest{
FormatVersion: 1, PackageID: *packageID, Product: *product, HardwareProfile: *profile,
Version: *version, MinimumCurrentVersion: *minimumVersion, CreatedAt: createdAt, SigningKeyID: privateKeyValue.KeyID,
Payloads: []update.Payload{{
Name: "rootfs", Path: rootfsArchivePath, Class: "full-rootfs", Size: rootfsInfo.Size(), SHA256: rootfsDigest,
TargetPartition: "rootfs", Encoding: *rootfsEncoding, ExpandedSize: expandedSize, ExpandedSHA256: expandedDigest,
}},
}
payloads := []packagePayload{{sourcePath: *rootfsPath, archivePath: rootfsArchivePath, info: rootfsInfo}}
if *bootPath != "" {
bootInfo, err := safeRegularFile(*bootPath)
if err != nil {
return err
}
bootDigest, err := fileSHA256(*bootPath)
if err != nil {
return err
}
bootArchivePath := "payload/" + filepath.Base(*bootPath)
if bootArchivePath == rootfsArchivePath {
return errors.New("boot and rootfs payload basenames must differ")
}
manifest.Payloads = append(manifest.Payloads, update.Payload{
Name: "boot", Path: bootArchivePath, Class: "boot", Size: bootInfo.Size(), SHA256: bootDigest,
TargetPartition: "boot", Encoding: "raw", ExpandedSize: bootInfo.Size(), ExpandedSHA256: bootDigest,
})
payloads = append(payloads, packagePayload{sourcePath: *bootPath, archivePath: bootArchivePath, info: bootInfo})
}
manifest.Apply.Capability = update.ApplyCapabilityRecoveryV1
manifest.Apply.RebootRequired = true
manifest.Recovery.Capability = update.RecoveryCapabilityOfflineV1
manifest.Recovery.Instructions = strings.TrimSpace(*recovery)
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return err
}
privateKeyBytes, _ := base64.StdEncoding.DecodeString(privateKeyValue.PrivateKey)
signatureBytes, err := json.Marshal(signatureEnvelope{Algorithm: "ed25519", KeyID: privateKeyValue.KeyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.PrivateKey(privateKeyBytes), manifestBytes))})
if err != nil {
return err
}
return writePackage(*outputPath, payloads, manifestBytes, signatureBytes, createdAt, "recovery-v1")
}
func inspect(arguments []string) error {
flags := flag.NewFlagSet("inspect", flag.ContinueOnError)
packagePath := flags.String("package", "", "package to verify")
trustDirectory := flags.String("trust-dir", "", "public trust anchor directory")
product := flags.String("product", "tera-core-x48-pro", "expected product")
profile := flags.String("profile", "fet3568j-c", "expected hardware profile")
currentVersion := flags.String("current-version", "0.1.0-dev", "current device version")
if err := flags.Parse(arguments); err != nil {
return err
}
manifest, signer, err := update.VerifyPackage(*packagePath, *trustDirectory, *product, *profile, *currentVersion)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{"valid": true, "signer_key_id": signer, "manifest": manifest})
}
func readPrivateKey(path string) (privateKeyEnvelope, error) {
info, err := safeRegularFile(path)
if err != nil {
return privateKeyEnvelope{}, err
}
if info.Size() > 4096 {
return privateKeyEnvelope{}, errors.New("private key file is too large")
}
data, err := os.ReadFile(path)
if err != nil {
return privateKeyEnvelope{}, err
}
var value privateKeyEnvelope
if err := json.Unmarshal(data, &value); err != nil || value.FormatVersion != 1 || value.Algorithm != "ed25519" || !safeName(value.KeyID) {
return privateKeyEnvelope{}, errors.New("private key envelope is invalid")
}
decoded, err := base64.StdEncoding.DecodeString(value.PrivateKey)
if err != nil || len(decoded) != ed25519.PrivateKeySize {
return privateKeyEnvelope{}, errors.New("private key material is invalid")
}
return value, nil
}
type packagePayload struct {
sourcePath string
archivePath string
info os.FileInfo
}
func writePackage(outputPath string, payloads []packagePayload, manifest, signature []byte, createdAt time.Time, capability string) error {
parent := filepath.Dir(outputPath)
if _, err := safeRegularDirectory(parent); err != nil {
return err
}
temporary, err := os.CreateTemp(parent, ".tera-update-*.part")
if err != nil {
return err
}
temporaryPath := temporary.Name()
committed := false
defer func() {
_ = temporary.Close()
if !committed {
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return err
}
writer := tar.NewWriter(temporary)
for _, entry := range []struct {
name string
data []byte
}{{"manifest.json", manifest}, {"manifest.sig", signature}} {
if err := writeTarBytes(writer, entry.name, entry.data, createdAt); err != nil {
return err
}
}
for _, item := range payloads {
if err := writer.WriteHeader(&tar.Header{Name: item.archivePath, Mode: 0o600, Size: item.info.Size(), ModTime: createdAt}); err != nil {
return err
}
payload, err := os.Open(item.sourcePath)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, payload)
closePayloadErr := payload.Close()
if copyErr != nil {
return copyErr
}
if closePayloadErr != nil {
return closePayloadErr
}
}
if err := writer.Close(); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, outputPath); err != nil {
return err
}
committed = true
fmt.Printf("signed %s package: %s\n", capability, outputPath)
return nil
}
func expandedMetadata(payloadPath, encoding string) (int64, string, error) {
file, err := os.Open(payloadPath)
if err != nil {
return 0, "", err
}
defer file.Close()
var reader io.Reader = file
if encoding == "gzip" {
compressed, err := gzip.NewReader(file)
if err != nil {
return 0, "", fmt.Errorf("open rootfs gzip payload: %w", err)
}
defer compressed.Close()
reader = compressed
} else if encoding != "raw" {
return 0, "", fmt.Errorf("unsupported payload encoding %q", encoding)
}
hash := sha256.New()
size, err := io.Copy(hash, reader)
if err != nil {
return 0, "", fmt.Errorf("read expanded payload: %w", err)
}
if size <= 0 {
return 0, "", errors.New("expanded payload is empty")
}
return size, hex.EncodeToString(hash.Sum(nil)), nil
}
func writeTarBytes(writer *tar.Writer, name string, data []byte, createdAt time.Time) error {
if err := writer.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data)), ModTime: createdAt}); err != nil {
return err
}
_, err := writer.Write(data)
return err
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func safeRegularFile(path string) (os.FileInfo, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("path must be a regular non-symlink file")
}
return info, nil
}
func safeRegularDirectory(path string) (os.FileInfo, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("path must be a non-symlink directory")
}
return info, nil
}
func writeExclusiveJSON(path string, value any, mode os.FileMode) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
encodeErr := json.NewEncoder(file).Encode(value)
syncErr := file.Sync()
closeErr := file.Close()
if encodeErr != nil {
return encodeErr
}
if syncErr != nil {
return syncErr
}
return closeErr
}
func exists(path string) bool { _, err := os.Lstat(path); return err == nil }
func safeName(value string) bool {
if len(value) < 1 || len(value) > 128 {
return false
}
for index, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || index > 0 && (character == '.' || character == '_' || character == '-') {
continue
}
return false
}
return true
}