308 lines
7.1 KiB
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
|
|
}
|