Files
Hammerhead/dkim.go
T
VeillaxandClaude Sonnet 5.5 f521fa8668 Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC
single sign-on, API keys, revocable sessions), an encrypted SQLite database,
resource logins, alerts and /metrics, mail egress management with DKIM keys,
and the .env loader. Split out of the original single repository; depends on
Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip
and Bamboo together live in e2e/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:50:03 -06:00

146 lines
4.5 KiB
Go

package hammerhead
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"net/http"
"regexp"
"sort"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
)
// DKIM: Hammerhead makes a signing key for a sending domain, gives the edge the
// private half, and shows you the public half as the DNS record to publish. The
// edge signs mail on its way out; receiving servers fetch the record to check it.
var selectorRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$`)
func (k *dkimView) record() string { return "v=DKIM1; k=rsa; p=" + k.PublicKey }
type dkimView struct {
ID string `json:"id"`
Domain string `json:"domain"`
Selector string `json:"selector"`
PublicKey string `json:"publicKey"`
Created time.Time `json:"created"`
RecordName string `json:"recordName"`
Record string `json:"record"`
Status string `json:"status"` // published, missing, mismatch or unknown
}
func (s *Server) listDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
out := []dkimView{}
for _, k := range s.st.DKIM {
if k.OrgID != c.Org.ID {
continue
}
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
for _, x := range s.st.MailEgress {
if x.OrgID != c.Org.ID {
continue
}
switch st := s.mailChecks[x.ID].DKIM[k.ID]; {
case st == "published":
v.Status = "published"
case st != "" && v.Status != "published":
v.Status = st
}
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Domain+out[i].Selector < out[j].Domain+out[j].Selector })
writeJSON(w, 200, out)
}
func (s *Server) createDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ Domain, Selector string }
if !decode(w, r, &in) {
return
}
in.Domain = strings.ToLower(strings.TrimSpace(in.Domain))
in.Selector = strings.ToLower(strings.TrimSpace(in.Selector))
if in.Selector == "" {
in.Selector = "hh" + time.Now().Format("200601")
}
if !validHostname(in.Domain) || strings.HasPrefix(in.Domain, "*.") {
fail(w, 400, errors.New("enter the domain you send mail from, like example.com"))
return
}
if !selectorRE.MatchString(in.Selector) {
fail(w, 400, errors.New("a selector is letters, digits and dashes, like mail2026"))
return
}
key, err := rsa.GenerateKey(rand.Reader, 2048) // RSA: every receiving server can verify it
if err != nil {
fail(w, 500, err)
return
}
priv, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
fail(w, 500, err)
return
}
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
fail(w, 500, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, k := range s.st.DKIM {
if k.Domain == in.Domain && k.Selector == in.Selector {
fail(w, 400, errors.New("you already have a key with that selector for this domain; pick a new selector to rotate"))
return
}
}
k := &model.DKIMKey{ID: newID(), OrgID: c.Org.ID, Domain: in.Domain, Selector: in.Selector, Created: time.Now(),
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv})), PublicKey: base64.StdEncoding.EncodeToString(pub)}
s.st.DKIM = append(s.st.DKIM, k)
s.bump() // edges need the new signer
s.audit(c.Org.ID, c.Actor, "dkim.create", in.Selector+"._domainkey."+in.Domain)
v := dkimView{ID: k.ID, Domain: k.Domain, Selector: k.Selector, PublicKey: k.PublicKey, Created: k.Created, Status: "unknown"}
v.RecordName, v.Record = k.Selector+"._domainkey."+k.Domain, v.record()
writeJSON(w, 200, v)
}
func (s *Server) deleteDKIM(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
var gone *model.DKIMKey
for _, k := range s.st.DKIM {
if k.ID == id && k.OrgID == c.Org.ID {
gone = k
}
}
if gone == nil {
fail(w, 404, errors.New("no such key"))
return
}
s.st.DKIM = filter(s.st.DKIM, func(k *model.DKIMKey) bool { return k.ID != id })
s.bump()
s.audit(c.Org.ID, c.Actor, "dkim.delete", gone.Selector+"._domainkey."+gone.Domain)
ok(w)
}
// signersLocked lists the signing keys an org's mail egress may use. Caller holds s.mu.
func (s *Server) signersLocked(orgID string) []model.DKIMSigner {
out := []model.DKIMSigner{}
for _, k := range s.st.DKIM {
if k.OrgID == orgID {
out = append(out, model.DKIMSigner{KeyID: k.ID, Domain: k.Domain, Selector: k.Selector, PrivateKey: k.PrivateKey, PublicKey: k.PublicKey})
}
}
return out
}