Initialize native registries through a private-password CLI
7 files changed,
+438
−31
cmd/oficina/main_linux.go+17 −0go.mod+1 −0internal/provisioner/cli_linux.go+77 −0internal/provisioner/floor_linux.go+32 −22internal/provisioner/init_linux.go+164 −0internal/provisioner/init_linux_test.go+116 −0internal/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{}) { |