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