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 @@
- <% if @avatar.present? %> -

LDAP

-

- -

- <% end %> - +

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 %> +
diff --git a/app/views/settings/_account.html.erb b/app/views/settings/_account.html.erb index b1b3430..072b769 100644 --- a/app/views/settings/_account.html.erb +++ b/app/views/settings/_account.html.erb @@ -1,6 +1,6 @@ <%= tag.section data: { controller: "settings--account--email", - "settings--account--email-validation-failed-value": @validation_errors.present? + "settings--account--email-validation-failed-value": @validation_errors&.[](:email)&.present? } do %>

E-Mail

<%= form_for(@user, url: update_email_settings_path, method: "post") do |f| %> @@ -23,7 +23,7 @@

- <% if @validation_errors.present? && @validation_errors[:email].present? %> + <% if @validation_errors&.[](:email)&.present? %>

<%= @validation_errors[:email].first %>

<% end %>
@@ -41,10 +41,33 @@ <% end %>

Password

-

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 %>
+<%= form_for(@user, url: setting_path(:account), html: { :method => :put }) do |f| %> +
+

OpenPGP

+ +
+
+

+ <%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %> +

+
+<% end %> diff --git a/app/views/settings/_email.html.erb b/app/views/settings/_email.html.erb index 8d48661..a041ea0 100644 --- a/app/views/settings/_email.html.erb +++ b/app/views/settings/_email.html.erb @@ -5,7 +5,7 @@

E-Mail Password

<%= form_for(@user, url: reset_email_password_settings_path, method: "post") do |f| %> <%= hidden_field_tag :section, "email" %> -

+

Use the following button to generate a new email password: