Let users upload their OpenPGP public key, and serve WKD response #205

Merged
raucao merged 16 commits from feature/191-gpg_keys_wkd into master 2024-10-14 14:08:32 +00:00
44 changed files with 869 additions and 132 deletions

View File

@ -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'

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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,

View File

@ -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
raucao marked this conversation as resolved
Review

Are any user emails other than the Kosmos address cleared somewhere in the process or are they exposed here?

Found this in the spec under "Security Considerations":

The mail provider MUST make sure to publish a key in a way that only
the mail address belonging to the requested user is part of the User
ID packets included in the returned key. Other User ID packets and
their associated binding signatures MUST be removed before
publication.

Are any user emails other than the Kosmos address cleared somewhere in the process or are they exposed here? Found this [in the spec](https://www.ietf.org/archive/id/draft-koch-openpgp-webkey-service-18.html#name-security-considerations) under "Security Considerations": > The mail provider MUST make sure to publish a key in a way that only the mail address belonging to the requested user is part of the User ID packets included in the returned key. Other User ID packets and their associated binding signatures MUST be removed before publication.
Review

Good catch. Currently, it offers whatever key is uploaded in its entirety, i.e. including any UIDs that do not contain the user's account address. I'll open a new issue for it, since it's a bit of an edge case IMO.

Good catch. Currently, it offers whatever key is uploaded in its entirety, i.e. including any UIDs that do not contain the user's account address. I'll open a new issue for it, since it's a bit of an edge case IMO.
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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 = {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
class UserManagerService < ApplicationService
end

View File

@ -89,13 +89,47 @@
</section>
<section class="sm:flex-1 sm:pt-0">
<% if @avatar.present? %>
<h3>LDAP<h3>
<p>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" />
</p>
<% end %>
<!-- <h3>Actions</h3> -->
<h3>LDAP</h3>
<table class="divided">
<tbody>
<tr>
<th>Avatar</th>
<td>
<% if @avatar.present? %>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" />
<% else %>
&mdash;
<% end %>
</td>
</tr>
<tr>
<th>Display name</th>
<td><%= @user.display_name || "—" %></td>
</tr>
<tr>
<th class="align-top">PGP key</th>
<td class="align-top leading-5">
<% if @user.pgp_pubkey.present? %>
<span class="font-mono" title="<%= @user.pgp_fpr %>">
<% 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 %>
</span><br />
<% @user.gnupg_key.uids.each do |uid| %>
<%= uid.uid %><br />
<% end %>
<% else %>
&mdash;
<% end %>
</td>
</tr>
</tbody>
</table>
</section>
</div>

View File

@ -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 %>
<h3>E-Mail</h3>
<%= form_for(@user, url: update_email_settings_path, method: "post") do |f| %>
@ -23,7 +23,7 @@
</span>
</button>
</p>
<% if @validation_errors.present? && @validation_errors[:email].present? %>
<% if @validation_errors&.[](:email)&.present? %>
<p class="error-msg"><%= @validation_errors[:email].first %></p>
<% end %>
<div class="initial-hidden">
@ -41,10 +41,33 @@
<% end %>
<section>
<h3>Password</h3>
<p class="mb-8">Use the following button to request an email with a password reset link:</p>
<p class="mb-6">Use the following button to request an email with a password reset link:</p>
<%= form_with(url: reset_password_settings_path, method: :post) do %>
<p>
<%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>
</p>
<% end %>
</section>
<%= form_for(@user, url: setting_path(:account), html: { :method => :put }) do |f| %>
<section class="!pt-8 sm:!pt-12">
<h3>OpenPGP</h3>
<ul role="list">
<%= render FormElements::FieldsetComponent.new(
title: "Public key",
description: "Your OpenPGP public key in ASCII Armor format"
) do %>
<%= f.text_area :pgp_pubkey,
value: @user.pgp_pubkey,
class: "h-24 w-full" %>
<% if @validation_errors&.[](:pgp_pubkey)&.present? %>
<p class="error-msg">This <%= @validation_errors[:pgp_pubkey].first %></p>
<% end %>
<% end %>
</ul>
</section>
<section>
<p class="pt-6 border-t border-gray-200 text-right">
<%= f.submit 'Save', class: "btn-md btn-blue w-full md:w-auto" %>
</p>
</section>
<% end %>

View File

@ -5,7 +5,7 @@
<h3>E-Mail Password</h3>
<%= form_for(@user, url: reset_email_password_settings_path, method: "post") do |f| %>
<%= hidden_field_tag :section, "email" %>
<p class="mb-8">
<p class="mb-6">
Use the following button to generate a new email password:
</p>
<p class="hidden initial-visible">

View File

@ -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'

View File

@ -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",
}

View File

@ -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

View File

@ -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'

View File

@ -0,0 +1,5 @@
class AddPgpFprToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :pgp_fpr, :string
end
end

View File

@ -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

13
db/seeds/admin.asc Normal file
View File

@ -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-----

View File

@ -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

View File

@ -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 )

View File

@ -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

View File

@ -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)

View File

@ -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",

11
spec/fixtures/files/pgp_key_invalid.asc vendored Normal file
View File

@ -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-----

View File

@ -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-----

View File

@ -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-----

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'
})}

View File

@ -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 }

View File

@ -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