Validate explicit Home issuer, public keys and storage key
Co-Authored-By: Codex <codex@openai.com>
2 files changed,
+259
−0
internal/docs/home_config.go+92 −0internal/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) |
| +} |