diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b330956..9b99382 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,21 +4,21 @@ "name": "Astral-Rails", "dockerComposeFile": "docker-compose.yml", "service": "app", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}" + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. // This can be used to network with other containers or the host. - // "forwardPorts": [3000, 5432, 8200] + "forwardPorts": [3000, 5432, 8200], // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "bundle install && rake db:setup", + "postCreateCommand": "bundle install && rake vault:setup", // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + "remoteUser": "vscode" } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index c435755..50cd8ff 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -13,11 +13,14 @@ services: command: sleep infinity # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. - network_mode: service:db + networks: + astral: + ipv4_address: "10.1.10.200" environment: - VAULT_ADDRESS: http://vault:8200 + VAULT_ADDR: http://10.1.10.100:8200 VAULT_TOKEN: root_token + JWT_SIGNING_KEY: jwt_secret vault: image: hashicorp/vault:latest @@ -25,32 +28,13 @@ services: environment: VAULT_DEV_ROOT_TOKEN_ID: root_token VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 - - db: - image: postgres:latest - restart: unless-stopped - volumes: - - postgres-data:/var/lib/postgresql/data - - ./create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql - environment: - POSTGRES_USER: postgres - POSTGRES_DB: postgres - POSTGRES_PASSWORD: postgres - # Your config/database.yml should use the user and password you set here, - # and host "db" (as that's the name of this service). You can use whatever - # database name you want. Use `bin/rails db:prepare` to create the database. - # - # Example: - # - # development: - # <<: *default - # host: db - # username: postgres - # password: postgres - # database: myapp_development - - # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. - # (Adding the "ports" property to this file will not forward from a Codespace.) - -volumes: - postgres-data: \ No newline at end of file + networks: + astral: + ipv4_address: "10.1.10.100" + +networks: + astral: + ipam: + driver: default + config: + - subnet: "10.1.10.0/24" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66414b7..fcf3b18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,30 +13,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Scan for common Rails security vulnerabilities using static analysis - run: bin/brakeman --no-pager - - scan_js: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - name: Run brakeman + uses: devcontainers/ci@v0.3 with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Scan for security vulnerabilities in JavaScript dependencies - run: bin/importmap audit + runCmd: bin/brakeman --no-pager lint: runs-on: ubuntu-latest @@ -63,23 +43,13 @@ jobs: # - 6379:6379 # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - name: Install packages - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libsqlite3-0 libvips - - name: Checkout code uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - name: Run tests - env: - RAILS_ENV: test - # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test test:system + uses: devcontainers/ci@v0.3 + with: + runCmd: bin/rails test - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/Gemfile b/Gemfile index e1a08a9..a8e16e9 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem "puma", ">= 5.0" # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[ windows jruby ] +gem "tzinfo-data", platforms: %i[ mswin jruby ] # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false @@ -32,9 +32,12 @@ gem "bootsnap", require: false # Use the vault-ruby gem to interact with HashiCorp Vault gem "vault" +# Use the jwt gem to decode access tokens +gem "jwt" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + gem "debug", platforms: %i[ mri mswin ], require: "debug/prelude" # Static analysis for security vulnerabilities [https://brakemanscanner.org/] gem "brakeman", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7fca954..aefbe81 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,7 +79,7 @@ GEM bigdecimal (3.1.8) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.1.2) + brakeman (6.2.1) racc builder (3.3.0) concurrent-ruby (1.3.4) @@ -100,6 +100,8 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.7.2) + jwt (2.8.2) + base64 language_server-protocol (3.17.0.3) logger (1.6.0) loofah (2.22.0) @@ -268,6 +270,7 @@ DEPENDENCIES bootsnap brakeman debug + jwt puma (>= 5.0) rails (~> 7.2.0) rubocop-rails-omakase diff --git a/README.md b/README.md index 7db80e4..a28d48c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,25 @@ # README -This README would normally document whatever steps are necessary to get the -application up and running. +Astral-rails is a proof-of-concept api application intended to simplify +certificate acquisition for other applications/services. Broadly speaking, +it will: -Things you may want to cover: +1) Authorize the request for cerficate using a third party trusted source (JWT, etc) +2) If authorized, obtain a certificate from PKI CLM (such as Vault/OpenBao) +3) Log this transaction in audit infrastructure (ELK, etc). -* Ruby version +# Running -* System dependencies +This app is most easily run and developed in its devcontainer. -* Configuration +1) Open in devcontainer +2) Launch server using vscode launch config, or in terminal run: +``` +rails s +``` +3) POST /certificates to acquire cert in terminal: +``` +curl -X POST http://localhost:3000/certificates \ +-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHBsaWNhdGlvbl9uYW1lIiwiY29tbW9uX25hbWUiOiJleGFtcGxlLmNvbSIsImlwX3NhbnMiOiIxMC4wLjEuMTAwIn0.61e0oQIj7vwGtOpFuPJDCI_Bqf8ZTpJxe_2kUwcbN7Y" +``` -* Database creation - -* Database initialization - -* How to run the test suite - -* Services (job queues, cache servers, search engines, etc.) - -* Deployment instructions - -* ... diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4ac8823..3eeffc5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,35 @@ class ApplicationController < ActionController::API + rescue_from StandardError, with: :handle_standard_error + rescue_from AuthError, with: :handle_auth_error + rescue_from ActionController::ParameterMissing, with: :handle_bad_request + + attr_reader :identity # decoded and verified JWT + + def info + render json: { + app: "astral", + description: "Astral provides a simplified API for PKI.", + version: "0.0.1" + } + end + + def authenticate_request + token = request.headers["Authorization"] + token = token.split(" ").last if token + @identity = Services::AuthService.new.authenticate!(token) + end + + private + + def handle_standard_error(exception) + render json: { error: exception.message }, status: :internal_server_error + end + + def handle_auth_error(exception) + render json: { error: "Unauthorized" }, status: :unauthorized + end + + def handle_bad_request(exception) + render json: { error: exception }, status: :bad_request + end end diff --git a/app/controllers/certificates_controller.rb b/app/controllers/certificates_controller.rb new file mode 100644 index 0000000..4528663 --- /dev/null +++ b/app/controllers/certificates_controller.rb @@ -0,0 +1,36 @@ +class CertificatesController < ApplicationController + before_action :authenticate_request + + def create + req = CertIssueRequest.new(params_permitted) + if !req.valid? + render json: { error: req.errors }, status: :bad_request + else + cert = Services::CertificateService.new.issue_cert(req) + render json: cert + end + end + + private + + def params_permitted + attrs = %i[ common_name + alt_names + exclude_cn_from_sans + format + not_after + other_sans + private_key_format + remove_roots_from_chain + ttl + uri_sans + ip_sans + serial_number + client_flag + code_signing_flag + email_protection_flag + server_flag + ] + params.permit(attrs) + end +end diff --git a/app/lib/auth_error.rb b/app/lib/auth_error.rb new file mode 100644 index 0000000..61cb7bb --- /dev/null +++ b/app/lib/auth_error.rb @@ -0,0 +1,3 @@ +# Error representing a failed authentication +class AuthError < StandardError +end diff --git a/app/lib/services/app_registry_service.rb b/app/lib/services/app_registry_service.rb new file mode 100644 index 0000000..6b92cc2 --- /dev/null +++ b/app/lib/services/app_registry_service.rb @@ -0,0 +1,32 @@ +module Services + class AppRegistryService + def authenticate!(token) + identity = decode(token) + raise AuthError unless identity + # TODO verify identity with authority? + identity + end + + def authorize!(identity, cert_req) + cert_req.fqdns.each do |fqdn| + domain = get_domain_name(fqdn) + raise AuthError unless (domain[:auto_approved_groups] & identity[:groups]).any? + end + end + + private + + def decode(token) + # Decode a JWT access token using the configured base. + body = JWT.decode(token, Rails.application.config.astral[:jwt_signing_key])[0] + HashWithIndifferentAccess.new body + rescue => e + Rails.logger.warn "Unable to decode token: #{e}" + nil + end + + def get_domain_name(fqdn) + # TODO implement + end + end +end diff --git a/app/lib/services/auth_service.rb b/app/lib/services/auth_service.rb new file mode 100644 index 0000000..a1e1764 --- /dev/null +++ b/app/lib/services/auth_service.rb @@ -0,0 +1,16 @@ +module Services + class AuthService + def initialize + # TODO make this selectable + @impl = AppRegistryService.new + end + + def authenticate!(token) + @impl.authenticate!(token) + end + + def authorize!(token, cert_issue_req) + @impl.authorize!(token, cert_issue_req) + end + end +end diff --git a/app/lib/services/certificate_service.rb b/app/lib/services/certificate_service.rb new file mode 100644 index 0000000..3b63dfa --- /dev/null +++ b/app/lib/services/certificate_service.rb @@ -0,0 +1,12 @@ +module Services + class CertificateService + def initialize + # TODO this should select an implementation service based on config + @impl = VaultService.new + end + + def issue_cert(cert_issue_request) + @impl.issue_cert(cert_issue_request) + end + end +end diff --git a/app/lib/services/vault_service.rb b/app/lib/services/vault_service.rb new file mode 100644 index 0000000..efb2381 --- /dev/null +++ b/app/lib/services/vault_service.rb @@ -0,0 +1,21 @@ +module Services + class VaultService + def initialize + # TODO create a new token for use in the session + @client = Vault::Client.new( + address: Rails.application.config.astral[:vault_addr], + token: Rails.application.config.astral[:vault_token] + ) + end + + def issue_cert(cert_issue_request) + # Generate the TLS certificate using the intermediate CA + tls_cert = @client.logical.write(Rails.application.config.astral[:vault_cert_path], + common_name: cert_issue_request.common_name, + ttl: cert_issue_request.ttl, + ip_sans: cert_issue_request.ip_sans, + format: cert_issue_request.format) + tls_cert.data + end + end +end diff --git a/app/models/cert_issue_request.rb b/app/models/cert_issue_request.rb new file mode 100644 index 0000000..503845e --- /dev/null +++ b/app/models/cert_issue_request.rb @@ -0,0 +1,30 @@ +class CertIssueRequest + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :common_name, :string + attribute :alt_names, array: :string, default: [] + attribute :exclude_cn_from_sans, :boolean, default: false + attribute :format, :string, default: "pem" + attribute :not_after, :datetime + attribute :other_sans, array: :string, default: [] + attribute :private_key_format, :string, default: "pem" + attribute :remove_roots_from_chain, :boolean, default: false + attribute :ttl, :integer, default: Rails.configuration.astral[:cert_ttl] + attribute :uri_sans, array: :string, default: [] + attribute :ip_sans, array: :string, default: [] + attribute :serial_number, :integer + attribute :client_flag, :boolean, default: true + attribute :code_signing_flag, :boolean, default: false + attribute :email_protection_flag, :boolean, default: false + attribute :server_flag, :boolean, default: true + + validates :common_name, presence: true + validates :format, presence: true, inclusion: { in: %w[pem der pem_bundle] } + validates :private_key_format, presence: true, inclusion: { in: %w[pem der pkcs8] } + + + def fqdns + alt_names + [ common_name ] + end +end diff --git a/config/application.rb b/config/application.rb index a796394..b4b90a2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -28,5 +28,8 @@ class Application < Rails::Application # Middleware like session, flash, cookies can be added back manually. # Skip views, helpers and assets when generating a new resource. config.api_only = true + + # Application configs from config/astral.yml + config.astral = config_for :astral end end diff --git a/config/astral.yml b/config/astral.yml new file mode 100644 index 0000000..2872e54 --- /dev/null +++ b/config/astral.yml @@ -0,0 +1,10 @@ +shared: + vault_addr: <%= ENV["VAULT_ADDR"] %> + vault_token: <%= ENV["VAULT_TOKEN"] %> + vault_cert_path: "pki_int/issue/learn" + jwt_signing_key: <%= ENV["JWT_SIGNING_KEY"] %> + cert_ttl: <%= 24.hours.in_seconds %> + +development: + +production: diff --git a/config/routes.rb b/config/routes.rb index 33c9639..0de2bf3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,5 +10,7 @@ get "manifest" => "rails/pwa#manifest", as: :pwa_manifest # Defines the root path route ("/") - # root "posts#index" + root "application#info" + + resources :certificates, only: %i[create] end diff --git a/lib/tasks/vault.rake b/lib/tasks/vault.rake new file mode 100644 index 0000000..ca1bc97 --- /dev/null +++ b/lib/tasks/vault.rake @@ -0,0 +1,111 @@ +require "rake" +require "vault" +require "json" + +# Define Rake tasks +namespace :vault do + desc "Setup PKI root and intermediate certificates" + task :setup do + unless Rails.env.development? + raise "This task should only be used in development" + end + Vault.address = ENV["VAULT_ADDR"] + Vault.token = ENV["VAULT_TOKEN"] + ensure_root_cert + configure_root_cert + ensure_intermediate_cert + configure_intermediate_cert + end +end + +# Helper methods +def enable_pki(path, max_ttl) + unless Vault.sys.mounts.key?(path + "/") + Vault.sys.mount(path, "pki", "PKI Secrets Engine") + else + puts "#{path} already enabled." + end +rescue Vault::HTTPError => e + puts "Error enabling pki, already enabled?: #{e}" +end + +def ensure_root_cert + enable_pki("pki", "87600h") + + # Generate root certificate + root_cert = Vault.logical.write("pki/root/generate/internal", + common_name: "astral.internal", + issuer_name: "root-2024", + ttl: "87600h").data[:certificate] + + # Save the root certificate + File.write("tmp/root_2024_ca.crt", root_cert) +rescue Vault::HTTPError => e + puts "Error enabling root pki, already enabled?: #{e}" +end + +def configure_root_cert + Vault.logical.write("pki/config/cluster", + path: "#{ENV["VAULT_ADDR"]}/v1/pki", + aia_path: "#{ENV["VAULT_ADDR"]}/v1/pki") + + Vault.logical.write("pki/roles/2024-servers", + allow_any_name: true, + no_store: false) + + Vault.logical.write("pki/config/urls", + issuing_certificates: "{{cluster_aia_path}}/issuer/{{issuer_id}}/der", + crl_distribution_points: "{{cluster_aia_path}}/issuer/{{issuer_id}}/crl/der", + ocsp_servers: "{{cluster_path}}/ocsp", + enable_templating: true) +rescue Vault::HTTPError => e + puts "Error configuring root pki, already enabled?: #{e}" +end + +def ensure_intermediate_cert + enable_pki("pki_int", "43800h") + + # Generate intermediate CSR + intermediate_csr = Vault.logical.write("pki_int/intermediate/generate/internal", + common_name: "astral.internal Intermediate Authority", + issuer_name: "learn-intermediate").data[:csr] + + # Save the intermediate CSR + File.write("tmp/pki_intermediate.csr", intermediate_csr) + + # Sign the intermediate certificate with the root CA + intermediate_cert = Vault.logical.write("pki/root/sign-intermediate", + issuer_ref: "root-2024", + csr: intermediate_csr, + format: "pem_bundle", + ttl: "43800h").data[:certificate] + + # Save the signed intermediate certificate + File.write("tmp/intermediate.cert.pem", intermediate_cert) + + # Set the signed intermediate certificate + Vault.logical.write("pki_int/intermediate/set-signed", certificate: intermediate_cert) +rescue Vault::HTTPError => e + puts "Error enabling intermediate pki, already enabled?: #{e}" +end + +def configure_intermediate_cert + Vault.logical.write("pki_int/config/cluster", + path: "#{Vault.address}/v1/pki_int", + aia_path: "#{Vault.address}/v1/pki_int") + + issuer_ref = Vault.logical.read("pki_int/config/issuers").data[:default] + Vault.logical.write("pki_int/roles/learn", + issuer_ref: issuer_ref, + allow_any_name: true, + max_ttl: "720h", + no_store: false) + + Vault.logical.write("pki_int/config/urls", + issuing_certificates: "{{cluster_aia_path}}/issuer/{{issuer_id}}/der", + crl_distribution_points: "{{cluster_aia_path}}/issuer/{{issuer_id}}/crl/der", + ocsp_servers: "{{cluster_path}}/ocsp", + enable_templating: true) +rescue Vault::HTTPError => e + puts "Error configuring intermediate pki, already enabled?: #{e}" +end diff --git a/test/integration/certificates_controller_test.rb b/test/integration/certificates_controller_test.rb new file mode 100644 index 0000000..3df4e5e --- /dev/null +++ b/test/integration/certificates_controller_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class CertificatesControllerTest < ActionDispatch::IntegrationTest + test "create unauthorized" do + post certificates_path + assert_response :unauthorized + end + + test "create with faulty token (encoded with different signing key)" do + jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHBsaWNhdGlvbl9uYW1lIiwiY29tbW9uX25hbWUiOiJleGFtcGxlLmNvbSIsImlwX3NhbnMiOiIxMC4wLjEuMTAwIn0.gEUyaZcARiBQNq2RUwZU0MdFXqthyo_oSQ8DAgKvxCs" + post certificates_path, headers: { "Authorization" => "Bearer #{jwt}" } + assert_response :unauthorized + end + + test "create authorized" do + jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhcHBsaWNhdGlvbl9uYW1lIiwiY29tbW9uX25hbWUiOiJleGFtcGxlLmNvbSIsImlwX3NhbnMiOiIxMC4wLjEuMTAwIn0.61e0oQIj7vwGtOpFuPJDCI_Bqf8ZTpJxe_2kUwcbN7Y" + post certificates_path, headers: { "Authorization" => "Bearer #{jwt}" }, + params: { common_name: "example.com" } + assert_response :success + %w[ ca_chain + certificate + expiration + issuing_ca + private_key + private_key_type + serial_number ].each do |key| + assert_includes response.parsed_body.keys, key + end + end +end diff --git a/test/models/cert_isssue_request_test.rb b/test/models/cert_isssue_request_test.rb new file mode 100644 index 0000000..8738915 --- /dev/null +++ b/test/models/cert_isssue_request_test.rb @@ -0,0 +1,77 @@ +# test/models/cert_issue_request_test.rb +require "test_helper" + +class CertIssueRequestTest < ActiveSupport::TestCase + def setup + @attributes = { + common_name: "example.com", + alt_names: [ "alt1.example.com", "alt2.example.com" ], + exclude_cn_from_sans: true, + format: "der", + not_after: DateTime.now + 1.year, + other_sans: [ "other1", "other2" ], + private_key_format: "pkcs8", + remove_roots_from_chain: true, + ttl: 365, + uri_sans: [ "http://example.com" ], + ip_sans: [ "192.168.1.1" ], + serial_number: 123456, + client_flag: false, + code_signing_flag: true, + email_protection_flag: true, + server_flag: false + } + @cert_issue_request = CertIssueRequest.new(@attributes) + end + + test "should set attributes correctly" do + @attributes.each do |key, value| + assert_equal value, @cert_issue_request.send(key), "Attribute #{key} was not set correctly" + end + end + + test "should be valid with valid attributes" do + assert @cert_issue_request.valid? + end + + test "should require a common_name" do + @cert_issue_request.common_name = nil + assert_not @cert_issue_request.valid? + assert_includes @cert_issue_request.errors[:common_name], "can't be blank" + end + + test "should require a valid format" do + @cert_issue_request.format = "invalid_format" + assert_not @cert_issue_request.valid? + assert_includes @cert_issue_request.errors[:format], "is not included in the list" + end + + test "should require a valid private_key_format" do + @cert_issue_request.private_key_format = "invalid_format" + assert_not @cert_issue_request.valid? + assert_includes @cert_issue_request.errors[:private_key_format], "is not included in the list" + end + + test "should have default values" do + @cert_issue_request = CertIssueRequest.new + assert_equal false, @cert_issue_request.exclude_cn_from_sans + assert_equal "pem", @cert_issue_request.format + assert_equal "pem", @cert_issue_request.private_key_format + assert_equal false, @cert_issue_request.remove_roots_from_chain + assert_equal Rails.configuration.astral[:cert_ttl], @cert_issue_request.ttl + assert_equal true, @cert_issue_request.client_flag + assert_equal false, @cert_issue_request.code_signing_flag + assert_equal false, @cert_issue_request.email_protection_flag + assert_equal true, @cert_issue_request.server_flag + end + + test "should be invalid with default values" do + @cert_issue_request = CertIssueRequest.new + assert_not @cert_issue_request.valid? + end + + + test "fqdns should return alt_names plus common_name" do + assert_equal [ "alt1.example.com", "alt2.example.com", "example.com" ], @cert_issue_request.fqdns + end +end