Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
temp-*.env
plan.md
.claude/settings.local.json
2 changes: 1 addition & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type kvRequest struct {
// Pre-computed ed25519 signing keys (via NewPairEd25519() or any other means)
// must be passed to a new client, with the expectation that the public key
// be made available to the server to facilitate authentication.
// see: WriteRegistry() for details
// see: FileRegistry.Register() for details
func NewClient(serverURL, keyPub, keyPriv string) (*Client, error) {
rsaPublic, rsaPrivate, err := newPairRSA(Defaults.BitsizeRSA)
if err != nil {
Expand Down
5 changes: 0 additions & 5 deletions example/testreg.yml

This file was deleted.

6 changes: 6 additions & 0 deletions locket.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ type defaults struct {
MaxClockSkew time.Duration // max client/server clock difference before a request is rejected
}

// PathRegistry is the API endpoint for registry operations.
// - GET: list all entries
// - POST: upsert an entry (RegEntry JSON body)
// - DELETE: remove an entry (RegEntry JSON body with name)
var PathRegistry = "/locket/registry"
Comment thread
turkosaurus marked this conversation as resolved.
Comment thread
turkosaurus marked this conversation as resolved.

// map[serviceName]keyPrivateSigning
type KeysPrivateSigning map[string]string

Expand Down
22 changes: 7 additions & 15 deletions locket_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package locket

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"strings"
"testing"

Expand All @@ -17,24 +18,16 @@ func TestE2E(t *testing.T) {
pub, priv, err := NewPairEd25519()
require.NoError(t, err)

reg := []RegEntry{{
Name: "SERVICE1",
KeyPub: pub,
}}
testReg := path.Join("example", "testreg.yml")
err = WriteRegistry(testReg, reg)
fileReg := FileRegistry{Path: filepath.Join(t.TempDir(), "registry.yml")}
err = fileReg.Upsert(RegEntry{Name: "SERVICE1", KeyPub: pub})
require.NoError(t, err)

source := Dotenv{
ServiceSecrets: testServiceMap,
Path: path.Join("example", ".env"),
Path: filepath.Join("example", ".env"),
}
registry, err := ReadRegistryFile(testReg)
require.NoError(t, err)
require.NotNil(t, registry)
require.Greater(t, len(registry), 0)

server, err := NewServer(source, registry)
server, err := NewServer(context.Background(), source, fileReg, 0, nil)
require.NoError(t, err)
defer server.Close()

Expand All @@ -51,9 +44,8 @@ func TestE2E(t *testing.T) {
text := MarshalDotenv(envVars)
t.Logf("formatted env vars: %s", text)
// write the env vars to a file
f, err := os.CreateTemp("example", "temp-*.env")
f, err := os.CreateTemp(t.TempDir(), "temp-*.env")
require.NoError(t, err)
defer os.Remove(f.Name())
_, err = f.WriteString(text)
require.NoError(t, err)
err = f.Close()
Expand Down
182 changes: 90 additions & 92 deletions registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,136 +3,134 @@ package locket
import (
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

/*
Registry is the process by which pre-computed signing keys (ed25519)
are created before deploying either server or client.
- Public signing keys for all allowed services are provided to the server via .yml
- Public and private keys are provided to the client for signing requests.
- Separately, both client and server create encryption keys on startup.
- Public signing keys for all allowed services are provided to the server.
- Public and private keys are provided to the client for signing requests.
- Separately, both client and server create encryption keys on startup.
*/

// RegEntry is a single registry item,
// representing a single client which
// the server should recognize and authorize
// the server should recognize and authorize.
type RegEntry struct {
Name string `yaml:"name"`
KeyPub string `yaml:"keypub"`
Name string `yaml:"name" json:"name"`
KeyPub string `yaml:"keypub" json:"keypub"`
}

// WriteRegistry creates a yaml file with a registry of allowed clients.
func WriteRegistry(path string, data []RegEntry) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer f.Close()

for i, item := range data {
data[i].Name = strings.TrimSuffix(filepath.Base(item.Name), ".env")
}
// Registry reads and writes authorized client entries.
// Implementations include FileRegistry (local YAML) and
// RemoteRegistry (HTTP API).
type Registry interface {
// Entries returns all authorized clients.
Entries() ([]RegEntry, error)
// Upsert inserts or updates a client entry by name.
Upsert(RegEntry) error
// Delete removes a client entry by name.
Delete(name string) error
}

Comment thread
turkosaurus marked this conversation as resolved.
b, err := yaml.Marshal(data)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
_, err = f.Write(b)
if err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
// FileRegistry is a Registry backed by a local YAML file.
type FileRegistry struct {
Path string
}

// ReadRegistryFile turns a yaml file into a list of RegEntry
// for use in server authenticating client requests.
func ReadRegistryFile(filepath string) ([]RegEntry, error) {
f, err := os.ReadFile(filepath)
// Entries reads all authorized clients from the YAML file.
func (f FileRegistry) Entries() ([]RegEntry, error) {
b, err := os.ReadFile(f.Path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
var out []RegEntry
err = yaml.Unmarshal(f, &out)
err = yaml.Unmarshal(b, &out)
if err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
return out, nil
}

// UnmarshalRegistry turns a byte slice into a list of RegEntry
// for use in server authenticating client requests.
// Bytes format easier for embed.FS
func UnmarshalRegistry(bytes []byte) ([]RegEntry, error) {
var out []RegEntry
err := yaml.Unmarshal(bytes, &out)
if err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
return out, nil
}

// Register reads the existing registry file, upserts service key, and rewrites.
// If the registry file does not exist, it will be created.
// If no registry for the named service exists, a new entry will be created.
// An existing entry for the named service will be updated with new public key.
// Each new call of Register will generate new key pair, returning:
// public key, private key, or any error.
func Register(name string, registryPath string) (string, string, error) {
publicKey, privateKey, err := NewPairEd25519()
if err != nil {
return "", "", fmt.Errorf("generate key pair: %w", err)
}

// match the normalization WriteRegistry applies, so re-registering the same
// service updates its entry rather than appending a duplicate.
name = strings.TrimSuffix(filepath.Base(name), ".env")

var registry []RegEntry
_, err = os.Stat(registryPath)
if err == nil {
registry, err = ReadRegistryFile(registryPath)
// Upsert inserts or updates a client entry in the YAML file.
// If the file does not exist, it is created. The name is stored verbatim
// (exact-match, case-sensitive) to match the RemoteRegistry / cloud contract;
// derive a clean service name before calling if needed.
func (f FileRegistry) Upsert(entry RegEntry) error {
var entries []RegEntry
_, err := os.Stat(f.Path)
switch {
case err == nil:
entries, err = f.Entries()
if err != nil {
return "", "", fmt.Errorf("read registry file: %w", err)
return fmt.Errorf("read existing: %w", err)
}
Comment thread
turkosaurus marked this conversation as resolved.
} else {
log.Debug(
"registry file does not exist (or other err); will create new one",
"registryPath", registryPath,
"statError", err,
)
case !os.IsNotExist(err):
return fmt.Errorf("stat file: %w", err)
}

// check if the service already exists in the registry
replaced := false
for i, entry := range registry {
if entry.Name == name {
log.Debug("updating existing service in registry",
"service", name,
"publicKey", publicKey,
)
registry[i].KeyPub = publicKey
for i, e := range entries {
if e.Name == entry.Name {
entries[i].KeyPub = entry.KeyPub
replaced = true
break
}
}
if !replaced {
log.Debug("adding new service to registry",
"service", name,
"publicKey", publicKey,
)
registry = append(registry, RegEntry{
Name: name,
KeyPub: publicKey,
})
entries = append(entries, entry)
}

// write the updated registry
err = WriteRegistry(registryPath, registry)
return f.write(entries)
}

// Delete removes a client entry by name from the YAML file.
func (f FileRegistry) Delete(name string) error {
entries, err := f.Entries()
if err != nil {
return fmt.Errorf("read existing: %w", err)
}
filtered := entries[:0]
for _, e := range entries {
if e.Name != name {
filtered = append(filtered, e)
}
}
return f.write(filtered)
}

// Register generates a new ed25519 signing keypair, upserts the
// public key into the YAML file, and returns the keypair.
func (f FileRegistry) Register(name string) (string, string, error) {
pub, priv, err := NewPairEd25519()
if err != nil {
return "", "", fmt.Errorf("generate key pair: %w", err)
}
err = f.Upsert(RegEntry{Name: name, KeyPub: pub})
if err != nil {
return "", "", fmt.Errorf("upsert: %w", err)
}
return pub, priv, nil
}

// write serializes entries to the YAML file, creating or
// truncating it as needed.
func (f FileRegistry) write(entries []RegEntry) error {
file, err := os.Create(f.Path)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer file.Close()

b, err := yaml.Marshal(entries)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
_, err = file.Write(b)
if err != nil {
return "", "", fmt.Errorf("write updated registry: %w", err)
return fmt.Errorf("write file: %w", err)
}
return publicKey, privateKey, nil
return nil
}
Loading
Loading