oficina / docs Public

Validate explicit Home issuer, public keys and storage key

Co-Authored-By: Codex <codex@openai.com>
Paul Campbell pushed by paul@keymail.dev d5030ec1bd6a987f5c46bf7804d39e288fd234e6 parent 7f66b43
2 files changed, +259 −0
  • internal/docs/home_config.go +92 −0
  • internal/docs/home_config_test.go +167 −0

CI passed — run details

diff --git a/internal/docs/home_config.go b/internal/docs/home_config.go
new file mode 100644
index 0000000..499b51f
--- /dev/null
+++ b/internal/docs/home_config.go
@@ -0,0 +1,92 @@
+package docs
+
+import (
+ "encoding/base64"
+ "errors"
+
+ "amadan.net/oficina/home/handoff/seal"
+ "amadan.net/rastrillo/rastrillo/assertion"
+ json "github.com/go-json-experiment/json"
+)
+
+// HomeConfig explicitly enables Home authentication. The public key manifest
+// is StdBase64-encoded JSON mapping key IDs to StdBase64 P-256 public keys.
+// DEKBase64 is a dedicated handoff/storage key, never the document content key.
+type HomeConfig struct {
+ Issuer string
+ ConsumerID string
+ KeysBase64 string
+ DEKBase64 string
+}
+
+type homeSettings struct {
+ issuer string
+ consumerID string
+ keys map[string][]byte
+ dek []byte
+}
+
+var errHomeConfig = errors.New("docs: invalid Home configuration")
+
+// A nil config preserves standalone authentication; an incomplete config
+// cannot silently fall back to it. Errors never include supplied values.
+func validatedHomeConfig(cfg *HomeConfig, origin string) (*homeSettings, error) {
+ if cfg == nil {
+ return nil, nil
+ }
+ if !homeConsumerID(cfg.ConsumerID) {
+ return nil, errHomeConfig
+ }
+ dek, ok := homeConfigBase64(cfg.DEKBase64, 32)
+ if !ok {
+ return nil, errHomeConfig
+ }
+ if _, err := seal.New(dek, "oficina/docs"); err != nil {
+ return nil, errHomeConfig
+ }
+ manifest, ok := homeConfigBase64(cfg.KeysBase64, 4096)
+ if !ok {
+ return nil, errHomeConfig
+ }
+ var encoded map[string]string
+ // This decoder rejects duplicate names, invalid UTF-8 and trailing values.
+ // A map of strings also rejects wrapper objects, arrays and nested key data.
+ if json.Unmarshal(manifest, &encoded) != nil || len(encoded) == 0 || len(encoded) > 8 {
+ return nil, errHomeConfig
+ }
+ keys := make(map[string][]byte, len(encoded))
+ for id, encodedKey := range encoded {
+ key, ok := homeConfigBase64(encodedKey, 65)
+ if !ok {
+ return nil, errHomeConfig
+ }
+ keys[id] = key
+ }
+ if _, err := assertion.NewVerifier(cfg.Issuer, origin, keys); err != nil {
+ return nil, errHomeConfig
+ }
+ return &homeSettings{issuer: cfg.Issuer, consumerID: cfg.ConsumerID, keys: keys, dek: dek}, nil
+}
+
+func homeConsumerID(v string) bool {
+ if len(v) == 0 || len(v) > 64 {
+ return false
+ }
+ for _, b := range []byte(v) {
+ if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+func homeConfigBase64(encoded string, limit int) ([]byte, bool) {
+ if encoded == "" || len(encoded) > base64.StdEncoding.EncodedLen(limit) {
+ return nil, false
+ }
+ decoded, err := base64.StdEncoding.Strict().DecodeString(encoded)
+ if err != nil || len(decoded) > limit || base64.StdEncoding.EncodeToString(decoded) != encoded {
+ return nil, false
+ }
+ return decoded, true
+}
diff --git a/internal/docs/home_config_test.go b/internal/docs/home_config_test.go
new file mode 100644
index 0000000..113855f
--- /dev/null
+++ b/internal/docs/home_config_test.go
@@ -0,0 +1,167 @@
+package docs
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+)
+
+const configOrigin = "https://blue.docs.oficina.works"
+
+func homeConfigFixture(t *testing.T) (HomeConfig, string, []byte) {
+ t.Helper()
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ public := key.SignPub()
+ sum := sha256.Sum256(public)
+ id := hex.EncodeToString(sum[:])
+ manifest, err := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(public)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return HomeConfig{Issuer: "https://id.oficina.works", ConsumerID: "blue-docs_1", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32))}, id, public
+}
+func requireHomeConfigRefused(t *testing.T, cfg *HomeConfig, origin string) {
+ t.Helper()
+ settings, err := validatedHomeConfig(cfg, origin)
+ if err != errHomeConfig || settings != nil {
+ t.Fatalf("configuration acceptance: settings present=%v, error=%v", settings != nil, err)
+ }
+ if err.Error() != "docs: invalid Home configuration" {
+ t.Fatal("configuration error leaked a supplied value")
+ }
+}
+
+func TestHomeConfigStandaloneAndValidPublicManifest(t *testing.T) {
+ settings, err := validatedHomeConfig(nil, "")
+ if err != nil || settings != nil {
+ t.Fatal("nil configuration changed standalone behavior")
+ }
+ cfg, id, pub := homeConfigFixture(t)
+ settings, err = validatedHomeConfig(&cfg, configOrigin)
+ if err != nil || settings == nil {
+ t.Fatal("actual Home key manifest refused", err)
+ }
+ if settings.issuer != cfg.Issuer || settings.consumerID != cfg.ConsumerID || !bytes.Equal(settings.keys[id], pub) || !bytes.Equal(settings.dek, bytes.Repeat([]byte{17}, 32)) {
+ t.Fatal("decoded configuration changed values")
+ }
+ // Encoded JSON may contain ordinary JSON whitespace. The outer base64 must
+ // remain canonical; requiring canonical JSON would reject Home's own shape.
+ raw, _ := base64.StdEncoding.DecodeString(cfg.KeysBase64)
+ cfg.KeysBase64 = base64.StdEncoding.EncodeToString(append(append([]byte(" \n"), raw...), []byte("\t ")...))
+ if _, err := validatedHomeConfig(&cfg, configOrigin); err != nil {
+ t.Fatal("JSON whitespace refused", err)
+ }
+}
+
+func TestHomeConfigRejectsPartialConfiguration(t *testing.T) {
+ cfg, _, _ := homeConfigFixture(t)
+ for _, field := range []string{"all", "issuer", "consumer", "keys", "dek"} {
+ t.Run(field, func(t *testing.T) {
+ broken := cfg
+ switch field {
+ case "all":
+ broken = HomeConfig{}
+ case "issuer":
+ broken.Issuer = ""
+ case "consumer":
+ broken.ConsumerID = ""
+ case "keys":
+ broken.KeysBase64 = ""
+ case "dek":
+ broken.DEKBase64 = ""
+ }
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ })
+ }
+}
+
+func TestHomeConfigUsesExactCanonicalAssertionOrigins(t *testing.T) {
+ cfg, _, _ := homeConfigFixture(t)
+ for _, bad := range []string{"", "http://id.oficina.works", " https://id.oficina.works", "https://id.oficina.works ", "https://id.oficina.works/", "https://id.oficina.works/path", "https://id.oficina.works?query=private", "https://id.oficina.works#private", "https://user:secret@id.oficina.works", "https://0x", "https://ID.oficina.works", "https://id.oficina.works:443", "https://id.oficina.works\n"} {
+ t.Run(bad, func(t *testing.T) {
+ broken := cfg
+ broken.Issuer = bad
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ requireHomeConfigRefused(t, &cfg, bad)
+ })
+ }
+ requireHomeConfigRefused(t, &cfg, cfg.Issuer)
+}
+
+func TestHomeConfigConsumerIDGrammar(t *testing.T) {
+ cfg, _, _ := homeConfigFixture(t)
+ for _, id := range []string{"", strings.Repeat("x", 65), "docs/one", "docs.one", "docs one", " docs", "docs\n", "dócs", "private?value"} {
+ cfg.ConsumerID = id
+ requireHomeConfigRefused(t, &cfg, configOrigin)
+ }
+ cfg.ConsumerID = strings.Repeat("A", 64)
+ if _, err := validatedHomeConfig(&cfg, configOrigin); err != nil {
+ t.Fatal("64-byte valid consumer refused", err)
+ }
+}
+
+func TestHomeConfigRejectsNoncanonicalOrWrongSizeDEK(t *testing.T) {
+ cfg, _, _ := homeConfigFixture(t)
+ for _, value := range []string{"private-invalid-secret", cfg.DEKBase64 + "\n", " " + cfg.DEKBase64, strings.TrimRight(cfg.DEKBase64, "="), cfg.DEKBase64[:42] + "B=", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{17}, 31)), base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{17}, 33)), strings.Repeat("A", 100000)} {
+ broken := cfg
+ broken.DEKBase64 = value
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ }
+}
+
+func TestHomeConfigRejectsAmbiguousKeyManifests(t *testing.T) {
+ cfg, id, pub := homeConfigFixture(t)
+ value := base64.StdEncoding.EncodeToString(pub)
+ quotedID, _ := json.Marshal(id)
+ quotedValue, _ := json.Marshal(value)
+ pair := string(quotedID) + ":" + string(quotedValue)
+ for _, raw := range []string{"", `null`, `{}`, `[]`, `"private-value"`, `{"keys":{}}`, `{"k":null}`, `{"k":1}`, `{"k":[]}`, "{" + pair + "," + pair + "}", "{" + pair + "}{}", "{" + pair + "} private-trailing", `{"private key":"` + value + `"}`, `{"":"` + value + `"}`, `{"` + strings.Repeat("x", 65) + `":"` + value + `"}`, "{\"k\":\"\xff\"}"} {
+ t.Run(fmt.Sprintf("shape-%x", sha256.Sum256([]byte(raw))), func(t *testing.T) {
+ broken := cfg
+ broken.KeysBase64 = base64.StdEncoding.EncodeToString([]byte(raw))
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ })
+ }
+ // Equivalent escaped names are duplicates too, not distinct trusted keys.
+ broken := cfg
+ broken.KeysBase64 = base64.StdEncoding.EncodeToString([]byte(`{"k":"` + value + `","\u006b":"` + value + `"}`))
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ for _, value := range []string{cfg.KeysBase64 + "\n", " " + cfg.KeysBase64, "private-base64", strings.Repeat("A", base64.StdEncoding.EncodedLen(4096)+4), base64.StdEncoding.EncodeToString([]byte(strings.Repeat(" ", 4097)))} {
+ broken := cfg
+ broken.KeysBase64 = value
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ }
+}
+
+func TestHomeConfigKeyBoundsAndP256Validation(t *testing.T) {
+ cfg, _, pub := homeConfigFixture(t)
+ for _, encodedKey := range []string{"", "private-invalid-public-key", base64.StdEncoding.EncodeToString(pub) + "\n", base64.StdEncoding.EncodeToString(pub[:64]), base64.StdEncoding.EncodeToString(append(pub, 0)), base64.StdEncoding.EncodeToString(make([]byte, 65)), strings.Repeat("A", 10000)} {
+ raw, _ := json.Marshal(map[string]string{"key": encodedKey})
+ broken := cfg
+ broken.KeysBase64 = base64.StdEncoding.EncodeToString(raw)
+ requireHomeConfigRefused(t, &broken, configOrigin)
+ }
+ keys := map[string]string{}
+ for i := 0; i < 8; i++ {
+ keys[fmt.Sprintf("key-%d", i)] = base64.StdEncoding.EncodeToString(pub)
+ }
+ raw, _ := json.Marshal(keys)
+ cfg.KeysBase64 = base64.StdEncoding.EncodeToString(raw)
+ if _, err := validatedHomeConfig(&cfg, configOrigin); err != nil {
+ t.Fatal("eight valid keys refused", err)
+ }
+ keys["key-nine"] = base64.StdEncoding.EncodeToString(pub)
+ raw, _ = json.Marshal(keys)
+ cfg.KeysBase64 = base64.StdEncoding.EncodeToString(raw)
+ requireHomeConfigRefused(t, &cfg, configOrigin)
+}