oficina / home Public

Wrap native operator credentials with pinned age scrypt

Paul Campbell pushed by paul@keymail.dev e0db957586b174dc02a1c38bc6a505758e835da8 parent 8402a96
4 files changed, +153 −4
  • go.mod +5 −2
  • go.sum +12 −2
  • internal/provisioner/keys_linux.go +98 −0
  • internal/provisioner/keys_linux_test.go +38 −0
diff --git a/go.mod b/go.mod
index 3ed1c6c..4fc7842 100644
--- a/go.mod
+++ b/go.mod
@@ -7,15 +7,19 @@ require (
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ filippo.io/age v1.3.2
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
github.com/keymaildev/signin v0.1.1
+ github.com/sqids/sqids-go v0.4.1
+ golang.org/x/sys v0.47.0
gorm.io/plugin/dbresolver v1.6.2
modernc.org/sqlite v1.55.0
)
require (
+ filippo.io/hpke v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@@ -23,8 +27,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/sqids/sqids-go v0.4.1 // indirect
- golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/crypto v0.55.0 // indirect
golang.org/x/text v0.41.0 // indirect
gorm.io/gorm v1.31.2 // indirect
modernc.org/libc v1.74.1 // indirect
diff --git a/go.sum b/go.sum
index b63cfca..6d706b2 100644
--- a/go.sum
+++ b/go.sum
@@ -6,6 +6,12 @@ amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5 h1:MQElRkuUPK4
amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5/go.mod h1:nhhPCCPaiXfZfQheJEOzKL8beyHkJjrag8vfmu/DcWU=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
+c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
+filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
+filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
+filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
+filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
@@ -50,6 +56,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sqids/sqids-go v0.4.1 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw=
github.com/sqids/sqids-go v0.4.1/go.mod h1:EMwHuPQgSNFS0A49jESTfIQS+066XQTVhukrzEPScl8=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
@@ -57,10 +65,12 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/internal/provisioner/keys_linux.go b/internal/provisioner/keys_linux.go
new file mode 100644
index 0000000..571099f
--- /dev/null
+++ b/internal/provisioner/keys_linux.go
@@ -0,0 +1,98 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "filippo.io/age"
+)
+
+type credential struct {
+ Version int `json:"version"`
+ Identity floorIdentity `json:"identity"`
+ Keypair string `json:"keypair"`
+}
+
+func wrapCredential(k *family.Keypair, id floorIdentity, password []byte) ([]byte, error) {
+ if len(password) == 0 || len(password) > 1024 || k == nil || k.SignPriv == nil || k.BoxPriv == nil || base64.RawURLEncoding.EncodeToString(k.SignPub()) != id.Public {
+ return nil, ErrRefused
+ }
+ raw, e := family.MarshalKeypair(k)
+ if e != nil {
+ return nil, ErrRefused
+ }
+ body, e := canonicalJSON(credential{1, id, base64.RawURLEncoding.EncodeToString(raw)})
+ if e != nil {
+ return nil, e
+ }
+ return encryptPrivate(body, password)
+}
+func unwrapCredential(ciphertext, password []byte) (floorIdentity, *family.Keypair, error) {
+ body, e := decryptPrivate(ciphertext, password, 16384)
+ if e != nil {
+ return floorIdentity{}, nil, e
+ }
+ var c credential
+ if closedJSON(body, &c) != nil || c.Version != 1 {
+ return floorIdentity{}, nil, ErrRefused
+ }
+ raw, e := base64.RawURLEncoding.DecodeString(c.Keypair)
+ if e != nil || base64.RawURLEncoding.EncodeToString(raw) != c.Keypair {
+ return floorIdentity{}, nil, ErrRefused
+ }
+ k, e := family.UnmarshalKeypair(raw)
+ if e != nil || base64.RawURLEncoding.EncodeToString(k.SignPub()) != c.Identity.Public {
+ return floorIdentity{}, nil, ErrRefused
+ }
+ if _, ok := tokenBytes(c.Identity.Registry, 32); !ok {
+ return floorIdentity{}, nil, ErrRefused
+ }
+ if _, ok := tokenBytes(c.Identity.Device, 32); !ok {
+ return floorIdentity{}, nil, ErrRefused
+ }
+ return c.Identity, k, nil
+}
+func encryptPrivate(body, password []byte) ([]byte, error) {
+ if len(body) > 64<<10 || len(password) == 0 || len(password) > 1024 {
+ return nil, ErrRefused
+ }
+ recipient, e := age.NewScryptRecipient(string(password))
+ if e != nil {
+ return nil, ErrRefused
+ }
+ var out bytes.Buffer
+ w, e := age.Encrypt(&out, recipient)
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ if _, e = w.Write(body); e != nil {
+ return nil, ErrUnavailable
+ }
+ if e = w.Close(); e != nil {
+ return nil, ErrUnavailable
+ }
+ return out.Bytes(), nil
+}
+func decryptPrivate(ciphertext, password []byte, limit int) ([]byte, error) {
+ if len(ciphertext) == 0 || len(ciphertext) > 128<<10 || len(password) == 0 || len(password) > 1024 || limit <= 0 || limit > 64<<10 {
+ return nil, ErrRefused
+ }
+ identity, e := age.NewScryptIdentity(string(password))
+ if e != nil {
+ return nil, ErrRefused
+ }
+ identity.SetMaxWorkFactor(18)
+ r, e := age.Decrypt(bytes.NewReader(ciphertext), identity)
+ if e != nil {
+ return nil, ErrRefused
+ }
+ body, e := io.ReadAll(io.LimitReader(r, int64(limit)+1))
+ if e != nil || len(body) > limit {
+ return nil, ErrRefused
+ }
+ return body, nil
+}
diff --git a/internal/provisioner/keys_linux_test.go b/internal/provisioner/keys_linux_test.go
new file mode 100644
index 0000000..0799426
--- /dev/null
+++ b/internal/provisioner/keys_linux_test.go
@@ -0,0 +1,38 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestAgeCredentialCustody(t *testing.T) {
+ floor, key := floorFixture(t)
+ password := []byte("synthetic-test-passphrase")
+ wrapped, e := wrapCredential(key, floor.identity, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if bytes.Contains(wrapped, password) || bytes.Contains(wrapped, []byte(floor.identity.Registry)) {
+ t.Fatal("plaintext credential in envelope")
+ }
+ if e = floor.dir.create("credential.age", wrapped); e != nil {
+ t.Fatal(e)
+ }
+ stored, e := floor.dir.read("credential.age", 128<<10)
+ if e != nil {
+ t.Fatal(e)
+ }
+ id, opened, e := unwrapCredential(stored, password)
+ if e != nil || id != floor.identity || !bytes.Equal(opened.SignPub(), key.SignPub()) || !bytes.Equal(opened.BoxPub(), key.BoxPub()) {
+ t.Fatal("credential round trip", e)
+ }
+ if _, _, e = unwrapCredential(stored, []byte("wrong")); e != ErrRefused {
+ t.Fatal("wrong passphrase", e)
+ }
+ stored[len(stored)-1] ^= 1
+ if _, _, e = unwrapCredential(stored, password); e != ErrRefused {
+ t.Fatal("tampered credential", e)
+ }
+}