Wrap native operator credentials with pinned age scrypt
4 files changed,
+153
−4
go.mod+5 −2go.sum+12 −2internal/provisioner/keys_linux.go+98 −0internal/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) |
| + } |
| +} |