Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 26 additions & 25 deletions internal/identity/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -819,31 +819,6 @@ func (s *Store) claimOrCreateDomain(ctx context.Context, domain, userID string,

verificationToken := "e2a-verify=" + generateID()

// Generate a DKIM keypair for this domain. Failures here are
// non-fatal — the columns are nullable and the outbound signer
// treats a missing key as "skip DKIM". We still log because key gen
// failing is a hard signal (entropy exhaustion or an OS-level
// CSPRNG bug) that ops should see.
var dkimSelector string
var dkimPubKey string
var dkimPrivKey []byte
if kp, kerr := dkim.GenerateKeypair(); kerr == nil {
// Encrypt the private key at rest (#144). On seal failure (catastrophic
// RNG) drop ALL three DKIM columns so we never publish a public key /
// selector without a usable private key — non-fatal, same posture as a
// keygen failure (the signer treats a missing key as "skip DKIM").
sealed, serr := s.sealDKIM(kp.PrivateKeyDER, domain)
if serr != nil {
log.Printf("[identity] dkim key seal failed for %s: %v", domain, serr)
} else {
dkimSelector = kp.Selector
dkimPubKey = kp.PublicKeyDNS
dkimPrivKey = sealed
}
} else {
log.Printf("[identity] dkim keygen failed for %s: %v", domain, kerr)
}

tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
Expand Down Expand Up @@ -926,6 +901,32 @@ func (s *Store) claimOrCreateDomain(ctx context.Context, domain, userID string,
return nil, &DomainLimitExceededError{Limit: maxDomains, Current: count}
}
}

// Generate a DKIM keypair for this domain. Only reached on a genuine
// new row (#826); a re-claim returns above via SELECT and never runs
// this. Non-fatal on failure (nullable columns; signer treats a
// missing key as "skip DKIM"), logged since it signals RNG/entropy
// trouble.
var dkimSelector string
var dkimPubKey string
var dkimPrivKey []byte
if kp, kerr := dkim.GenerateKeypair(); kerr == nil {
// Encrypt the private key at rest (#144). On seal failure (catastrophic
// RNG) drop ALL three DKIM columns so we never publish a public key /
// selector without a usable private key, non-fatal, same posture as a
// keygen failure (the signer treats a missing key as "skip DKIM").
sealed, serr := s.sealDKIM(kp.PrivateKeyDER, domain)
if serr != nil {
log.Printf("[identity] dkim key seal failed for %s: %v", domain, serr)
} else {
dkimSelector = kp.Selector
dkimPubKey = kp.PublicKeyDNS
dkimPrivKey = sealed
}
} else {
log.Printf("[identity] dkim keygen failed for %s: %v", domain, kerr)
}

err = tx.QueryRow(ctx,
`INSERT INTO domains (domain, user_id, verified, verification_token, dkim_selector, dkim_public_key, dkim_private_key)
VALUES ($1, $2, false, $3, $4, $5, $6)
Expand Down
47 changes: 47 additions & 0 deletions internal/identity/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package identity_test

import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
Expand Down Expand Up @@ -140,6 +142,51 @@ func TestClaimOrCreateDomain_StableOnReclaim(t *testing.T) {
}
}

// countingRandReader counts bytes read through crypto/rand.Reader, so a test
// can detect an RSA-2048 keygen (tens of KB, vs. 16 bytes for a plain
// token/ID read) without depending on timing.
type countingRandReader struct {
orig io.Reader
n int
}

func (c *countingRandReader) Read(p []byte) (int, error) {
n, err := c.orig.Read(p)
c.n += n
return n, err
}

// TestClaimOrCreateDomain_ReclaimSkipsDKIMKeygen pins #826: a reclaim must
// not generate and discard a fresh DKIM keypair. Swaps crypto/rand.Reader
// for a counter; the threshold sits well above generateID's 16 bytes.
func TestClaimOrCreateDomain_ReclaimSkipsDKIMKeygen(t *testing.T) {
pool := testutil.TestDB(t)
store := identity.NewStore(pool)
ctx := context.Background()

user, _ := store.CreateOrGetUser(ctx, "owner-keygen@example.com", "Owner", "google-keygen-token")

if _, err := store.ClaimOrCreateDomain(ctx, "keygen.example.com", user.ID); err != nil {
t.Fatalf("first ClaimOrCreateDomain: %v", err)
}

orig := rand.Reader
counter := &countingRandReader{orig: orig}
rand.Reader = counter
_, err := store.ClaimOrCreateDomain(ctx, "keygen.example.com", user.ID)
rand.Reader = orig
if err != nil {
t.Fatalf("reclaim ClaimOrCreateDomain: %v", err)
}

// generateID() alone reads 16 bytes; RSA-2048 keygen reads tens of
// thousands. 256 cleanly separates "just the ID" from "also a keypair".
const maxExpectedBytes = 256
if counter.n > maxExpectedBytes {
t.Errorf("reclaim of an already-owned domain read %d bytes from crypto/rand (want <= %d): a DKIM keypair was generated and discarded", counter.n, maxExpectedBytes)
}
}

// TestClaimOrCreateDomain_CrossUserReclaimRejected asserts that a second
// user cannot take over an unverified domain that another user has
// already claimed. Combined with the stable verification_token, this
Expand Down