diff --git a/Gemfile b/Gemfile
index fe749cb..3cf7942 100644
--- a/Gemfile
+++ b/Gemfile
@@ -44,6 +44,8 @@ gem 'pagy', '~> 6.0', '>= 6.0.2'
gem 'flipper'
gem 'flipper-active_record'
gem 'flipper-ui'
+gem 'gpgme', '~> 2.0.24'
+gem 'zbase32', '~> 0.1.1'
# HTTP requests
gem 'faraday'
diff --git a/Gemfile.lock b/Gemfile.lock
index 59cb7d8..ffcd15b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -197,6 +197,8 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
+ gpgme (2.0.24)
+ mini_portile2 (~> 2.7)
hashdiff (1.1.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
@@ -483,6 +485,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yard (0.9.34)
+ zbase32 (0.1.1)
zeitwerk (2.6.12)
PLATFORMS
@@ -507,6 +510,7 @@ DEPENDENCIES
flipper
flipper-active_record
flipper-ui
+ gpgme (~> 2.0.24)
image_processing (~> 1.12.2)
importmap-rails
jbuilder (~> 2.7)
@@ -540,6 +544,7 @@ DEPENDENCIES
warden
web-console (~> 4.2)
webmock
+ zbase32 (~> 0.1.1)
BUNDLED WITH
2.5.5
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 6b6f510..82b91da 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -30,7 +30,7 @@ class Admin::UsersController < Admin::BaseController
amount = params[:amount].to_i
notify_user = ActiveRecord::Type::Boolean.new.cast(params[:notify_user])
- CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
+ UserManager::CreateInvitations.call(user: @user, amount: amount, notify: notify_user)
redirect_to admin_user_path(@user.cn), flash: {
success: "Added #{amount} invitations to #{@user.cn}'s account"
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index a21c542..e469d66 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -21,10 +21,12 @@ class SettingsController < ApplicationController
end
end
+ # PUT /settings/:section
def update
@user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name]
- @user.avatar_new = user_params[:avatar]
+ @user.avatar_new = user_params[:avatar]
+ @user.pgp_pubkey = user_params[:pgp_pubkey]
if @user.save
if @user.display_name && (@user.display_name != @user.ldap_entry[:display_name])
@@ -35,6 +37,10 @@ class SettingsController < ApplicationController
LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new)
end
+ if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
+ UserManager::UpdatePgpKey.call(user: @user)
+ end
+
redirect_to setting_path(@settings_section), flash: {
success: 'Settings saved.'
}
@@ -44,6 +50,7 @@ class SettingsController < ApplicationController
end
end
+ # POST /settings/update_email
def update_email
if @user.valid_ldap_authentication?(security_params[:current_password])
if @user.update email: email_params[:email]
@@ -61,6 +68,7 @@ class SettingsController < ApplicationController
end
end
+ # POST /settings/reset_email_password
def reset_email_password
@user.current_password = security_params[:current_password]
@@ -83,6 +91,7 @@ class SettingsController < ApplicationController
end
end
+ # POST /settings/reset_password
def reset_password
current_user.send_reset_password_instructions
sign_out current_user
@@ -90,6 +99,7 @@ class SettingsController < ApplicationController
redirect_to check_your_email_path, notice: msg
end
+ # POST /settings/set_nostr_pubkey
def set_nostr_pubkey
signed_event = Nostr::Event.new(**nostr_event_from_params)
@@ -152,7 +162,8 @@ class SettingsController < ApplicationController
def user_params
params.require(:user).permit(
- :display_name, :avatar, preferences: UserPreferences.pref_keys
+ :display_name, :avatar, :pgp_pubkey,
+ preferences: UserPreferences.pref_keys
)
end
diff --git a/app/controllers/signup_controller.rb b/app/controllers/signup_controller.rb
index 236db54..631551d 100644
--- a/app/controllers/signup_controller.rb
+++ b/app/controllers/signup_controller.rb
@@ -96,7 +96,7 @@ class SignupController < ApplicationController
session[:new_user] = nil
session[:validation_error] = nil
- CreateAccount.call(account: {
+ UserManager::CreateAccount.call(account: {
username: @user.cn,
domain: Setting.primary_domain,
email: @user.email,
diff --git a/app/controllers/web_key_directory_controller.rb b/app/controllers/web_key_directory_controller.rb
new file mode 100644
index 0000000..593a62f
--- /dev/null
+++ b/app/controllers/web_key_directory_controller.rb
@@ -0,0 +1,35 @@
+class WebKeyDirectoryController < WellKnownController
+ before_action :allow_cross_origin_requests
+
+ # /.well-known/openpgpkey/hu/:hashed_username(.txt)
+ def show
+ @user = User.find_by(cn: params[:l].downcase)
+
+ if @user.nil? ||
+ @user.pgp_pubkey.empty? ||
+ !@user.pgp_pubkey_contains_user_address?
+ http_status :not_found and return
+ end
+
+ if params[:hashed_username] != @user.wkd_hash
+ http_status :unprocessable_entity and return
+ end
+
+ respond_to do |format|
+ format.text do
+ response.headers['Content-Type'] = 'text/plain'
+ render plain: @user.pgp_pubkey
+ end
+
+ format.any do
+ key = @user.gnupg_key.export
+ send_data key, filename: "#{@user.wkd_hash}.pem",
+ type: "application/octet-stream"
+ end
+ end
+ end
+
+ def policy
+ head :ok
+ end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index d8bd387..a9ccecf 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,3 +1,90 @@
class ApplicationMailer < ActionMailer::Base
+ default Rails.application.config.action_mailer.default_options
layout 'mailer'
+
+ private
+
+ def send_mail
+ @template ||= "#{self.class.name.underscore}/#{caller[0][/`([^']*)'/, 1]}"
+ headers['Message-ID'] = message_id
+
+ if @user.pgp_pubkey.present?
+ mail(to: @user.email, subject: "...", content_type: pgp_content_type) do |format|
+ format.text { render plain: pgp_content }
+ end
+ else
+ mail(to: @user.email, subject: @subject) do |format|
+ format.text { render @template }
+ end
+ end
+ end
+
+ def from_address
+ self.class.default[:from]
+ end
+
+ def from_domain
+ Mail::Address.new(from_address).domain
+ end
+
+ def message_id
+ @message_id ||= "#{SecureRandom.uuid}@#{from_domain}"
+ end
+
+ def boundary
+ @boundary ||= SecureRandom.hex(8)
+ end
+
+ def pgp_content_type
+ "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"------------#{boundary}\""
+ end
+
+ def pgp_nested_content
+ message_content = render_to_string(template: @template)
+ message_content_base64 = Base64.encode64(message_content)
+ nested_boundary = SecureRandom.hex(8)
+
+ <<~NESTED_CONTENT
+ Content-Type: multipart/mixed; boundary="------------#{nested_boundary}"; protected-headers="v1"
+ Subject: #{@subject}
+ From: <#{from_address}>
+ To: #{@user.display_name || @user.cn} <#{@user.email}>
+ Message-ID: <#{message_id}>
+
+ --------------#{nested_boundary}
+ Content-Type: text/plain; charset=UTF-8; format=flowed
+ Content-Transfer-Encoding: base64
+
+ #{message_content_base64}
+
+ --------------#{nested_boundary}--
+ NESTED_CONTENT
+ end
+
+ def pgp_content
+ encrypted_content = UserManager::PgpEncrypt.call(user: @user, text: pgp_nested_content)
+ encrypted_base64 = Base64.encode64(encrypted_content.to_s)
+
+ <<~EMAIL_CONTENT
+ This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)
+ --------------#{boundary}
+ Content-Type: application/pgp-encrypted
+ Content-Description: PGP/MIME version identification
+
+ Version: 1
+
+ --------------#{boundary}
+ Content-Type: application/octet-stream; name="encrypted.asc"
+ Content-Description: OpenPGP encrypted message
+ Content-Disposition: inline; filename="encrypted.asc"
+
+ -----BEGIN PGP MESSAGE-----
+
+ #{encrypted_base64}
+
+ -----END PGP MESSAGE-----
+
+ --------------#{boundary}--
+ EMAIL_CONTENT
+ end
end
diff --git a/app/mailers/custom_mailer.rb b/app/mailers/custom_mailer.rb
index 325a620..fd35185 100644
--- a/app/mailers/custom_mailer.rb
+++ b/app/mailers/custom_mailer.rb
@@ -18,6 +18,6 @@ class CustomMailer < ApplicationMailer
@user = params[:user]
@subject = params[:subject]
@body = params[:body]
- mail(to: @user.email, subject: @subject)
+ send_mail
end
end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index b4ec37d..d281380 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -3,7 +3,7 @@ class NotificationMailer < ApplicationMailer
@user = params[:user]
@amount_sats = params[:amount_sats]
@subject = "Sats received"
- mail to: @user.email, subject: @subject
+ send_mail
end
def remotestorage_auth_created
@@ -15,19 +15,19 @@ class NotificationMailer < ApplicationMailer
"#{access} #{directory}"
end
@subject = "New app connected to your storage"
- mail to: @user.email, subject: @subject
+ send_mail
end
def new_invitations_available
@user = params[:user]
@subject = "New invitations added to your account"
- mail to: @user.email, subject: @subject
+ send_mail
end
def bitcoin_donation_confirmed
@user = params[:user]
@donation = params[:donation]
@subject = "Donation confirmed"
- mail to: @user.email, subject: @subject
+ send_mail
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 248a865..284f350 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,9 +3,10 @@ require 'nostr'
class User < ApplicationRecord
include EmailValidatable
- attr_accessor :display_name
- attr_accessor :avatar_new
attr_accessor :current_password
+ attr_accessor :avatar_new
+ attr_accessor :display_name
+ attr_accessor :pgp_pubkey
serialize :preferences, coder: UserPreferences
@@ -51,6 +52,8 @@ class User < ApplicationRecord
validate :acceptable_avatar
+ validate :acceptable_pgp_key_format, if: -> { defined?(@pgp_pubkey) && @pgp_pubkey.present? }
+
#
# Scopes
#
@@ -165,6 +168,24 @@ class User < ApplicationRecord
Nostr::PublicKey.new(nostr_pubkey).to_bech32
end
+ def pgp_pubkey
+ @pgp_pubkey ||= ldap_entry[:pgp_key]
+ end
+
+ def gnupg_key
+ return nil unless pgp_pubkey.present?
+ GPGME::Key.import(pgp_pubkey)
+ GPGME::Key.get(pgp_fpr)
+ end
+
+ def pgp_pubkey_contains_user_address?
+ gnupg_key.uids.map(&:email).include?(address)
+ end
+
+ def wkd_hash
+ ZBase32.encode(Digest::SHA1.digest(cn))
+ end
+
def avatar
@avatar_base64 ||= LdapManager::FetchAvatar.call(cn: cn)
end
@@ -214,4 +235,10 @@ class User < ApplicationRecord
errors.add(:avatar, "must be a JPEG or PNG file")
end
end
+
+ def acceptable_pgp_key_format
+ unless GPGME::Key.valid?(pgp_pubkey)
+ errors.add(:pgp_pubkey, 'is not a valid armored PGP public key block')
+ end
+ end
end
diff --git a/app/services/create_account.rb b/app/services/create_account.rb
deleted file mode 100644
index 12bf07b..0000000
--- a/app/services/create_account.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-class CreateAccount < ApplicationService
- def initialize(account:)
- @username = account[:username]
- @domain = account[:ou] || Setting.primary_domain
- @email = account[:email]
- @password = account[:password]
- @invitation = account[:invitation]
- @confirmed = account[:confirmed]
- end
-
- def call
- user = create_user_in_database
- add_ldap_document
- create_lndhub_account(user) if Setting.lndhub_enabled
-
- if @invitation.present?
- update_invitation(user.id)
- end
- end
-
- private
-
- def create_user_in_database
- User.create!(
- cn: @username,
- ou: @domain,
- email: @email,
- password: @password,
- password_confirmation: @password,
- confirmed_at: @confirmed ? DateTime.now : nil
- )
- end
-
- def update_invitation(user_id)
- @invitation.update! invited_user_id: user_id, used_at: DateTime.now
- end
-
- def add_ldap_document
- hashed_pw = Devise.ldap_auth_password_builder.call(@password)
- CreateLdapUserJob.perform_later(
- username: @username,
- domain: @domain,
- email: @email,
- hashed_pw: hashed_pw,
- confirmed: @confirmed
- )
- end
-
- def create_lndhub_account(user)
- #TODO enable in development when we have a local lndhub (mock?) API
- return if Rails.env.development?
- CreateLndhubAccountJob.perform_later(user)
- end
-end
diff --git a/app/services/create_invitations.rb b/app/services/create_invitations.rb
deleted file mode 100644
index 3003b1a..0000000
--- a/app/services/create_invitations.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-class CreateInvitations < ApplicationService
- def initialize(user:, amount:, notify: true)
- @user = user
- @amount = amount
- @notify = notify
- end
-
- def call
- @amount.times do
- Invitation.create(user: @user)
- end
-
- if @notify
- NotificationMailer.with(user: @user).new_invitations_available.deliver_later
- end
- end
-end
diff --git a/app/services/ldap_manager/update_pgp_key.rb b/app/services/ldap_manager/update_pgp_key.rb
new file mode 100644
index 0000000..fa5b982
--- /dev/null
+++ b/app/services/ldap_manager/update_pgp_key.rb
@@ -0,0 +1,16 @@
+module LdapManager
+ class UpdatePgpKey < LdapManagerService
+ def initialize(dn:, pubkey:)
+ @dn = dn
+ @pubkey = pubkey
+ end
+
+ def call
+ if @pubkey.present?
+ replace_attribute @dn, :pgpKey, @pubkey
+ else
+ delete_attribute @dn, :pgpKey
+ end
+ end
+ end
+end
diff --git a/app/services/ldap_service.rb b/app/services/ldap_service.rb
index 8364eb5..316a58a 100644
--- a/app/services/ldap_service.rb
+++ b/app/services/ldap_service.rb
@@ -58,7 +58,7 @@ class LdapService < ApplicationService
attributes = %w[
dn cn uid mail displayName admin serviceEnabled
- mailRoutingAddress mailpassword nostrKey
+ mailRoutingAddress mailpassword nostrKey pgpKey
]
filter = Net::LDAP::Filter.eq("uid", args[:uid] || "*")
@@ -73,7 +73,8 @@ class LdapService < ApplicationService
services_enabled: e.try(:serviceEnabled),
email_maildrop: e.try(:mailRoutingAddress),
email_password: e.try(:mailpassword),
- nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil
+ nostr_key: e.try(:nostrKey) ? e.nostrKey.first : nil,
+ pgp_key: e.try(:pgpKey) ? e.pgpKey.first : nil
}
end
end
@@ -101,7 +102,7 @@ class LdapService < ApplicationService
dn = "ou=#{ou},cn=users,#{ldap_suffix}"
aci = <<-EOS
-(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
+(target="ldap:///cn=*,ou=#{ou},cn=users,#{ldap_suffix}")(targetattr="cn || sn || uid || userPassword || mail || mailRoutingAddress || serviceEnabled || nostrKey || pgpKey || nsRole || objectClass") (version 3.0; acl "service-#{ou.gsub(".", "-")}-read-search"; allow (read,search) userdn="ldap:///uid=service,ou=#{ou},cn=applications,#{ldap_suffix}";)
EOS
attrs = {
diff --git a/app/services/user_manager/create_account.rb b/app/services/user_manager/create_account.rb
new file mode 100644
index 0000000..ca65451
--- /dev/null
+++ b/app/services/user_manager/create_account.rb
@@ -0,0 +1,56 @@
+module UserManager
+ class CreateAccount < UserManagerService
+ def initialize(account:)
+ @username = account[:username]
+ @domain = account[:ou] || Setting.primary_domain
+ @email = account[:email]
+ @password = account[:password]
+ @invitation = account[:invitation]
+ @confirmed = account[:confirmed]
+ end
+
+ def call
+ user = create_user_in_database
+ add_ldap_document
+ create_lndhub_account(user) if Setting.lndhub_enabled
+
+ if @invitation.present?
+ update_invitation(user.id)
+ end
+ end
+
+ private
+
+ def create_user_in_database
+ User.create!(
+ cn: @username,
+ ou: @domain,
+ email: @email,
+ password: @password,
+ password_confirmation: @password,
+ confirmed_at: @confirmed ? DateTime.now : nil
+ )
+ end
+
+ def update_invitation(user_id)
+ @invitation.update! invited_user_id: user_id, used_at: DateTime.now
+ end
+
+ def add_ldap_document
+ hashed_pw = Devise.ldap_auth_password_builder.call(@password)
+ CreateLdapUserJob.perform_later(
+ username: @username,
+ domain: @domain,
+ email: @email,
+ hashed_pw: hashed_pw,
+ confirmed: @confirmed
+ )
+ end
+
+ def create_lndhub_account(user)
+ #TODO enable in development when we have a local lndhub (mock?) API
+ return if Rails.env.development?
+ CreateLndhubAccountJob.perform_later(user)
+ end
+ end
+end
diff --git a/app/services/user_manager/create_invitations.rb b/app/services/user_manager/create_invitations.rb
new file mode 100644
index 0000000..67d2fe8
--- /dev/null
+++ b/app/services/user_manager/create_invitations.rb
@@ -0,0 +1,19 @@
+module UserManager
+ class CreateInvitations < UserManagerService
+ def initialize(user:, amount:, notify: true)
+ @user = user
+ @amount = amount
+ @notify = notify
+ end
+
+ def call
+ @amount.times do
+ Invitation.create(user: @user)
+ end
+
+ if @notify
+ NotificationMailer.with(user: @user).new_invitations_available.deliver_later
+ end
+ end
+ end
+end
diff --git a/app/services/user_manager/pgp_encrypt.rb b/app/services/user_manager/pgp_encrypt.rb
new file mode 100644
index 0000000..afb24b3
--- /dev/null
+++ b/app/services/user_manager/pgp_encrypt.rb
@@ -0,0 +1,19 @@
+require 'gpgme'
+
+module UserManager
+ class PgpEncrypt < UserManagerService
+ def initialize(user:, text:)
+ @user = user
+ @text = text
+ end
+
+ def call
+ crypto = GPGME::Crypto.new
+ crypto.encrypt(
+ @text,
+ recipients: @user.gnupg_key,
+ always_trust: true
+ )
+ end
+ end
+end
diff --git a/app/services/user_manager/update_pgp_key.rb b/app/services/user_manager/update_pgp_key.rb
new file mode 100644
index 0000000..16b78c7
--- /dev/null
+++ b/app/services/user_manager/update_pgp_key.rb
@@ -0,0 +1,24 @@
+module UserManager
+ class UpdatePgpKey < UserManagerService
+ def initialize(user:)
+ @user = user
+ end
+
+ def call
+ if @user.pgp_pubkey.blank?
+ @user.update! pgp_fpr: nil
+ else
+ result = GPGME::Key.import(@user.pgp_pubkey)
+
+ if result.imports.present?
+ @user.update! pgp_fpr: result.imports.first.fpr
+ else
+ # TODO notify Sentry, user
+ raise "Failed to import OpenPGP pubkey"
+ end
+ end
+
+ LdapManager::UpdatePgpKey.call(dn: @user.dn, pubkey: @user.pgp_pubkey)
+ end
+ end
+end
diff --git a/app/services/user_manager_service.rb b/app/services/user_manager_service.rb
new file mode 100644
index 0000000..74f9d37
--- /dev/null
+++ b/app/services/user_manager_service.rb
@@ -0,0 +1,2 @@
+class UserManagerService < ApplicationService
+end
diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb
index 2cd8f67..0c86e94 100644
--- a/app/views/admin/users/show.html.erb
+++ b/app/views/admin/users/show.html.erb
@@ -89,13 +89,47 @@
- LDAP
-
-
LDAP
+
+
+
+
+ Avatar
+
+ <% if @avatar.present? %>
+
+
+ <% else %>
+ —
+ <% end %>
+
+
+ Display name
+ <%= @user.display_name || "—" %>
+
+
+
+ PGP key
+
+ <% if @user.pgp_pubkey.present? %>
+
+ <% if @user.pgp_pubkey_contains_user_address? %>
+ <%= link_to wkd_key_url(hashed_username: @user.wkd_hash, l: @user.cn, format: :txt),
+ class: "ks-text-link", target: "_blank" do %>
+ <%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %>
+ <% end %>
+ <% else %>
+ <%= "#{@user.pgp_fpr[0, 8]}…#{@user.pgp_fpr[-8..-1]}" %>
+ <% end %>
+
+
+ <% @user.gnupg_key.uids.each do |uid| %>
+ <%= uid.uid %>
+ <% end %>
+ <% else %>
+ —
+ <% end %>
+
<%= @validation_errors[:email].first %>
<% end %>Use the following button to request an email with a password reset link:
+Use the following button to request an email with a password reset link:
<%= form_with(url: reset_password_settings_path, method: :post) do %><%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>
<% end %>This <%= @validation_errors[:pgp_pubkey].first %>
+ <% end %> + <% end %> ++ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> +
++
Use the following button to generate a new email password:
diff --git a/config/environments/development.rb b/config/environments/development.rb index 9f73578..a5b46ac 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -57,16 +57,22 @@ Rails.application.configure do # routes, locales, etc. This feature depends on the listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker - config.action_mailer.default_options = { - from: "accounts@localhost" - } - # Don't actually send emails, cache them for viewing via letter opener config.action_mailer.delivery_method = :letter_opener + # Don't care if the mailer can't send config.action_mailer.raise_delivery_errors = true + # Base URL to be used by email template link helpers - config.action_mailer.default_url_options = { host: "localhost:3000", protocol: "http" } + config.action_mailer.default_url_options = { + host: "localhost:3000", + protocol: "http" + } + + config.action_mailer.default_options = { + from: "accounts@localhost", + message_id: -> { "<#{Mail.random_tag}@localhost>" }, + } # Allow requests from any IP config.web_console.permissions = '0.0.0.0/0' diff --git a/config/environments/production.rb b/config/environments/production.rb index 16e7fa5..da0fa6f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -63,7 +63,7 @@ Rails.application.configure do outgoing_email_domain = Mail::Address.new(outgoing_email_address).domain config.action_mailer.default_url_options = { - host: ENV['AKKOUNTS_DOMAIN'], + host: ENV.fetch('AKKOUNTS_DOMAIN'), protocol: "https", } diff --git a/config/environments/test.rb b/config/environments/test.rb index 65473dd..0c474bf 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -46,8 +46,12 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: "accounts.kosmos.org", - protocol: "https", - from: "accounts@kosmos.org" + protocol: "https" + } + + config.action_mailer.default_options = { + from: "accounts@kosmos.org", + message_id: -> { "<#{Mail.random_tag}@kosmos.org>" }, } config.active_job.queue_adapter = :test diff --git a/config/routes.rb b/config/routes.rb index 07e1ffe..b9c771e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -70,10 +70,12 @@ Rails.application.routes.draw do get '.well-known/webfinger', to: 'webfinger#show' get '.well-known/nostr', to: 'well_known#nostr' - get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: 'lightning_address' - get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: 'lightning_address_keysend' + get '.well-known/lnurlp/:username', to: 'lnurlpay#index', as: :lightning_address + get '.well-known/keysend/:username', to: 'lnurlpay#keysend', as: :lightning_address_keysend + get '.well-known/openpgpkey/hu/:hashed_username(.:format)', to: 'web_key_directory#show', as: :wkd_key + get '.well-known/openpgpkey/policy', to: 'web_key_directory#policy' - get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: 'lnurlpay_invoice' + get 'lnurlpay/:username/invoice', to: 'lnurlpay#invoice', as: :lnurlpay_invoice post 'webhooks/lndhub', to: 'webhooks#lndhub' diff --git a/db/migrate/20240922205634_add_pgp_fpr_to_users.rb b/db/migrate/20240922205634_add_pgp_fpr_to_users.rb new file mode 100644 index 0000000..d3b1572 --- /dev/null +++ b/db/migrate/20240922205634_add_pgp_fpr_to_users.rb @@ -0,0 +1,5 @@ +class AddPgpFprToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :pgp_fpr, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 90a60e6..31e617c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do +ActiveRecord::Schema[7.1].define(version: 2024_09_22_205634) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -132,6 +132,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_07_123654) do t.datetime "remember_created_at" t.string "remember_token" t.text "preferences" + t.string "pgp_fpr" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/db/seeds/admin.asc b/db/seeds/admin.asc new file mode 100644 index 0000000..d3b65a1 --- /dev/null +++ b/db/seeds/admin.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZvGiUxYJKwYBBAHaRw8BAQdARPZXLqyB3nylJuzuARlOJxqc9mchMKHI4Cy+ +hPWlzja0GEFkbWluIDxhZG1pbkBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEE0pie1+fG +ImdZwzGnwgEYSg8AulYFAmbxolMCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQwgEYSg8AulaldAEA7yzh7XRCdIJDHgLUvKHsy2NnyLaDD1Tl +hyZWbl5og0IBAJAQ2Dm82YXMdUK3X1OGlK8KH5O4E5lSFY4+8/xx0UEJuDgEZvGi +UxIKKwYBBAGXVQEFAQEHQJc8pzzeIF7Hm5z1eseRAqGvFa+V1BIDf+1XQzuJhhxi +AwEIB4h+BBgWCgAmFiEE0pie1+fGImdZwzGnwgEYSg8AulYFAmbxolMCGwwFCQWj +moAACgkQwgEYSg8AulbLtgEApZvuDqSP77lrl1jmtCAJEEZk/ofsRFkf1g3U3Zhm +9PcA/1+AbcyqjLTcqIPjHmZyGEPiaAvEsBzbPKEPiL3JYhkG +=45sx +-----END PGP PUBLIC KEY BLOCK----- diff --git a/lib/tasks/ldap.rake b/lib/tasks/ldap.rake index 3beb0e9..244a7d2 100644 --- a/lib/tasks/ldap.rake +++ b/lib/tasks/ldap.rake @@ -21,7 +21,7 @@ namespace :ldap do desc "Add custom attributes to schema" task add_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key ].each do |name| + %w[ admin service_enabled nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "add") Rake::Task['ldap:modify_ldap_schema'].reenable end @@ -29,7 +29,7 @@ namespace :ldap do desc "Delete custom attributes from schema" task delete_custom_attributes: :environment do |t, args| - %w[ admin service_enabled nostr_key ].each do |name| + %w[ admin service_enabled nostr_key pgp_key ].each do |name| Rake::Task["ldap:modify_ldap_schema"].invoke(name, "delete") Rake::Task['ldap:modify_ldap_schema'].reenable end diff --git a/schemas/ldap/pgp_key.ldif b/schemas/ldap/pgp_key.ldif new file mode 100644 index 0000000..e8ca2a6 --- /dev/null +++ b/schemas/ldap/pgp_key.ldif @@ -0,0 +1,8 @@ +dn: cn=schema +changetype: modify +add: attributeTypes +attributeTypes: ( 1.3.6.1.4.1.3401.8.2.11 + NAME 'pgpKey' + DESC 'OpenPGP public key block' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) diff --git a/spec/features/settings/account_spec.rb b/spec/features/settings/account_spec.rb index 51f1c83..47bb9ad 100644 --- a/spec/features/settings/account_spec.rb +++ b/spec/features/settings/account_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'Account settings', type: :feature do .with("invalid password").and_return(false) allow_any_instance_of(User).to receive(:valid_ldap_authentication?) .with("valid password").and_return(true) + allow_any_instance_of(User).to receive(:pgp_pubkey).and_return(nil) end scenario 'fails with invalid password' do @@ -55,4 +56,44 @@ RSpec.describe 'Account settings', type: :feature do end end end + + feature "Update OpenPGP key" do + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + + before do + login_as user, :scope => :user + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil + }) + end + + scenario 'rejects an invalid key' do + expect(UserManager::UpdatePgpKey).not_to receive(:call) + + visit setting_path(:account) + fill_in 'Public key', with: invalid_key + click_button "Save" + + expect(current_url).to eq(setting_url(:account)) + within ".error-msg" do + expect(page).to have_content("This is not a valid armored PGP public key block") + end + end + + scenario 'stores a valid key' do + expect(UserManager::UpdatePgpKey).to receive(:call) + .with(user: user).and_return(true) + + visit setting_path(:account) + fill_in 'Public key', with: valid_key_alice + click_button "Save" + + expect(current_url).to eq(setting_url(:account)) + within ".flash-msg" do + expect(page).to have_content("Settings saved") + end + end + end end diff --git a/spec/features/settings/profile_spec.rb b/spec/features/settings/profile_spec.rb index 1d5fa8c..55b861e 100644 --- a/spec/features/settings/profile_spec.rb +++ b/spec/features/settings/profile_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Profile settings', type: :feature do allow(user).to receive(:display_name).and_return("Mark") allow_any_instance_of(User).to receive(:dn).and_return("cn=mwahlberg,ou=kosmos.org,cn=users,dc=kosmos,dc=org") allow_any_instance_of(User).to receive(:ldap_entry).and_return({ - uid: user.cn, ou: user.ou, display_name: "Mark" + uid: user.cn, ou: user.ou, display_name: "Mark", pgp_key: nil }) allow_any_instance_of(User).to receive(:avatar).and_return(avatar_base64) diff --git a/spec/features/signup_spec.rb b/spec/features/signup_spec.rb index e668e9d..0691688 100644 --- a/spec/features/signup_spec.rb +++ b/spec/features/signup_spec.rb @@ -52,7 +52,7 @@ RSpec.describe "Signup", type: :feature do click_button "Continue" expect(page).to have_content("Choose a password") - expect(CreateAccount).to receive(:call) + expect(UserManager::CreateAccount).to receive(:call) .with(account: { username: "tony", domain: "kosmos.org", email: "tony@example.com", password: "a-valid-password", @@ -96,7 +96,7 @@ RSpec.describe "Signup", type: :feature do click_button "Create account" expect(page).to have_content("Password is too short") - expect(CreateAccount).to receive(:call) + expect(UserManager::CreateAccount).to receive(:call) .with(account: { username: "tony", domain: "kosmos.org", email: "tony@example.com", password: "a-valid-password", diff --git a/spec/fixtures/files/pgp_key_invalid.asc b/spec/fixtures/files/pgp_key_invalid.asc new file mode 100644 index 0000000..08bd918 --- /dev/null +++ b/spec/fixtures/files/pgp_key_invalid.asc @@ -0,0 +1,11 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE +ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy +MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO +dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4 +OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s +E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb +DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn +0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE= +=iIGO +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_alice.asc b/spec/fixtures/files/pgp_key_valid_alice.asc new file mode 100644 index 0000000..d509cb1 --- /dev/null +++ b/spec/fixtures/files/pgp_key_valid_alice.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: Alice's OpenPGP certificate +Comment: https://www.ietf.org/id/draft-bre-openpgp-samples-01.html + +mDMEXEcE6RYJKwYBBAHaRw8BAQdArjWwk3FAqyiFbFBKT4TzXcVBqPTB3gmzlC/U +b7O1u120JkFsaWNlIExvdmVsYWNlIDxhbGljZUBvcGVucGdwLmV4YW1wbGU+iJAE +ExYIADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQTrhbtfozp14V6UTmPy +MVUMT0fjjgUCXaWfOgAKCRDyMVUMT0fjjukrAPoDnHBSogOmsHOsd9qGsiZpgRnO +dypvbm+QtXZqth9rvwD9HcDC0tC+PHAsO7OTh1S1TC9RiJsvawAfCPaQZoed8gK4 +OARcRwTpEgorBgEEAZdVAQUBAQdAQv8GIa2rSTzgqbXCpDDYMiKRVitCsy203x3s +E9+eviIDAQgHiHgEGBYIACAWIQTrhbtfozp14V6UTmPyMVUMT0fjjgUCXEcE6QIb +DAAKCRDyMVUMT0fjjlnQAQDFHUs6TIcxrNTtEZFjUFm1M0PJ1Dng/cDW4xN80fsn +0QEA22Kr7VkCjeAEC08VSTeV+QFsmz55/lntWkwYWhmvOgE= +=iIGO + +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_jimmy.asc b/spec/fixtures/files/pgp_key_valid_jimmy.asc new file mode 100644 index 0000000..385caaf --- /dev/null +++ b/spec/fixtures/files/pgp_key_valid_jimmy.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZvFjRhYJKwYBBAHaRw8BAQdACUxVX9bGlbuNR0MNYUyHHxTcOgm4qjwq8Bjg +7P41OFK0GEppbW15IDxqaW1teUBrb3Ntb3Mub3JnPoiZBBMWCgBBFiEEMWv1FiNt +r3cjaxX2BX2Tly+4YsMFAmbxY0YCGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQBX2Tly+4YsMjHgEAoOOLrv9pWbi8hhrSMkqJ7FJvsBTQF//U +aJUQRa8CTgoBAI3kyGKZ8gOC8UOOKsUC0LiNCVXPyX45h8T4QFRdEVYKuDgEZvFj +RhIKKwYBBAGXVQEFAQEHQIomqcQ59UjtQex54pz8qGqyxCj2DPJYUat9pXinDgN8 +AwEIB4h+BBgWCgAmFiEEMWv1FiNtr3cjaxX2BX2Tly+4YsMFAmbxY0YCGwwFCQWj +moAACgkQBX2Tly+4YsPoVgEA/9Q5Gs1klP4u/nw343V57e9s4RKmEiRSkErnC9wW +Iu0A/jp6Elz2pDQPB2XLwcb+n7JlgA05HI0zWj1+EoM7TC4J +=KQbn +-----END PGP PUBLIC KEY BLOCK----- diff --git a/spec/fixtures/files/pgp_key_valid_jimmy.pem b/spec/fixtures/files/pgp_key_valid_jimmy.pem new file mode 100644 index 0000000..44c792b Binary files /dev/null and b/spec/fixtures/files/pgp_key_valid_jimmy.pem differ diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb new file mode 100644 index 0000000..a2b3099 --- /dev/null +++ b/spec/mailers/notification_mailer_spec.rb @@ -0,0 +1,87 @@ +# spec/mailers/welcome_mailer_spec.rb +require 'rails_helper' + +RSpec.describe NotificationMailer, type: :mailer do + describe '#lightning_sats_received' do + + context "without PGP key" do + let(:user) { create(:user, cn: "phil", email: 'phil@example.com') } + + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: nil + }) + end + + describe "unencrypted email" do + let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received } + + it 'renders the correct to/from headers' do + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(['accounts@kosmos.org']) + end + + it 'renders the correct subject' do + expect(mail.subject).to eq('Sats received') + end + + it 'uses the correct content type' do + expect(mail.header['content-type'].to_s).to include('text/plain') + end + + it 'renders the body with correct content' do + expect(mail.body.encoded).to match(/You just received 21,000 sats/) + expect(mail.body.encoded).to include(user.address) + end + + it 'includes a link to the lightning service page' do + expect(mail.body.encoded).to include("https://accounts.kosmos.org/services/lightning") + end + end + end + + context "with PGP key" do + let(:pgp_pubkey) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:pgp_fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:user) { create(:user, id: 2, cn: "alice", email: 'alice@example.com', pgp_fpr: pgp_fingerprint) } + + before do + allow_any_instance_of(User).to receive(:ldap_entry).and_return({ + uid: user.cn, ou: user.ou, display_name: nil, pgp_key: pgp_pubkey + }) + end + + describe "encrypted email" do + let(:mail) { described_class.with(user: user, amount_sats: 21000).lightning_sats_received } + + it 'renders the correct to/from headers' do + expect(mail.to).to eq([user.email]) + expect(mail.from).to eq(['accounts@kosmos.org']) + end + + it 'encrypts the subject line' do + expect(mail.subject).to eq('...') + end + + it 'uses the correct content type' do + expect(mail.header['content-type'].to_s).to include('multipart/encrypted') + expect(mail.header['content-type'].to_s).to include('protocol="application/pgp-encrypted"') + end + + it 'renders the PGP version part' do + expect(mail.body.encoded).to include("Content-Type: application/pgp-encrypted") + expect(mail.body.encoded).to include("Content-Description: PGP/MIME version identification") + expect(mail.body.encoded).to include("Version: 1") + end + + it 'renders the encrypted PGP part' do + expect(mail.body.encoded).to include('Content-Type: application/octet-stream; name="encrypted.asc"') + expect(mail.body.encoded).to include('Content-Description: OpenPGP encrypted message') + expect(mail.body.encoded).to include('Content-Disposition: inline; filename="encrypted.asc"') + expect(mail.body.encoded).to include('-----BEGIN PGP MESSAGE-----') + expect(mail.body.encoded).to include('hF4DR') + end + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5f6c7c1..a4e0f57 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,20 +1,16 @@ require 'rails_helper' RSpec.describe User, type: :model do - let(:user) { create :user, cn: "philipp" } + let(:user) { create :user, cn: "philipp", ou: "kosmos.org", email: "philipp@example.com" } let(:dn) { "cn=philipp,ou=kosmos.org,cn=users,dc=kosmos,dc=org" } describe "#address" do - let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" } - it "returns the user address" do - expect(user.address).to eq("jimmy@kosmos.org") + expect(user.address).to eq("philipp@kosmos.org") end end describe "#mastodon_address" do - let(:user) { build :user, cn: "jimmy", ou: "kosmos.org" } - context "Mastodon service not configured" do before do Setting.mastodon_enabled = false @@ -32,7 +28,7 @@ RSpec.describe User, type: :model do describe "domain is the same as primary domain" do it "returns the user address" do - expect(user.mastodon_address).to eq("jimmy@kosmos.org") + expect(user.mastodon_address).to eq("philipp@kosmos.org") end end @@ -42,7 +38,7 @@ RSpec.describe User, type: :model do end it "returns the user address" do - expect(user.mastodon_address).to eq("jimmy@kosmos.social") + expect(user.mastodon_address).to eq("philipp@kosmos.social") end end @@ -239,7 +235,7 @@ RSpec.describe User, type: :model do describe "#nostr_pubkey" do before do - allow_any_instance_of(User).to receive(:ldap_entry) + allow(user).to receive(:ldap_entry) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) end @@ -250,7 +246,7 @@ RSpec.describe User, type: :model do describe "#nostr_pubkey_bech32" do before do - allow_any_instance_of(User).to receive(:ldap_entry) + allow(user).to receive(:ldap_entry) .and_return({ nostr_key: "07e188a1ff87ce171d517b8ed2bb7a31b1d3453a0db3b15379ec07b724d232f3" }) end @@ -258,4 +254,73 @@ RSpec.describe User, type: :model do expect(user.nostr_pubkey_bech32).to eq("npub1qlsc3g0lsl8pw8230w8d9wm6xxcax3f6pkemz5measrmwfxjxteslf2hac") end end + + describe "OpenPGP key" do + let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" } + let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" } + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + + before do + GPGME::Key.import(valid_key_alice) + GPGME::Key.import(valid_key_jimmy) + alice.update pgp_fpr: fingerprint_alice + jimmy.update pgp_fpr: fingerprint_jimmy + allow(alice).to receive(:ldap_entry).and_return({ pgp_key: valid_key_alice }) + allow(jimmy).to receive(:ldap_entry).and_return({ pgp_key: valid_key_jimmy }) + end + + after do + alice.gnupg_key.delete! + jimmy.gnupg_key.delete! + end + + describe "#acceptable_pgp_key_format" do + it "validates the record when the key is valid" do + alice.pgp_pubkey = valid_key_alice + expect(alice).to be_valid + end + + it "adds a validation error when the key is not valid" do + user.pgp_pubkey = invalid_key + expect(user).to_not be_valid + expect(user.errors[:pgp_pubkey]).to be_present + end + end + + describe "#pgp_pubkey" do + it "returns the raw pubkey from LDAP" do + expect(alice.pgp_pubkey).to eq(valid_key_alice) + end + end + + describe "#gnupg_key" do + subject { alice.gnupg_key } + + it "returns a GPGME::Key object from the system's GPG keyring" do + expect(subject).to be_a(GPGME::Key) + expect(subject.fingerprint).to eq(fingerprint_alice) + expect(subject.email).to eq("alice@openpgp.example") + end + end + + describe "#pgp_pubkey_contains_user_address?" do + it "returns false when the user address is one of the UIDs of the key" do + expect(alice.pgp_pubkey_contains_user_address?).to eq(false) + end + + it "returns true when the user address is missing from the UIDs of the key" do + expect(jimmy.pgp_pubkey_contains_user_address?).to eq(true) + end + end + + describe "wkd_hash" do + it "returns a z-base32 encoded SHA-1 digest of the username" do + expect(alice.wkd_hash).to eq("kei1q4tipxxu1yj79k9kfukdhfy631xe") + end + end + end end diff --git a/spec/requests/web_key_directory_spec.rb b/spec/requests/web_key_directory_spec.rb new file mode 100644 index 0000000..c7ef1f3 --- /dev/null +++ b/spec/requests/web_key_directory_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe "OpenPGP Web Key Directory", type: :request do + describe "policy" do + it "returns an empty 200 response" do + get "/.well-known/openpgpkey/policy" + expect(response).to have_http_status(:ok) + expect(response.body).to be_empty + end + end + + describe "non-existent user" do + it "returns a 404 status" do + get "/.well-known/openpgpkey/hu/fmb8gw3n4zdj4xpwaziki4mwcxr1368i?l=aristotle" + expect(response).to have_http_status(:not_found) + end + end + + describe "user without pubkey" do + let(:user) { create :user, cn: 'bernd', ou: 'kosmos.org' } + + it "returns a 404 status" do + get "/.well-known/openpgpkey/hu/kp95h369c89sx8ia1hn447i868nqyz4t?l=bernd" + expect(response).to have_http_status(:not_found) + end + end + + describe "user with pubkey" do + let(:alice) { create :user, id: 2, cn: "alice", email: "alice@example.com" } + let(:jimmy) { create :user, id: 3, cn: "jimmy", email: "jimmy@example.com" } + let(:valid_key_alice) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:valid_key_jimmy) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.asc") } + let(:fingerprint_alice) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + let(:fingerprint_jimmy) { "316BF516236DAF77236B15F6057D93972FB862C3" } + let(:invalid_key) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_invalid.asc") } + + before do + GPGME::Key.import(valid_key_alice) + GPGME::Key.import(valid_key_jimmy) + alice.update pgp_fpr: fingerprint_alice + jimmy.update pgp_fpr: fingerprint_jimmy + end + + after do + alice.gnupg_key.delete! + jimmy.gnupg_key.delete! + end + + describe "pubkey does not contain user address" do + before do + allow_any_instance_of(User).to receive(:ldap_entry) + .and_return({ pgp_key: valid_key_alice }) + end + + it "returns a 404 status" do + get "/.well-known/openpgpkey/hu/kei1q4tipxxu1yj79k9kfukdhfy631xe?l=alice" + expect(response).to have_http_status(:not_found) + end + end + + describe "pubkey contains user address" do + before do + allow_any_instance_of(User).to receive(:ldap_entry) + .and_return({ pgp_key: valid_key_jimmy }) + end + + it "returns the pubkey in binary format" do + get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=jimmy" + expect(response).to have_http_status(:ok) + expect(response.headers['Content-Type']).to eq("application/octet-stream") + expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem") + expect(response.body).to eq(expected_binary_data) + end + + context "with wrong capitalization of username" do + it "returns the pubkey as ASCII Armor plain text" do + get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf?l=JimmY" + expect(response).to have_http_status(:ok) + expected_binary_data = File.binread("#{Rails.root}/spec/fixtures/files/pgp_key_valid_jimmy.pem") + expect(response.body).to eq(expected_binary_data) + end + end + + context "with .txt extension" do + it "returns the pubkey as ASCII Armor plain text" do + get "/.well-known/openpgpkey/hu/yuca4ky39mhwkjo78qb8zjgbfj1hg3yf.txt?l=jimmy" + expect(response).to have_http_status(:ok) + expect(response.body).to eq(valid_key_jimmy) + expect(response.headers['Content-Type']).to eq("text/plain") + end + end + + context "invalid URL" do + it "returns a 422 status" do + get "/.well-known/openpgpkey/hu/123456abcdef?l=alice" + expect(response).to have_http_status(:not_found) + end + end + end + end +end diff --git a/spec/services/create_account_spec.rb b/spec/services/user_manager/create_account_spec.rb similarity index 89% rename from spec/services/create_account_spec.rb rename to spec/services/user_manager/create_account_spec.rb index 4aaf139..3958b5a 100644 --- a/spec/services/create_account_spec.rb +++ b/spec/services/user_manager/create_account_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' -RSpec.describe CreateAccount, type: :model do +RSpec.describe UserManager::CreateAccount, type: :model do describe "#create_user_in_database" do - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'isaacnewton', email: 'isaacnewton@example.com', password: 'bright-ideas-in-autumn' @@ -19,7 +19,7 @@ RSpec.describe CreateAccount, type: :model do describe "#update_invitation" do let(:invitation) { create :invitation } - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'isaacnewton', email: 'isaacnewton@example.com', password: 'bright-ideas-in-autumn', @@ -42,7 +42,7 @@ RSpec.describe CreateAccount, type: :model do describe "#add_ldap_document" do include ActiveJob::TestHelper - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'halfinney', email: 'halfinney@example.com', password: 'remember-remember-the-5th-of-november' @@ -68,7 +68,7 @@ RSpec.describe CreateAccount, type: :model do describe "#add_ldap_document for pre-confirmed account" do include ActiveJob::TestHelper - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'halfinney', email: 'halfinney@example.com', password: 'remember-remember-the-5th-of-november', @@ -89,7 +89,7 @@ RSpec.describe CreateAccount, type: :model do describe "#create_lndhub_account" do include ActiveJob::TestHelper - let(:service) { CreateAccount.new(account: { + let(:service) { described_class.new(account: { username: 'halfinney', email: 'halfinney@example.com', password: 'bright-ideas-in-winter' })} diff --git a/spec/services/create_invitations_spec.rb b/spec/services/user_manager/create_invitations_spec.rb similarity index 84% rename from spec/services/create_invitations_spec.rb rename to spec/services/user_manager/create_invitations_spec.rb index 4e5de23..d994c6e 100644 --- a/spec/services/create_invitations_spec.rb +++ b/spec/services/user_manager/create_invitations_spec.rb @@ -1,13 +1,13 @@ require 'rails_helper' -RSpec.describe CreateInvitations, type: :model do +RSpec.describe UserManager::CreateInvitations, type: :model do include ActiveJob::TestHelper let(:user) { create :user } describe "#call" do before do - CreateInvitations.call(user: user, amount: 5) + described_class.call(user: user, amount: 5) end after(:each) { clear_enqueued_jobs } @@ -28,7 +28,7 @@ RSpec.describe CreateInvitations, type: :model do describe "#call with notification disabled" do before do - CreateInvitations.call(user: user, amount: 3, notify: false) + described_class.call(user: user, amount: 3, notify: false) end after(:each) { clear_enqueued_jobs } diff --git a/spec/services/user_manager/update_pgp_key_spec.rb b/spec/services/user_manager/update_pgp_key_spec.rb new file mode 100644 index 0000000..844eaf4 --- /dev/null +++ b/spec/services/user_manager/update_pgp_key_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +RSpec.describe UserManager::UpdatePgpKey, type: :model do + include ActiveJob::TestHelper + + let(:alice) { create :user, cn: "alice" } + let(:dn) { "cn=alice,ou=kosmos.org,cn=users,dc=kosmos,dc=org" } + let(:pubkey_asc) { File.read("#{Rails.root}/spec/fixtures/files/pgp_key_valid_alice.asc") } + let(:fingerprint) { "EB85BB5FA33A75E15E944E63F231550C4F47E38E" } + + before do + allow(alice).to receive(:dn).and_return(dn) + allow(alice).to receive(:ldap_entry).and_return({ + uid: alice.cn, ou: alice.ou, pgp_key: nil + }) + end + + describe "#call" do + context "with valid key" do + before do + alice.pgp_pubkey = pubkey_asc + + allow(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: pubkey_asc) + end + + after do + alice.gnupg_key.delete! + end + + it "imports the key into the GnuPG keychain" do + described_class.call(user: alice) + expect(alice.gnupg_key).to be_present + end + + it "stores the key's fingerprint on the user record" do + described_class.call(user: alice) + expect(alice.pgp_fpr).to eq(fingerprint) + end + + it "updates the user's LDAP entry with the new key" do + expect(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: pubkey_asc) + described_class.call(user: alice) + end + end + + context "with empty key" do + before do + alice.update pgp_fpr: fingerprint + alice.pgp_pubkey = "" + + allow(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: "") + end + + it "does not attempt to import the key" do + expect(GPGME::Key).not_to receive(:import) + described_class.call(user: alice) + end + + it "removes the key's fingerprint from the user record" do + described_class.call(user: alice) + expect(alice.pgp_fpr).to be_nil + end + + it "removes the key from the user's LDAP entry" do + expect(LdapManager::UpdatePgpKey).to receive(:call) + .with(dn: alice.dn, pubkey: "") + described_class.call(user: alice) + end + end + end +end