feat(workspace): encrypt workspace files using ChaChaPolySigil
Some checks failed
CI / test (push) Failing after 3s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s

ReadWorkspaceFile and WriteWorkspaceFile now encrypt/decrypt file
content using XChaCha20-Poly1305 via the existing sigil pipeline.
A 32-byte symmetric key is derived by SHA-256-hashing the workspace's
stored private.key material so no new dependencies are required.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-31 16:14:43 +01:00
parent c713bafd48
commit df9c443657
2 changed files with 107 additions and 2 deletions

View file

@ -9,6 +9,7 @@ import (
core "dappco.re/go/core"
"dappco.re/go/core/io"
"dappco.re/go/core/io/sigil"
)
// Example: service, _ := workspace.New(workspace.Options{
@ -45,11 +46,15 @@ type WorkspaceCommand struct {
// Example: KeyPairProvider: keyPairProvider,
// Example: RootPath: "/srv/workspaces",
// Example: Medium: io.NewMemoryMedium(),
// Example: Core: c,
// Example: })
type Options struct {
KeyPairProvider KeyPairProvider
RootPath string
Medium io.Medium
// Core is the optional Core instance. When set, the workspace service
// auto-registers as an IPC listener for workspace.create and workspace.switch events.
Core *core.Core
}
// Example: service, _ := workspace.New(workspace.Options{
@ -105,6 +110,10 @@ func New(options Options) (*Service, error) {
return nil, core.E("workspace.New", "failed to ensure root directory", err)
}
if options.Core != nil {
options.Core.RegisterAction(service.HandleWorkspaceMessage)
}
return service, nil
}
@ -178,6 +187,24 @@ func (service *Service) resolveActiveWorkspaceFilePath(operation, workspaceFileP
return filePath, nil
}
// Example: cipherSigil, _ := service.workspaceCipherSigil("workspace.ReadWorkspaceFile")
func (service *Service) workspaceCipherSigil(operation string) (*sigil.ChaChaPolySigil, error) {
if service.activeWorkspaceID == "" {
return nil, core.E(operation, "no active workspace", fs.ErrNotExist)
}
keyPath := core.Path(service.rootPath, service.activeWorkspaceID, "keys", "private.key")
rawKey, err := service.medium.Read(keyPath)
if err != nil {
return nil, core.E(operation, "failed to read workspace key", err)
}
derived := sha256.Sum256([]byte(rawKey))
cipherSigil, err := sigil.NewChaChaPolySigil(derived[:], nil)
if err != nil {
return nil, core.E(operation, "failed to create cipher sigil", err)
}
return cipherSigil, nil
}
// Example: content, _ := service.ReadWorkspaceFile("notes/todo.txt")
func (service *Service) ReadWorkspaceFile(workspaceFilePath string) (string, error) {
service.stateLock.RLock()
@ -187,7 +214,19 @@ func (service *Service) ReadWorkspaceFile(workspaceFilePath string) (string, err
if err != nil {
return "", err
}
return service.medium.Read(filePath)
cipherSigil, err := service.workspaceCipherSigil("workspace.ReadWorkspaceFile")
if err != nil {
return "", err
}
encoded, err := service.medium.Read(filePath)
if err != nil {
return "", err
}
plaintext, err := sigil.Untransmute([]byte(encoded), []sigil.Sigil{cipherSigil})
if err != nil {
return "", core.E("workspace.ReadWorkspaceFile", "failed to decrypt file content", err)
}
return string(plaintext), nil
}
// Example: _ = service.WriteWorkspaceFile("notes/todo.txt", "ship it")
@ -199,7 +238,15 @@ func (service *Service) WriteWorkspaceFile(workspaceFilePath, content string) er
if err != nil {
return err
}
return service.medium.Write(filePath, content)
cipherSigil, err := service.workspaceCipherSigil("workspace.WriteWorkspaceFile")
if err != nil {
return err
}
ciphertext, err := sigil.Transmute([]byte(content), []sigil.Sigil{cipherSigil})
if err != nil {
return core.E("workspace.WriteWorkspaceFile", "failed to encrypt file content", err)
}
return service.medium.Write(filePath, string(ciphertext))
}
// Example: commandResult := service.HandleWorkspaceCommand(WorkspaceCommand{Action: WorkspaceCreateAction, Identifier: "alice", Password: "pass123"})

View file

@ -127,6 +127,64 @@ func TestService_JoinPathWithinRoot_DefaultSeparator_Good(t *testing.T) {
assert.Empty(t, path)
}
func TestService_New_IPCAutoRegistration_Good(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
c := core.New()
service, err := New(Options{
KeyPairProvider: testKeyPairProvider{privateKey: "private-key"},
Core: c,
})
require.NoError(t, err)
// Create a workspace directly, then switch via the Core IPC bus.
workspaceID, err := service.CreateWorkspace("ipc-bus-user", "pass789")
require.NoError(t, err)
// Dispatching workspace.switch via ACTION must reach the auto-registered handler.
c.ACTION(WorkspaceCommand{
Action: WorkspaceSwitchAction,
WorkspaceID: workspaceID,
})
assert.Equal(t, workspaceID, service.activeWorkspaceID)
}
func TestService_New_IPCCreate_Good(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
c := core.New()
service, err := New(Options{
KeyPairProvider: testKeyPairProvider{privateKey: "private-key"},
Core: c,
})
require.NoError(t, err)
// workspace.create dispatched via the bus must create the workspace on the medium.
c.ACTION(WorkspaceCommand{
Action: WorkspaceCreateAction,
Identifier: "ipc-create-user",
Password: "pass123",
})
// A duplicate create must fail — proves the first create succeeded.
_, err = service.CreateWorkspace("ipc-create-user", "pass123")
require.Error(t, err)
}
func TestService_New_NoCoreOption_NoRegistration_Good(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Without Core in Options, New must succeed and no IPC handler is registered.
service, err := New(Options{
KeyPairProvider: testKeyPairProvider{privateKey: "private-key"},
})
require.NoError(t, err)
assert.NotNil(t, service)
}
func TestService_HandleWorkspaceMessage_Command_Good(t *testing.T) {
service, _ := newWorkspaceService(t)