import: bounds on a domain export (audit W4, W5, W8)

Security audit 2026-10-17, findings W4/W5/W8 (low), stage 4 - all
global-only and self-inflicted, but unbounded:
- W4: ImportKey took any RSA key; a 16 384-bit key makes every signature
  of the domain cost seconds. Now 1024-4096 bits (MinImportKeyBits,
  MaxImportKeyBits); under 1024 Go's parser already refuses.
- W5: an export's applications were unbounded, each a copy of sasldb2 and
  a saslpasswd2 run on the panel's one connection. Now at most 500
  (maxImportApplications).
- W8: an import named as the hostname silently replaced the server's own
  signing key while DNS still held the old record. Now refused with a
  sentence.

Tests: TestImportKeyRefusesAnOversizedKey (4104 bits); the import cases.
Docs: guide § Moving a domain; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 22:27:21 +03:00
co-authored by Claude Opus 5.5
parent 668e299c69
commit bde6a4000e
7 changed files with 66 additions and 3 deletions
+12
View File
@@ -120,6 +120,12 @@ func (o *Keys) ExportKey(domainName, selector string) ([]byte, error) {
return pem.EncodeToMemory(block), nil
}
// The sizes of an imported DKIM key ImportKey takes.
const (
MinImportKeyBits = 1024
MaxImportKeyBits = 4096
)
// ImportKey writes an imported DKIM private key to disk for a domain
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
@@ -138,6 +144,12 @@ func (o *Keys) ImportKey(domainName, selector string, pemKey []byte) error {
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
// Within what receivers verify and what signing can afford: RFC 8301 §
// 3.2 asks for at least 1024 bits, and a key far above 4096 makes every
// signature of the domain cost seconds (security audit 2026-10-17, W4).
if bits := key.N.BitLen(); bits < MinImportKeyBits || bits > MaxImportKeyBits {
return fmt.Errorf("import dkim key for %s: a %d-bit key; SelfPost signs with keys of %d to %d bits", domainName, bits, MinImportKeyBits, MaxImportKeyBits)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create key dir: %w", err)
+20
View File
@@ -1,8 +1,13 @@
package domain
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/store"
@@ -196,3 +201,18 @@ func TestImportRollsBackOnAppFailure(t *testing.T) {
t.Error("expected rollback to remove the imported DKIM key")
}
}
// An imported key over 4096 bits is refused (security audit 2026-10-17,
// W4): every signature of the domain would cost seconds. One under 1024 is
// refused by the parser already.
func TestImportKeyRefusesAnOversizedKey(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 4104)
if err != nil {
t.Fatal(err)
}
pemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
err = NewKeys(t.TempDir()).ImportKey("example.com", "sel", pemKey)
if err == nil || !strings.Contains(err.Error(), "a 4104-bit key; SelfPost signs with keys of 1024 to 4096 bits") {
t.Errorf("a 4104-bit key: %v", err)
}
}