feat(dkim): make key size configurable and allow keys to be rotated

This commit is contained in:
Adam Cooke
2026-10-01 12:32:46 +01:00
committed by GitHub
parent ced77a3860
commit d96eddbade
16 changed files with 845 additions and 93 deletions
+27 -25
View File
@@ -4,31 +4,33 @@
#
# Table name: domains
#
# id :integer not null, primary key
# server_id :integer
# uuid :string(255)
# name :string(255)
# verification_token :string(255)
# verification_method :string(255)
# verified_at :datetime
# dkim_private_key :text(65535)
# created_at :datetime
# updated_at :datetime
# dns_checked_at :datetime
# spf_status :string(255)
# spf_error :string(255)
# dkim_status :string(255)
# dkim_error :string(255)
# mx_status :string(255)
# mx_error :string(255)
# return_path_status :string(255)
# return_path_error :string(255)
# outgoing :boolean default(TRUE)
# incoming :boolean default(TRUE)
# owner_type :string(255)
# owner_id :integer
# dkim_identifier_string :string(255)
# use_for_any :boolean
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# pending_dkim_identifier_string :string(255)
# pending_dkim_private_key :text(65535)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
#
# Indexes
#
+300 -25
View File
@@ -4,31 +4,33 @@
#
# Table name: domains
#
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
# id :integer not null, primary key
# dkim_error :string(255)
# dkim_identifier_string :string(255)
# dkim_private_key :text(65535)
# dkim_status :string(255)
# dns_checked_at :datetime
# incoming :boolean default(TRUE)
# mx_error :string(255)
# mx_status :string(255)
# name :string(255)
# outgoing :boolean default(TRUE)
# owner_type :string(255)
# pending_dkim_identifier_string :string(255)
# pending_dkim_private_key :text(65535)
# return_path_error :string(255)
# return_path_status :string(255)
# spf_error :string(255)
# spf_status :string(255)
# use_for_any :boolean
# uuid :string(255)
# verification_method :string(255)
# verification_token :string(255)
# verified_at :datetime
# created_at :datetime
# updated_at :datetime
# owner_id :integer
# server_id :integer
#
# Indexes
#
@@ -162,6 +164,22 @@ describe Domain do
it "generates a new dkim key" do
expect { domain.generate_dkim_key }.to change { domain.dkim_private_key }.from(nil).to(match(/\A-+BEGIN RSA PRIVATE KEY-+/))
end
it "generates a key of the configured size" do
domain.generate_dkim_key
expect(OpenSSL::PKey::RSA.new(domain.dkim_private_key).n.num_bits).to eq Postal::Config.dns.dkim_key_size
end
context "when a different key size is configured" do
before do
allow(Postal::Config.dns).to receive(:dkim_key_size).and_return(1024)
end
it "generates a key of that size" do
domain.generate_dkim_key
expect(OpenSSL::PKey::RSA.new(domain.dkim_private_key).n.num_bits).to eq 1024
end
end
end
describe "#dkim_key" do
@@ -183,6 +201,138 @@ describe Domain do
end
end
describe "#pending_dkim_key" do
context "when the domain has a pending DKIM key" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "returns the pending dkim key as a OpenSSL::PKey::RSA" do
expect(domain.pending_dkim_key).to be_a OpenSSL::PKey::RSA
expect(domain.pending_dkim_key.to_s).to eq domain.pending_dkim_private_key
end
end
context "when the domain has no pending DKIM key" do
let(:domain) { create(:domain) }
it "returns nil" do
expect(domain.pending_dkim_key).to be_nil
end
end
end
describe "#regenerate_dkim_key!" do
context "when the current DKIM record is verified" do
let(:domain) { create(:domain, dkim_status: "OK") }
it "stores the new key as a pending key with a new identifier" do
domain.regenerate_dkim_key!
expect(domain.pending_dkim_private_key).to match(/\A-+BEGIN RSA PRIVATE KEY-+/)
expect(domain.pending_dkim_identifier_string).to be_present
expect(domain.pending_dkim_identifier_string).to_not eq domain.dkim_identifier_string
end
it "does not change the active key or status" do
expect { domain.regenerate_dkim_key! }.to_not(change { domain.reload.dkim_private_key })
expect(domain.dkim_status).to eq "OK"
end
end
context "when the current DKIM record is not verified" do
let(:domain) { create(:domain, dkim_status: "Missing", dkim_error: "No TXT records") }
it "replaces the active key immediately without creating a pending key" do
original_key = domain.dkim_key.to_s
domain.regenerate_dkim_key!
expect(domain.reload.dkim_private_key).to_not eq original_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_key.to_s).to eq domain.dkim_private_key
end
it "resets the DKIM status" do
domain.regenerate_dkim_key!
expect(domain.dkim_status).to be_nil
expect(domain.dkim_error).to be_nil
end
context "when a key change was already in progress" do
# This happens when the active record breaks (so the status is no longer
# "OK") while a pending key is waiting to be activated. The immediate
# replacement supersedes the in-flight change, so the stale pending key
# must not be left behind to be activated later.
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
domain.update!(dkim_status: "Missing", dkim_error: "No TXT records")
end
it "abandons the pending key" do
expect(domain.pending_dkim_private_key).to_not be_nil
domain.regenerate_dkim_key!
expect(domain.reload.pending_dkim_private_key).to be_nil
expect(domain.pending_dkim_identifier_string).to be_nil
expect(domain.pending_dkim_key).to be_nil
end
it "replaces the active key rather than promoting the pending one" do
stale_pending_key = domain.pending_dkim_private_key
original_key = domain.dkim_private_key
domain.regenerate_dkim_key!
expect(domain.reload.dkim_private_key).to_not eq original_key
expect(domain.dkim_private_key).to_not eq stale_pending_key
end
end
end
end
describe "#activate_pending_dkim_key" do
context "when there is no pending key" do
let(:domain) { create(:domain) }
it "does nothing" do
expect { domain.activate_pending_dkim_key }.to_not(change { domain.dkim_private_key })
end
end
context "when there is a pending key" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "promotes the pending key and identifier and clears the pending values" do
pending_key = domain.pending_dkim_private_key
pending_identifier = domain.pending_dkim_identifier_string
domain.activate_pending_dkim_key
expect(domain.dkim_private_key).to eq pending_key
expect(domain.dkim_identifier_string).to eq pending_identifier
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.pending_dkim_identifier_string).to be_nil
expect(domain.dkim_key.to_s).to eq pending_key
expect(domain.pending_dkim_key).to be_nil
end
end
end
describe "#cancel_pending_dkim_key!" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "discards the pending key without changing the active key" do
expect { domain.cancel_pending_dkim_key! }.to_not(change { domain.reload.dkim_private_key })
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.pending_dkim_identifier_string).to be_nil
end
end
describe "#to_param" do
context "when the domain has not been saved" do
it "returns nil" do
@@ -274,6 +424,131 @@ describe Domain do
end
end
describe "#pending_dkim_record" do
context "when the domain has no pending DKIM key" do
it "returns nil" do
expect(domain.pending_dkim_record).to be_nil
end
end
context "when the domain has a pending DKIM key" do
let(:domain) { create(:domain, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "returns the DKIM record for the pending key" do
expect(domain.pending_dkim_record).to match(/\Av=DKIM1; t=s; h=sha256; p=.*;\z/)
expect(domain.pending_dkim_record).to_not eq domain.dkim_record
end
it "returns a record name using the pending identifier" do
expect(domain.pending_dkim_record_name).to eq "#{Postal::Config.dns.dkim_identifier}-#{domain.pending_dkim_identifier_string}._domainkey"
end
end
end
describe "#check_dkim_record" do
let(:domain) { create(:domain, dkim_status: "OK") }
let(:resolver) { instance_double(DNSResolver) }
before do
allow(domain).to receive(:resolver).and_return(resolver)
end
context "when there is a pending DKIM key" do
before do
domain.regenerate_dkim_key!
end
context "when the pending record has been published" do
it "activates and verifies the pending key from a single DNS response" do
responses = [[domain.pending_dkim_record], []]
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}") { responses.shift }
pending_key = domain.pending_dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq pending_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_status).to eq "OK"
expect(domain.dkim_error).to be_nil
expect(responses).to eq([[]])
end
end
context "when multiple pending records have been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([domain.pending_dkim_record, domain.pending_dkim_record])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and retains the pending key" do
original_key = domain.dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
context "when the pending record has not been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and retains the pending key" do
original_key = domain.dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
context "when the pending record does not match" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return(["v=DKIM1; t=s; h=sha256; p=something;"])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and retains the pending key" do
original_key = domain.dkim_private_key
domain.check_dkim_record
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
end
context "when there is no pending DKIM key" do
context "when the record is published" do
before do
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "marks the record as OK" do
domain.check_dkim_record
expect(domain.dkim_status).to eq "OK"
expect(domain.dkim_error).to be_nil
end
end
context "when the record is missing" do
before do
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([])
end
it "marks the record as missing" do
domain.check_dkim_record
expect(domain.dkim_status).to eq "Missing"
end
end
end
end
describe "#return_path_domain" do
it "returns the return path domain" do
expect(domain.return_path_domain).to eq "#{Postal::Config.dns.custom_return_path_prefix}.#{domain.name}"
+144
View File
@@ -0,0 +1,144 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "DomainsController", type: :request do
let(:user) { create(:user, admin: true) }
let(:organization) { create(:organization, owner: user) }
let(:server) { create(:server, organization: organization) }
before do
post "/login", params: { email_address: user.email_address, password: "passw0rd" }
end
describe "POST /org/:org/servers/:server/domains/:domain/regenerate_dkim" do
context "when the domain's DKIM record is verified" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
it "generates a pending DKIM key without changing the active key" do
original_key = domain.dkim_private_key
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:notice]).to include("Keep the old DNS record published afterward")
expect(flash[:notice]).to include("queued, held, or available for redelivery")
expect(flash[:notice]).to_not include("you can remove the old record")
domain.reload
expect(domain.dkim_private_key).to eq original_key
expect(domain.pending_dkim_private_key).to_not be_nil
end
end
context "when the domain's DKIM record is not verified" do
let(:domain) { create(:domain, owner: server, dkim_status: "Missing") }
it "replaces the active key immediately" do
original_key = domain.dkim_private_key
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
domain.reload
expect(domain.dkim_private_key).to_not eq original_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_status).to be_nil
end
end
context "when a key change is already in progress" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "refuses and leaves the pending key untouched" do
existing_pending_key = domain.pending_dkim_private_key
existing_pending_identifier = domain.pending_dkim_identifier_string
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:alert]).to include("already in progress")
domain.reload
expect(domain.pending_dkim_private_key).to eq existing_pending_key
expect(domain.pending_dkim_identifier_string).to eq existing_pending_identifier
end
it "does not change the active key" do
expect do
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/regenerate_dkim"
end.to_not(change { domain.reload.dkim_private_key })
end
end
end
describe "POST /org/:org/domains/:domain/regenerate_dkim" do
let(:domain) { create(:domain, owner: organization, dkim_status: "OK") }
it "generates a pending DKIM key for an organization-owned domain" do
post "/org/#{organization.permalink}/domains/#{domain.uuid}/regenerate_dkim"
expect(response).to redirect_to(setup_organization_domain_path(organization, domain))
expect(domain.reload.pending_dkim_private_key).to_not be_nil
end
end
describe "POST /org/:org/servers/:server/domains/:domain/check with a pending DKIM key" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
let(:resolver) { instance_double(DNSResolver) }
before do
domain.regenerate_dkim_key!
allow(DNSResolver).to receive(:for_domain).and_return(resolver)
allow(resolver).to receive(:txt).with(domain.name).and_return([domain.spf_record])
allow(resolver).to receive(:mx).with(domain.name).and_return([])
allow(resolver).to receive(:cname).and_return([])
end
context "when the pending DKIM record has been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([domain.pending_dkim_record])
end
it "activates the pending key and tells the user to retain the old record" do
old_record_name = domain.dkim_record_name
new_key = domain.pending_dkim_private_key
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/check"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:notice]).to include("new DKIM key is now active")
expect(flash[:notice]).to include(old_record_name)
expect(flash[:notice]).to include("queued, held, or available for redelivery")
expect(flash[:notice]).to_not include("can remove")
domain.reload
expect(domain.dkim_private_key).to eq new_key
expect(domain.pending_dkim_private_key).to be_nil
expect(domain.dkim_status).to eq "OK"
end
end
context "when the pending DKIM record has not been published" do
before do
allow(resolver).to receive(:txt).with("#{domain.pending_dkim_record_name}.#{domain.name}").and_return([])
allow(resolver).to receive(:txt).with("#{domain.dkim_record_name}.#{domain.name}").and_return([domain.dkim_record])
end
it "keeps the current key active and tells the user the new record wasn't verified" do
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/check"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(flash[:alert]).to include("couldn't verify the record for your new DKIM key")
domain.reload
expect(domain.pending_dkim_private_key).to_not be_nil
expect(domain.dkim_status).to eq "OK"
end
end
end
describe "POST /org/:org/servers/:server/domains/:domain/cancel_dkim_regeneration" do
let(:domain) { create(:domain, owner: server, dkim_status: "OK") }
before do
domain.regenerate_dkim_key!
end
it "discards the pending DKIM key" do
post "/org/#{organization.permalink}/servers/#{server.permalink}/domains/#{domain.uuid}/cancel_dkim_regeneration"
expect(response).to redirect_to(setup_organization_server_domain_path(organization, server, domain))
expect(domain.reload.pending_dkim_private_key).to be_nil
end
end
end
@@ -0,0 +1,48 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "domains/index", type: :view do
let(:user) { create(:user, admin: true) }
let(:organization) { create(:organization, owner: user) }
let(:domain) { create(:domain, owner: organization, dkim_status: "OK") }
before do
assign(:organization, organization)
assign(:domains, [domain])
assign(:server, nil)
without_partial_double_verification do
allow(view).to receive(:organization).and_return(organization)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:page_title).and_return(["Postal"])
end
end
context "when no key change is in progress" do
it "does not flag a pending DKIM key" do
render
expect(rendered).to_not include("New DKIM key pending")
end
end
context "when a key change is in progress" do
before do
domain.regenerate_dkim_key!
end
it "flags the domain as having a pending DKIM key" do
render
expect(rendered).to include("New DKIM key pending")
end
it "links the flag to the DNS setup page" do
render
expect(rendered).to include(setup_organization_domain_path(organization, domain))
end
it "still shows DKIM as OK because the active key is unaffected" do
render
expect(rendered).to include("domainList__check--ok")
end
end
end
@@ -0,0 +1,73 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "domains/setup", type: :view do
let(:user) { create(:user, admin: true) }
let(:organization) { create(:organization, owner: user) }
let(:domain) { create(:domain, owner: organization, dkim_status: "OK") }
before do
assign(:organization, organization)
assign(:domain, domain)
assign(:server, nil)
without_partial_double_verification do
allow(view).to receive(:organization).and_return(organization)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:page_title).and_return(["Postal"])
end
end
context "when no key change is in progress" do
it "shows the active record and offers regeneration" do
render
expect(rendered).to include(domain.dkim_record_name)
expect(rendered).to include(domain.dkim_record)
expect(rendered).to include("Regenerate DKIM key")
expect(rendered).to_not include("Key change in progress")
expect(rendered).to_not include("Cancel new DKIM key")
end
end
context "when a key change is in progress" do
before do
domain.regenerate_dkim_key!
end
it "shows both the current and the new record" do
render
expect(rendered).to include(domain.dkim_record_name)
expect(rendered).to include(domain.dkim_record)
expect(rendered).to include(domain.pending_dkim_record_name)
expect(rendered).to include(domain.pending_dkim_record)
end
it "labels which record is active and which must be added" do
render
expect(rendered).to include("Key change in progress")
expect(rendered).to include("Keep this record exactly as it is")
expect(rendered).to include("Add this record as well")
end
it "tells the user to add the new record rather than replace the old one" do
render
expect(rendered).to include("alongside the record above rather than replacing it")
end
it "does not describe the active record as one which needs adding" do
render
expect(rendered).to_not include("You need to add a new TXT record")
end
it "offers cancellation instead of another regeneration" do
render
expect(rendered).to include("Cancel new DKIM key")
expect(rendered).to_not include("Regenerate DKIM key")
end
it "does not claim the DKIM record is fully good" do
render
expect(rendered).to_not include("Your DKIM record looks good!")
end
end
end