oficina / home Public

Initialize native registries through a private-password CLI

Paul Campbell pushed by paul@keymail.dev 84cb58fff350f2a4ca86bde24f8888fce825dd8a parent a1d7618
7 files changed, +438 −31
  • cmd/oficina/main_linux.go +17 −0
  • go.mod +1 −0
  • internal/provisioner/cli_linux.go +77 −0
  • internal/provisioner/floor_linux.go +32 −22
  • internal/provisioner/init_linux.go +164 −0
  • internal/provisioner/init_linux_test.go +116 −0
  • internal/provisioner/journal_linux.go +31 −9

CI failed — run details

diff --git a/cmd/oficina/main_linux.go b/cmd/oficina/main_linux.go
new file mode 100644
index 0000000..17672e1
--- /dev/null
+++ b/cmd/oficina/main_linux.go
@@ -0,0 +1,17 @@
+//go:build linux
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "amadan.net/oficina/home/internal/provisioner"
+)
+
+func main() {
+ if err := provisioner.Run(os.Args[1:], os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/go.mod b/go.mod
index 95ab88e..24f70e1 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/keymaildev/signin v0.1.1
github.com/sqids/sqids-go v0.4.1
golang.org/x/sys v0.47.0
+ golang.org/x/term v0.45.0
gorm.io/plugin/dbresolver v1.6.2
modernc.org/sqlite v1.55.0
)
diff --git a/internal/provisioner/cli_linux.go b/internal/provisioner/cli_linux.go
new file mode 100644
index 0000000..d87c1af
--- /dev/null
+++ b/internal/provisioner/cli_linux.go
@@ -0,0 +1,77 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "flag"
+ "io"
+ "os"
+
+ "golang.org/x/term"
+)
+
+func Run(args []string, out io.Writer) error {
+ if len(args) < 2 || args[0] != "provision" || args[1] != "init" || out == nil {
+ return ErrRefused
+ }
+ flags := flag.NewFlagSet("oficina provision init", flag.ContinueOnError)
+ flags.SetOutput(io.Discard)
+ state := flags.String("state", "", "")
+ floor := flags.String("floor-dir", "", "")
+ passwordFD := flags.Int("password-fd", -1, "")
+ if flags.Parse(args[2:]) != nil || flags.NArg() != 0 || *state == "" || *floor == "" {
+ return ErrRefused
+ }
+ password, e := privatePassword(*passwordFD)
+ if e != nil {
+ return e
+ }
+ defer clear(password)
+ info, e := Initialize(*state, *floor, password)
+ if e != nil {
+ return e
+ }
+ raw, e := canonicalJSON(info)
+ if e != nil {
+ return e
+ }
+ if _, e = out.Write(append(raw, '\n')); e != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
+func privatePassword(fd int) ([]byte, error) {
+ if fd >= 3 {
+ f := os.NewFile(uintptr(fd), "private-password")
+ if f == nil {
+ return nil, ErrRefused
+ }
+ defer f.Close()
+ password, e := io.ReadAll(io.LimitReader(f, 1025))
+ if e != nil || len(password) == 0 || len(password) > 1024 {
+ return nil, ErrRefused
+ }
+ return password, nil
+ }
+ if fd != -1 {
+ return nil, ErrRefused
+ }
+ tty, e := os.OpenFile("/dev/tty", os.O_RDWR, 0)
+ if e != nil {
+ return nil, ErrRefused
+ }
+ defer tty.Close()
+ if !term.IsTerminal(int(tty.Fd())) {
+ return nil, ErrRefused
+ }
+ if _, e = tty.WriteString("Passphrase: "); e != nil {
+ return nil, ErrUnavailable
+ }
+ password, e := term.ReadPassword(int(tty.Fd()))
+ tty.WriteString("\n")
+ if e != nil || len(password) == 0 || len(password) > 1024 {
+ clear(password)
+ return nil, ErrRefused
+ }
+ return password, nil
+}
diff --git a/internal/provisioner/floor_linux.go b/internal/provisioner/floor_linux.go
index bc3667c..8892c32 100644
--- a/internal/provisioner/floor_linux.go
+++ b/internal/provisioner/floor_linux.go
@@ -99,12 +99,14 @@ func (f *nativeFloor) signed(k *family.Keypair, p floorPoint, previous string) (
return canonicalJSON(floorEnvelope{r, base64.RawURLEncoding.EncodeToString(sig)})
}
func (f *nativeFloor) initialize(k *family.Keypair) error {
- files, e := f.names()
+ files, e := directoryNames(f.dir)
if e != nil {
return e
}
- if len(files) != 0 {
- return ErrRefused
+ for _, name := range files {
+ if name != "lock" {
+ return ErrRefused
+ }
}
raw, e := f.signed(k, floorPoint{}, "")
if e != nil {
@@ -116,28 +118,10 @@ func (f *nativeFloor) initialize(k *family.Keypair) error {
return f.updateMarker(floorPoint{})
}
func (f *nativeFloor) names() ([]string, error) {
- // Open a fresh directory description: a retained Readdir offset must never
- // hide files appended since a previous verification.
- fd, e := openPrivateDirectory(f.dir.file.Name())
+ entries, e := directoryNames(f.dir)
if e != nil {
return nil, e
}
- defer fd.close()
- oldInfo, e := f.dir.file.Stat()
- if e != nil {
- return nil, ErrUnavailable
- }
- newInfo, e := fd.file.Stat()
- if e != nil || !os.SameFile(oldInfo, newInfo) {
- return nil, ErrRefused
- }
- entries, e := fd.file.Readdirnames(maxRecords + 5)
- if e != nil && e != io.EOF {
- return nil, ErrUnavailable
- }
- if len(entries) > maxRecords+4 {
- return nil, ErrRefused
- }
names := make([]string, 0, len(entries))
for _, name := range entries {
if name == "lock" || name == "head" || name == "head.tmp" {
@@ -235,3 +219,29 @@ func (f *nativeFloor) updateMarker(point floorPoint) error {
}
return nil
}
+
+func directoryNames(dir *privateDirectory) ([]string, error) {
+ // Open a fresh directory description: a retained Readdir offset must never
+ // hide files appended since a previous verification.
+ fd, e := openPrivateDirectory(dir.file.Name())
+ if e != nil {
+ return nil, e
+ }
+ defer fd.close()
+ oldInfo, e := dir.file.Stat()
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ newInfo, e := fd.file.Stat()
+ if e != nil || !os.SameFile(oldInfo, newInfo) {
+ return nil, ErrRefused
+ }
+ entries, e := fd.file.Readdirnames(maxRecords + 5)
+ if e != nil && e != io.EOF {
+ return nil, ErrUnavailable
+ }
+ if len(entries) > maxRecords+4 {
+ return nil, ErrRefused
+ }
+ return entries, nil
+}
diff --git a/internal/provisioner/init_linux.go b/internal/provisioner/init_linux.go
new file mode 100644
index 0000000..73f7f3c
--- /dev/null
+++ b/internal/provisioner/init_linux.go
@@ -0,0 +1,164 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "encoding/base64"
+ "os"
+ "strings"
+
+ family "amadan.net/rastrillo/rastrillo/crypto"
+)
+
+type RegistryInfo struct {
+ Registry string `json:"registry"`
+ Device string `json:"device"`
+ PublicKey string `json:"public_key"`
+ Sequence int64 `json:"sequence"`
+}
+
+// Initialize creates or resumes only the identity already recorded in these
+// independent directories. It never replaces a credential or existing floor.
+func Initialize(statePath, floorPath string, password []byte) (RegistryInfo, error) {
+ if statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") {
+ return RegistryInfo{}, ErrRefused
+ }
+ state, e := openPrivateDirectory(statePath)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer state.close()
+ floorDir, e := openPrivateDirectory(floorPath)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer floorDir.close()
+ stateLock, e := state.lock()
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer stateLock.Close()
+ floorLock, e := floorDir.lock()
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer floorLock.Close()
+ names, e := directoryNames(state)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ present := map[string]bool{}
+ for _, name := range names {
+ switch name {
+ case "lock", "credential.age", "initializing.json", "registry.db", "registry.db-wal", "registry.db-shm":
+ present[name] = true
+ default:
+ return RegistryInfo{}, ErrRefused
+ }
+ }
+ floorNames, e := directoryNames(floorDir)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ var id floorIdentity
+ var key *family.Keypair
+ if !present["credential.age"] {
+ for name := range present {
+ if name != "lock" {
+ return RegistryInfo{}, ErrRefused
+ }
+ }
+ for _, name := range floorNames {
+ if name != "lock" {
+ return RegistryInfo{}, ErrRefused
+ }
+ }
+ key, e = family.Generate()
+ if e != nil {
+ return RegistryInfo{}, ErrUnavailable
+ }
+ registry, e := family.NewKey()
+ if e != nil {
+ return RegistryInfo{}, ErrUnavailable
+ }
+ device, e := family.NewKey()
+ if e != nil {
+ return RegistryInfo{}, ErrUnavailable
+ }
+ id = floorIdentity{base64.RawURLEncoding.EncodeToString(registry), base64.RawURLEncoding.EncodeToString(device), base64.RawURLEncoding.EncodeToString(key.SignPub())}
+ wrapped, e := wrapCredential(key, id, password)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ if e = state.create("credential.age", wrapped); e != nil {
+ return RegistryInfo{}, e
+ }
+ } else {
+ raw, e := state.read("credential.age", 128<<10)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ id, key, e = unwrapCredential(raw, password)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ }
+ floor, e := newNativeFloor(floorDir, id)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ if present["initializing.json"] {
+ raw, e := state.read("initializing.json", 8192)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ var signed floorEnvelope
+ if closedJSON(raw, &signed) != nil || signed.Record.Version != 1 || signed.Record.Identity != id || signed.Record.Point != (floorPoint{}) || signed.Record.Previous != "" {
+ return RegistryInfo{}, ErrRefused
+ }
+ body, e := canonicalJSON(signed.Record)
+ sig, ok := tokenBytes(signed.Signature, 64)
+ if e != nil || !ok || !family.Verify(floor.public, floorContext, body, sig) {
+ return RegistryInfo{}, ErrRefused
+ }
+ } else {
+ if present["registry.db"] {
+ return RegistryInfo{}, ErrRefused
+ }
+ raw, e := floor.signed(key, floorPoint{}, "")
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ if e = state.create("initializing.json", raw); e != nil {
+ return RegistryInfo{}, e
+ }
+ }
+ hasFloor := false
+ for _, name := range floorNames {
+ if name != "lock" {
+ hasFloor = true
+ }
+ }
+ if !hasFloor {
+ if present["registry.db"] {
+ return RegistryInfo{}, ErrRefused
+ }
+ if e = floor.initialize(key); e != nil {
+ return RegistryInfo{}, e
+ }
+ } else {
+ if _, _, e = floor.current(); e != nil {
+ return RegistryInfo{}, e
+ }
+ }
+ j, e := openJournalWithLocks(state, floor, key, true, []*os.File{stateLock, floorLock}, true)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer j.close()
+ point, _, e := floor.current()
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ return RegistryInfo{id.Registry, id.Device, id.Public, point.Sequence}, nil
+}
diff --git a/internal/provisioner/init_linux_test.go b/internal/provisioner/init_linux_test.go
new file mode 100644
index 0000000..b18faab
--- /dev/null
+++ b/internal/provisioner/init_linux_test.go
@@ -0,0 +1,116 @@
+//go:build linux
+
+package provisioner
+
+import (
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "bytes"
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestInitializeResumesSameDurableIdentity(t *testing.T) {
+ state, floor := persistentDirectory(t), persistentDirectory(t)
+ password := []byte("synthetic init password")
+ first, e := Initialize(state, floor, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ credential, e := os.ReadFile(filepath.Join(state, "credential.age"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ next, e := Initialize(state, floor, password)
+ if e != nil || next != first {
+ t.Fatal("init identity changed", e)
+ }
+ again, e := os.ReadFile(filepath.Join(state, "credential.age"))
+ if e != nil || !bytes.Equal(credential, again) {
+ t.Fatal("credential replaced", e)
+ }
+ if _, e = Initialize(state, floor, []byte("wrong")); e != ErrRefused {
+ t.Fatal("wrong password", e)
+ }
+ if e = os.Remove(filepath.Join(state, "credential.age")); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = Initialize(state, floor, password); e != ErrRefused {
+ t.Fatal("lost credential silently recreated", e)
+ }
+}
+func TestInitializeRefusesLostFloorAndNestedStorage(t *testing.T) {
+ state, floor := persistentDirectory(t), persistentDirectory(t)
+ password := []byte("synthetic floor-loss password")
+ if _, e := Initialize(state, floor, password); e != nil {
+ t.Fatal(e)
+ }
+ entries, e := os.ReadDir(floor)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, entry := range entries {
+ if e = os.Remove(filepath.Join(floor, entry.Name())); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if _, e = Initialize(state, floor, password); e != ErrRefused {
+ t.Fatal("lost floor silently recreated", e)
+ }
+ nested := filepath.Join(state, "nested")
+ if e = os.Mkdir(nested, 0700); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = Initialize(state, nested, password); e != ErrRefused {
+ t.Fatal("floor included in registry backup scope", e)
+ }
+}
+
+func TestInitializeResumesExactGenesisBeforeDatabaseSchema(t *testing.T) {
+ state, floorPath := persistentDirectory(t), persistentDirectory(t)
+ d, e := openPrivateDirectory(state)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer d.close()
+ fd, e := openPrivateDirectory(floorPath)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer fd.close()
+ key, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ identity := floorIdentity{testToken(31), testToken(32), base64.RawURLEncoding.EncodeToString(key.SignPub())}
+ password := []byte("synthetic interrupted init")
+ wrapped, e := wrapCredential(key, identity, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = d.create("credential.age", wrapped); e != nil {
+ t.Fatal(e)
+ }
+ floor, e := newNativeFloor(fd, identity)
+ if e != nil {
+ t.Fatal(e)
+ }
+ genesis, e := floor.signed(key, floorPoint{}, "")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = d.create("initializing.json", genesis); e != nil {
+ t.Fatal(e)
+ }
+ if e = floor.initialize(key); e != nil {
+ t.Fatal(e)
+ }
+ if e = d.create("registry.db", nil); e != nil {
+ t.Fatal(e)
+ }
+ info, e := Initialize(state, floorPath, password)
+ if e != nil || info.Registry != identity.Registry || info.Device != identity.Device || info.PublicKey != identity.Public {
+ t.Fatal("exact genesis did not resume", e)
+ }
+}
diff --git a/internal/provisioner/journal_linux.go b/internal/provisioner/journal_linux.go
index b5b7cee..a696842 100644
--- a/internal/provisioner/journal_linux.go
+++ b/internal/provisioner/journal_linux.go
@@ -49,6 +49,10 @@ type journal struct {
}
func openJournal(dir *privateDirectory, floor *nativeFloor, key *family.Keypair, create bool) (*journal, error) {
+ return openJournalWithLocks(dir, floor, key, create, nil, false)
+}
+
+func openJournalWithLocks(dir *privateDirectory, floor *nativeFloor, key *family.Keypair, create bool, held []*os.File, resumeCreate bool) (*journal, error) {
if dir == nil || floor == nil || key == nil || key.SignPriv == nil || key.BoxPriv == nil || !bytes.Equal(key.SignPub(), floor.public) {
return nil, ErrRefused
}
@@ -57,14 +61,23 @@ func openJournal(dir *privateDirectory, floor *nativeFloor, key *family.Keypair,
if e != nil || floorErr != nil || os.SameFile(stateInfo, floorInfo) {
return nil, ErrRefused
}
- stateLock, e := dir.lock()
- if e != nil {
- return nil, e
- }
- floorLock, e := floor.dir.lock()
- if e != nil {
- stateLock.Close()
- return nil, e
+ var stateLock, floorLock *os.File
+ if held == nil {
+ stateLock, e = dir.lock()
+ if e != nil {
+ return nil, e
+ }
+ floorLock, e = floor.dir.lock()
+ if e != nil {
+ stateLock.Close()
+ return nil, e
+ }
+ } else {
+ if len(held) != 2 {
+ return nil, ErrRefused
+ }
+ stateLock = held[0]
+ floorLock = held[1]
}
keepLocks := false
defer func() {
@@ -74,7 +87,7 @@ func openJournal(dir *privateDirectory, floor *nativeFloor, key *family.Keypair,
}
}()
if create {
- if e := dir.create("registry.db", nil); e != nil {
+ if e := dir.create("registry.db", nil); e != nil && !resumeCreate {
return nil, e
}
}
@@ -105,6 +118,15 @@ func openJournal(dir *privateDirectory, floor *nativeFloor, key *family.Keypair,
if db.QueryRow("PRAGMA foreign_keys").Scan(&fk) != nil || db.QueryRow("PRAGMA secure_delete").Scan(&secure) != nil || db.QueryRow("PRAGMA synchronous").Scan(&synchronous) != nil || fk != 1 || secure != 1 || synchronous != 2 {
return fail()
}
+ if create && resumeCreate {
+ var tables int
+ if e = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); e != nil {
+ return fail()
+ }
+ if tables != 0 {
+ create = false
+ }
+ }
if create {
point, _, e := floor.current()
if e != nil || point != (floorPoint{}) {