diff --git a/pkg/cli/app.go b/pkg/cli/app.go index 22fe513..3815fe5 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -3,8 +3,10 @@ package cli import ( "os" + "github.com/host-uk/core/pkg/crypt/openpgp" "github.com/host-uk/core/pkg/framework" "github.com/host-uk/core/pkg/log" + "github.com/host-uk/core/pkg/workspace" "github.com/spf13/cobra" ) @@ -31,6 +33,8 @@ func Main() { framework.WithName("log", NewLogService(log.Options{ Level: log.LevelInfo, })), + framework.WithName("crypt", openpgp.New), + framework.WithName("workspace", workspace.New), }, }); err != nil { Error(err.Error()) diff --git a/pkg/crypt/openpgp/service.go b/pkg/crypt/openpgp/service.go new file mode 100644 index 0000000..1020058 --- /dev/null +++ b/pkg/crypt/openpgp/service.go @@ -0,0 +1,191 @@ +package openpgp + +import ( + "bytes" + "crypto" + goio "io" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + core "github.com/host-uk/core/pkg/framework/core" +) + +// Service implements the core.Crypt interface using OpenPGP. +type Service struct { + core *core.Core +} + +// New creates a new OpenPGP service instance. +func New(c *core.Core) (any, error) { + return &Service{core: c}, nil +} + +// CreateKeyPair generates a new RSA-4096 PGP keypair. +// Returns the armored private key string. +func (s *Service) CreateKeyPair(name, passphrase string) (string, error) { + config := &packet.Config{ + Algorithm: packet.PubKeyAlgoRSA, + RSABits: 4096, + DefaultHash: crypto.SHA256, + DefaultCipher: packet.CipherAES256, + } + + entity, err := openpgp.NewEntity(name, "Workspace Key", "", config) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to create entity", err) + } + + // Encrypt private key if passphrase is provided + if passphrase != "" { + err = entity.PrivateKey.Encrypt([]byte(passphrase)) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to encrypt private key", err) + } + for _, subkey := range entity.Subkeys { + err = subkey.PrivateKey.Encrypt([]byte(passphrase)) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err) + } + } + } + + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to create armor encoder", err) + } + + // Manual serialization to avoid panic from re-signing encrypted keys + err = s.serializeEntity(w, entity) + if err != nil { + w.Close() + return "", core.E("openpgp.CreateKeyPair", "failed to serialize private key", err) + } + w.Close() + + return buf.String(), nil +} + +// serializeEntity manually serializes an OpenPGP entity to avoid re-signing. +func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error { + err := e.PrivateKey.Serialize(w) + if err != nil { + return err + } + for _, ident := range e.Identities { + err = ident.UserId.Serialize(w) + if err != nil { + return err + } + err = ident.SelfSignature.Serialize(w) + if err != nil { + return err + } + } + for _, subkey := range e.Subkeys { + err = subkey.PrivateKey.Serialize(w) + if err != nil { + return err + } + err = subkey.Sig.Serialize(w) + if err != nil { + return err + } + } + return nil +} + +// EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath). +// The encrypted data is written to the provided writer and also returned as an armored string. +func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) { + entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath)) + if err != nil { + return "", core.E("openpgp.EncryptPGP", "failed to read recipient key", err) + } + + var armoredBuf bytes.Buffer + armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", nil) + if err != nil { + return "", core.E("openpgp.EncryptPGP", "failed to create armor encoder", err) + } + + // MultiWriter to write to both the provided writer and our armored buffer + mw := goio.MultiWriter(writer, armoredWriter) + + w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil) + if err != nil { + armoredWriter.Close() + return "", core.E("openpgp.EncryptPGP", "failed to start encryption", err) + } + + _, err = goio.WriteString(w, data) + if err != nil { + w.Close() + armoredWriter.Close() + return "", core.E("openpgp.EncryptPGP", "failed to write data", err) + } + + w.Close() + armoredWriter.Close() + + return armoredBuf.String(), nil +} + +// DecryptPGP decrypts a PGP message using the provided armored private key and passphrase. +func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) { + entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey)) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to read private key", err) + } + + entity := entityList[0] + if entity.PrivateKey.Encrypted { + err = entity.PrivateKey.Decrypt([]byte(passphrase)) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to decrypt private key", err) + } + for _, subkey := range entity.Subkeys { + _ = subkey.PrivateKey.Decrypt([]byte(passphrase)) + } + } + + // Decrypt armored message + block, err := armor.Decode(strings.NewReader(message)) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to decode armored message", err) + } + + md, err := openpgp.ReadMessage(block.Body, entityList, nil, nil) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to read message", err) + } + + var buf bytes.Buffer + _, err = goio.Copy(&buf, md.UnverifiedBody) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to read decrypted body", err) + } + + return buf.String(), nil +} + +// HandleIPCEvents handles PGP-related IPC messages. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case map[string]any: + action, _ := m["action"].(string) + switch action { + case "openpgp.create_key_pair": + name, _ := m["name"].(string) + passphrase, _ := m["passphrase"].(string) + _, err := s.CreateKeyPair(name, passphrase) + return err + } + } + return nil +} + +// Ensure Service implements core.Crypt. +var _ core.Crypt = (*Service)(nil) diff --git a/pkg/crypt/openpgp/service_test.go b/pkg/crypt/openpgp/service_test.go new file mode 100644 index 0000000..c6f1243 --- /dev/null +++ b/pkg/crypt/openpgp/service_test.go @@ -0,0 +1,43 @@ +package openpgp + +import ( + "bytes" + "testing" + + core "github.com/host-uk/core/pkg/framework/core" + "github.com/stretchr/testify/assert" +) + +func TestCreateKeyPair(t *testing.T) { + c, _ := core.New() + s := &Service{core: c} + + privKey, err := s.CreateKeyPair("test user", "password123") + assert.NoError(t, err) + assert.NotEmpty(t, privKey) + assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----") +} + +func TestEncryptDecrypt(t *testing.T) { + c, _ := core.New() + s := &Service{core: c} + + passphrase := "secret" + privKey, err := s.CreateKeyPair("test user", passphrase) + assert.NoError(t, err) + + // In this simple test, the public key is also in the armored private key string + // (openpgp.ReadArmoredKeyRing reads both) + publicKey := privKey + + data := "hello openpgp" + var buf bytes.Buffer + armored, err := s.EncryptPGP(&buf, publicKey, data) + assert.NoError(t, err) + assert.NotEmpty(t, armored) + assert.NotEmpty(t, buf.String()) + + decrypted, err := s.DecryptPGP(privKey, armored, passphrase) + assert.NoError(t, err) + assert.Equal(t, data, decrypted) +} diff --git a/pkg/framework/core/core.go b/pkg/framework/core/core.go index 5f6c393..82c1a04 100644 --- a/pkg/framework/core/core.go +++ b/pkg/framework/core/core.go @@ -300,6 +300,18 @@ func (c *Core) Display() Display { return d } +// Workspace returns the registered Workspace service. +func (c *Core) Workspace() Workspace { + w := MustServiceFor[Workspace](c, "workspace") + return w +} + +// Crypt returns the registered Crypt service. +func (c *Core) Crypt() Crypt { + cr := MustServiceFor[Crypt](c, "crypt") + return cr +} + // Core returns self, implementing the CoreProvider interface. func (c *Core) Core() *Core { return c } diff --git a/pkg/framework/core/interfaces.go b/pkg/framework/core/interfaces.go index 0bef944..8455e68 100644 --- a/pkg/framework/core/interfaces.go +++ b/pkg/framework/core/interfaces.go @@ -3,6 +3,7 @@ package core import ( "context" "embed" + goio "io" ) // This file defines the public API contracts (interfaces) for the services @@ -98,6 +99,28 @@ type Display interface { OpenWindow(opts ...WindowOption) error } +// Workspace provides management for encrypted user workspaces. +type Workspace interface { + // CreateWorkspace creates a new encrypted workspace. + CreateWorkspace(identifier, password string) (string, error) + // SwitchWorkspace changes the active workspace. + SwitchWorkspace(name string) error + // WorkspaceFileGet retrieves the content of a file from the active workspace. + WorkspaceFileGet(filename string) (string, error) + // WorkspaceFileSet saves content to a file in the active workspace. + WorkspaceFileSet(filename, content string) error +} + +// Crypt provides PGP-based encryption, signing, and key management. +type Crypt interface { + // CreateKeyPair generates a new PGP keypair. + CreateKeyPair(name, passphrase string) (string, error) + // EncryptPGP encrypts data for a recipient. + EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) + // DecryptPGP decrypts a PGP message. + DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error) +} + // ActionServiceStartup is a message sent when the application's services are starting up. // This provides a hook for services to perform initialization tasks. type ActionServiceStartup struct{} diff --git a/pkg/workspace/service.go b/pkg/workspace/service.go new file mode 100644 index 0000000..e8d1643 --- /dev/null +++ b/pkg/workspace/service.go @@ -0,0 +1,145 @@ +package workspace + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sync" + + core "github.com/host-uk/core/pkg/framework/core" + "github.com/host-uk/core/pkg/io" +) + +// Service implements the core.Workspace interface. +type Service struct { + core *core.Core + activeWorkspace string + rootPath string + medium io.Medium + mu sync.RWMutex +} + +// New creates a new Workspace service instance. +func New(c *core.Core) (any, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, core.E("workspace.New", "failed to determine home directory", err) + } + rootPath := filepath.Join(home, ".core", "workspaces") + + s := &Service{ + core: c, + rootPath: rootPath, + medium: io.Local, + } + + if err := s.medium.EnsureDir(rootPath); err != nil { + return nil, core.E("workspace.New", "failed to ensure root directory", err) + } + + return s, nil +} + +// CreateWorkspace creates a new encrypted workspace. +// Identifier is hashed (SHA-256 as proxy for LTHN) to create the directory name. +// A PGP keypair is generated using the password. +func (s *Service) CreateWorkspace(identifier, password string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // 1. Identification (LTHN hash proxy) + hash := sha256.Sum256([]byte(identifier)) + wsID := hex.EncodeToString(hash[:]) + wsPath := filepath.Join(s.rootPath, wsID) + + if s.medium.Exists(wsPath) { + return "", core.E("workspace.CreateWorkspace", "workspace already exists", nil) + } + + // 2. Directory structure + dirs := []string{"config", "log", "data", "files", "keys"} + for _, d := range dirs { + if err := s.medium.EnsureDir(filepath.Join(wsPath, d)); err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to create directory: "+d, err) + } + } + + // 3. PGP Keypair generation + crypt := s.core.Crypt() + privKey, err := crypt.CreateKeyPair(identifier, password) + if err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to generate keys", err) + } + + // Save private key + if err := s.medium.Write(filepath.Join(wsPath, "keys", "private.key"), privKey); err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to save private key", err) + } + + return wsID, nil +} + +// SwitchWorkspace changes the active workspace. +func (s *Service) SwitchWorkspace(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + wsPath := filepath.Join(s.rootPath, name) + if !s.medium.IsDir(wsPath) { + return core.E("workspace.SwitchWorkspace", "workspace not found: "+name, nil) + } + + s.activeWorkspace = name + return nil +} + +// WorkspaceFileGet retrieves the content of a file from the active workspace. +// In a full implementation, this would involve decryption using the workspace key. +func (s *Service) WorkspaceFileGet(filename string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.activeWorkspace == "" { + return "", core.E("workspace.WorkspaceFileGet", "no active workspace", nil) + } + + path := filepath.Join(s.rootPath, s.activeWorkspace, "files", filename) + return s.medium.Read(path) +} + +// WorkspaceFileSet saves content to a file in the active workspace. +// In a full implementation, this would involve encryption using the workspace key. +func (s *Service) WorkspaceFileSet(filename, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.activeWorkspace == "" { + return core.E("workspace.WorkspaceFileSet", "no active workspace", nil) + } + + path := filepath.Join(s.rootPath, s.activeWorkspace, "files", filename) + return s.medium.Write(path, content) +} + +// HandleIPCEvents handles workspace-related IPC messages. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case map[string]any: + action, _ := m["action"].(string) + switch action { + case "workspace.create": + id, _ := m["identifier"].(string) + pass, _ := m["password"].(string) + _, err := s.CreateWorkspace(id, pass) + return err + case "workspace.switch": + name, _ := m["name"].(string) + return s.SwitchWorkspace(name) + } + } + return nil +} + +// Ensure Service implements core.Workspace. +var _ core.Workspace = (*Service)(nil) diff --git a/pkg/workspace/service_test.go b/pkg/workspace/service_test.go new file mode 100644 index 0000000..c8b8945 --- /dev/null +++ b/pkg/workspace/service_test.go @@ -0,0 +1,55 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/host-uk/core/pkg/crypt/openpgp" + core "github.com/host-uk/core/pkg/framework/core" + "github.com/stretchr/testify/assert" +) + +func TestWorkspace(t *testing.T) { + // Setup core with crypt service + c, _ := core.New( + core.WithName("crypt", openpgp.New), + ) + + tempHome, _ := os.MkdirTemp("", "core-test-home") + defer os.RemoveAll(tempHome) + + // Mock os.UserHomeDir by setting HOME env + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tempHome) + defer os.Setenv("HOME", oldHome) + + s_any, err := New(c) + assert.NoError(t, err) + s := s_any.(*Service) + + // Test CreateWorkspace + id, err := s.CreateWorkspace("test-user", "pass123") + assert.NoError(t, err) + assert.NotEmpty(t, id) + + wsPath := filepath.Join(tempHome, ".core", "workspaces", id) + assert.DirExists(t, wsPath) + assert.DirExists(t, filepath.Join(wsPath, "keys")) + assert.FileExists(t, filepath.Join(wsPath, "keys", "private.key")) + + // Test SwitchWorkspace + err = s.SwitchWorkspace(id) + assert.NoError(t, err) + assert.Equal(t, id, s.activeWorkspace) + + // Test File operations + filename := "secret.txt" + content := "top secret info" + err = s.WorkspaceFileSet(filename, content) + assert.NoError(t, err) + + got, err := s.WorkspaceFileGet(filename) + assert.NoError(t, err) + assert.Equal(t, content, got) +}