Borg/pkg/tim/tim.go

308 lines
7.1 KiB
Go

package tim
import (
"archive/tar"
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"strings"
"github.com/Snider/Borg/pkg/datanode"
borgtrix "github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/trix"
)
var (
ErrDataNodeRequired = errors.New("datanode is required")
ErrConfigIsNil = errors.New("config is nil")
ErrPasswordRequired = errors.New("password is required for encryption")
ErrInvalidStimPayload = errors.New("invalid stim payload")
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
)
// TerminalIsolationMatrix represents a runc bundle.
type TerminalIsolationMatrix struct {
Config []byte
RootFS *datanode.DataNode
}
// New creates a new, empty TerminalIsolationMatrix.
func New() (*TerminalIsolationMatrix, error) {
// Use the default runc spec as a starting point.
// This can be customized later.
spec, err := defaultConfig()
if err != nil {
return nil, err
}
specBytes, err := json.Marshal(spec)
if err != nil {
return nil, err
}
return &TerminalIsolationMatrix{
Config: specBytes,
RootFS: datanode.New(),
}, nil
}
// FromDataNode creates a new TerminalIsolationMatrix from a DataNode.
func FromDataNode(dn *datanode.DataNode) (*TerminalIsolationMatrix, error) {
if dn == nil {
return nil, ErrDataNodeRequired
}
m, err := New()
if err != nil {
return nil, err
}
m.RootFS = dn
return m, nil
}
// FromTar creates a TerminalIsolationMatrix from a tarball.
// The tarball must contain config.json and a rootfs/ directory.
func FromTar(data []byte) (*TerminalIsolationMatrix, error) {
tr := tar.NewReader(bytes.NewReader(data))
var config []byte
rootfs := datanode.New()
for {
hdr, err := tr.Next()
if err != nil {
break
}
if hdr.Name == "config.json" {
config, err = io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("failed to read config.json: %w", err)
}
} else if strings.HasPrefix(hdr.Name, "rootfs/") && hdr.Typeflag == tar.TypeReg {
// Strip "rootfs/" prefix
name := strings.TrimPrefix(hdr.Name, "rootfs/")
if name == "" {
continue
}
content, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", hdr.Name, err)
}
rootfs.AddData(name, content)
}
}
if config == nil {
return nil, ErrConfigIsNil
}
return &TerminalIsolationMatrix{
Config: config,
RootFS: rootfs,
}, nil
}
// ToTar serializes the TerminalIsolationMatrix to a tarball.
func (m *TerminalIsolationMatrix) ToTar() ([]byte, error) {
if m.Config == nil {
return nil, ErrConfigIsNil
}
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
// Add the config.json file.
hdr := &tar.Header{
Name: "config.json",
Mode: 0600,
Size: int64(len(m.Config)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write(m.Config); err != nil {
return nil, err
}
// Add the rootfs directory.
hdr = &tar.Header{
Name: "rootfs/",
Mode: 0755,
Typeflag: tar.TypeDir,
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
// Add the rootfs files.
err := m.RootFS.Walk(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
// If the root directory doesn't exist (i.e. empty datanode), it's not an error.
if path == "." && errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
if d.IsDir() {
return nil
}
file, err := m.RootFS.Open(path)
if err != nil {
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
hdr := &tar.Header{
Name: "rootfs/" + path,
Mode: 0600,
Size: info.Size(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(file); err != nil {
return err
}
if _, err := tw.Write(buf.Bytes()); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ToSigil serializes and encrypts the TIM to .stim format using ChaChaPolySigil.
// Config and RootFS are encrypted separately.
// The output format is a Trix container with "STIM" magic containing:
// - Header: {"encryption_algorithm": "chacha20poly1305", "tim": true}
// - Payload: [config_size(4 bytes)][encrypted_config][encrypted_rootfs]
func (m *TerminalIsolationMatrix) ToSigil(password string) ([]byte, error) {
if password == "" {
return nil, ErrPasswordRequired
}
if m.Config == nil {
return nil, ErrConfigIsNil
}
key := borgtrix.DeriveKey(password)
sigil, err := enchantrix.NewChaChaPolySigil(key)
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
// Encrypt config
encConfig, err := sigil.In(m.Config)
if err != nil {
return nil, fmt.Errorf("failed to encrypt config: %w", err)
}
// Get rootfs as tar
rootfsTar, err := m.RootFS.ToTar()
if err != nil {
return nil, fmt.Errorf("failed to serialize rootfs: %w", err)
}
// Encrypt rootfs
encRootFS, err := sigil.In(rootfsTar)
if err != nil {
return nil, fmt.Errorf("failed to encrypt rootfs: %w", err)
}
// Build payload: [config_size(4 bytes)][encrypted_config][encrypted_rootfs]
payload := make([]byte, 4+len(encConfig)+len(encRootFS))
binary.BigEndian.PutUint32(payload[:4], uint32(len(encConfig)))
copy(payload[4:4+len(encConfig)], encConfig)
copy(payload[4+len(encConfig):], encRootFS)
// Create trix container
t := &trix.Trix{
Header: map[string]interface{}{
"encryption_algorithm": "chacha20poly1305",
"tim": true,
"config_size": len(encConfig),
"rootfs_size": len(encRootFS),
"version": "1.0",
},
Payload: payload,
}
return trix.Encode(t, "STIM", nil)
}
// FromSigil decrypts and deserializes a .stim file into a TerminalIsolationMatrix.
func FromSigil(data []byte, password string) (*TerminalIsolationMatrix, error) {
if password == "" {
return nil, ErrPasswordRequired
}
// Decode the trix container
t, err := trix.Decode(data, "STIM", nil)
if err != nil {
return nil, fmt.Errorf("failed to decode stim: %w", err)
}
key := borgtrix.DeriveKey(password)
sigil, err := enchantrix.NewChaChaPolySigil(key)
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
// Parse payload structure
if len(t.Payload) < 4 {
return nil, ErrInvalidStimPayload
}
configSize := binary.BigEndian.Uint32(t.Payload[:4])
if len(t.Payload) < int(4+configSize) {
return nil, ErrInvalidStimPayload
}
encConfig := t.Payload[4 : 4+configSize]
encRootFS := t.Payload[4+configSize:]
// Decrypt config
config, err := sigil.Out(encConfig)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
}
// Decrypt rootfs
rootfsTar, err := sigil.Out(encRootFS)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
}
// Reconstruct DataNode from tar
rootfs, err := datanode.FromTar(rootfsTar)
if err != nil {
return nil, fmt.Errorf("failed to parse rootfs: %w", err)
}
return &TerminalIsolationMatrix{
Config: config,
RootFS: rootfs,
}, nil
}