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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## [unreleased]

### New features

- `acmesmith order` and `acmesmith new-account` gain `--ensure` option for idempotency; when an account key or a certificate already exists, `acmesmith` exits with doing nothing. [#86](https://github.com/sorah/acmesmith/pull/86)

## v2.9.0 (2026-03-14)

### Enhancements
Expand Down
28 changes: 24 additions & 4 deletions lib/acmesmith/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'acmesmith/authorization_service'
require 'acmesmith/ordering_service'
require 'acmesmith/save_certificate_service'
require 'acmesmith/storages/base'
require 'acme-client'

module Acmesmith
Expand All @@ -11,7 +12,13 @@ def initialize(config: nil)
@config ||= config
end

def new_account(contact, tos_agreed: true)
def new_account(contact, tos_agreed: true, ensure_existence: false)
if ensure_existence && storage.account_key_exist?
puts "=> Account key already exists; skipping (--ensure)"
return account_key
end

puts "=> Creating an account ..."
key = AccountKey.generate
acme = Acme::Client.new(private_key: key.private_key, directory: config.directory, connection_options: config.connection_options, bad_nonce_retry: config.bad_nonce_retry)
acme.new_account(contact: contact, terms_of_service_agreed: tos_agreed)
Expand All @@ -21,9 +28,9 @@ def new_account(contact, tos_agreed: true)
key
end

def order(*identifiers, key_type: 'rsa', rsa_key_size: 2048, elliptic_curve: 'prime256v1', not_before: nil, not_after: nil)
def order(*identifiers, key_type: 'rsa', rsa_key_size: 2048, elliptic_curve: 'prime256v1', not_before: nil, not_after: nil, ensure_existence: false)
private_key = generate_private_key(key_type: key_type, rsa_key_size: rsa_key_size, elliptic_curve: elliptic_curve)
order_with_private_key(*identifiers, private_key: private_key, not_before: not_before, not_after: not_after)
order_with_private_key(*identifiers, private_key: private_key, not_before: not_before, not_after: not_after, ensure_existence: ensure_existence)
end

def authorize(*identifiers)
Expand Down Expand Up @@ -218,7 +225,7 @@ def account_key_passphrase
end
end

def order_with_private_key(name, *identifiers, private_key:, not_before: nil, not_after: nil)
def order_with_private_key(name, *identifiers, private_key:, not_before: nil, not_after: nil, ensure_existence: false)
order = OrderingService.new(
acme: acme,
common_name: name,
Expand All @@ -230,6 +237,19 @@ def order_with_private_key(name, *identifiers, private_key:, not_before: nil, no
not_before: not_before,
not_after: not_after
)

if ensure_existence
existing = begin
storage.get_certificate(name, version: 'current')
rescue Storages::Base::NotExist
nil
end
if existing && order.covers?(existing)
puts "=> Current certificate for #{name} covers all requested identifiers and is not expired; skipping (--ensure)"
return existing
end
end

order.perform!
cert = order.certificate

Expand Down
6 changes: 4 additions & 2 deletions lib/acmesmith/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ class Command < Thor
class_option :passphrase_from_env, type: :boolean, aliases: %w(-E), default: nil, desc: 'Read $ACMESMITH_ACCOUNT_KEY_PASSPHRASE and $ACMESMITH_CERTIFICATE_KEY_PASSPHRASE for passphrases'

desc "new-account CONTACT", "Create account key (contact e.g. mailto:xxx@example.org)"
method_option :ensure, type: :boolean, default: false, desc: 'Exit normally if account key already exists (for idempotency)'
def new_account(contact)
puts "=> Creating an account ..."
key = client.new_account(contact)
key = client.new_account(contact, ensure_existence: options[:ensure])
puts "=> Public Key:"
puts "\n#{key.private_key.public_key.to_pem}"
end
Expand All @@ -35,12 +35,14 @@ def authorize(*domains)
method_option :key_type, type: :string, enum: %w(rsa ec), default: 'rsa', desc: 'key type'
method_option :rsa_key_size, type: :numeric, default: 2048, desc: 'size of RSA key'
method_option :elliptic_curve, type: :string, default: 'prime256v1', desc: 'elliptic curve group for EC key'
method_option :ensure, type: :boolean, default: false, desc: 'Skip issuance if a current certificate already covers all identifiers and has not expired (for idempotency)'
def order(name, *sans)
cert = client.order(
name, *sans,
key_type: options[:key_type],
rsa_key_size: options[:rsa_key_size],
elliptic_curve: options[:elliptic_curve],
ensure_existence: options[:ensure],
)
if options[:show_certificate]
puts cert.certificate.to_text
Expand Down
10 changes: 10 additions & 0 deletions lib/acmesmith/ordering_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ def initialize(acme:, common_name:, identifiers:, private_key:, challenge_respon

attr_reader :acme, :common_name, :identifiers, :private_key, :challenge_responder_rules, :chain_preferences, :profile_rules, :not_before, :not_after

# @param certificate [Acmesmith::Certificate]
# @return [Boolean] true iff the certificate is unexpired and already contains every identifier this order would request
def covers?(certificate)
return false if certificate.certificate.not_after.utc <= Time.now.utc

existing = (certificate.sans + certificate.ip_sans).map { |s| acme_identifier(s) }
requested = identifiers.map { |id| acme_identifier(id) }
(requested - existing).empty?
end

def perform!
puts "=> Ordering a certificate for the following identifiers:"
puts
Expand Down
8 changes: 8 additions & 0 deletions lib/acmesmith/storages/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ def put_account_key(key, passphrase = nil)
raise NotImplementedError
end

# @return [Boolean]
def account_key_exist?
get_account_key
true
rescue NotExist
false
end

# @param cert [Acmesmith::Certificate]
# @param passphrase [String, nil]
# @param update_current [true, false]
Expand Down