diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..dc04b27 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Auto-generated from team-config.yml +# Team: core +# +# To apply: scripts/apply-codeowners.sh legion-crypt + +* @LegionIO/maintainers +* @LegionIO/core diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..79ea87c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: bundler + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - "type:dependencies" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - "type:dependencies" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a83e3a5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 9 * * 1' + +jobs: + ci: + uses: LegionIO/.github/.github/workflows/ci.yml@main + + lint: + uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main + + security: + uses: LegionIO/.github/.github/workflows/security-scan.yml@main + + version-changelog: + uses: LegionIO/.github/.github/workflows/version-changelog.yml@main + + dependency-review: + uses: LegionIO/.github/.github/workflows/dependency-review.yml@main + + stale: + if: github.event_name == 'schedule' + uses: LegionIO/.github/.github/workflows/stale.yml@main + + release: + needs: [ci, lint] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: LegionIO/.github/.github/workflows/release.yml@main + secrets: + rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/rubocop-analysis.yml b/.github/workflows/rubocop-analysis.yml deleted file mode 100644 index b9d222e..0000000 --- a/.github/workflows/rubocop-analysis.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: "Rubocop" - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - schedule: - - cron: '41 13 * * 4' - -jobs: - rubocop: - runs-on: ubuntu-latest - strategy: - fail-fast: false - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 2.6 - - - name: Install Code Scanning integration - run: bundle add code-scanning-rubocop --version 0.3.0 --skip-install - - - name: Install dependencies - run: bundle install - - - name: Rubocop run - run: | - bash -c " - bundle exec rubocop --require code_scanning --format CodeScanning::SarifFormatter -o rubocop.sarif - [[ $? -ne 2 ]] - " - - - name: Upload Sarif output - uses: github/codeql-action/upload-sarif@v1 - with: - sarif_file: rubocop.sarif diff --git a/.github/workflows/sourcehawk-scan.yml b/.github/workflows/sourcehawk-scan.yml deleted file mode 100644 index 72a2af8..0000000 --- a/.github/workflows/sourcehawk-scan.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Sourcehawk Scan -on: - push: - branches: - - main - - master - pull_request: - branches: - - main - - master -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Sourcehawk Scan - uses: optum/sourcehawk-scan-github-action@main - - - diff --git a/.gitignore b/.gitignore index 54781f1..1b04863 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,11 @@ /tmp/ /legion/.idea/ /.idea/ +*.gem *.key # rspec failure tracking .rspec_status legionio.key + +# git worktrees +.worktrees/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1756f55 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +# Standard LegionIO pre-commit configuration +# Install: pre-commit install +# Manual: pre-commit run --all-files +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + exclude: Gemfile\.lock + - id: check-merge-conflict + + - repo: local + hooks: + - id: rubocop + name: RuboCop (autofix) + entry: scripts/pre-commit-rubocop.sh + language: script + types: [ruby] + pass_filenames: true + + - id: ruby-syntax + name: Ruby syntax check + entry: ruby -c + language: system + types: [ruby] + pass_filenames: true diff --git a/.rubocop.yml b/.rubocop.yml index b9e47c1..80fdf88 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,26 +1,53 @@ +AllCops: + TargetRubyVersion: 3.4 + NewCops: enable + SuggestExtensions: false + Layout/LineLength: - Max: 140 + Max: 160 + +Layout/SpaceAroundEqualsInParameterDefault: + EnforcedStyle: space + +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + Metrics/MethodLength: Max: 50 + Metrics/ClassLength: Max: 1500 + +Metrics/ModuleLength: + Max: 1500 + Metrics/BlockLength: - Max: 50 -Metrics/CyclomaticComplexity: - Max: 14 + Max: 40 + Exclude: + - 'spec/**/*' + Metrics/AbcSize: - Max: 17 + Max: 60 + +Metrics/CyclomaticComplexity: + Max: 15 + Metrics/PerceivedComplexity: - Max: 16 -Naming/MethodParameterName: - Enabled: false + Max: 17 + Style/Documentation: Enabled: false -AllCops: - TargetRubyVersion: 2.6 - NewCops: enable - SuggestExtensions: false + +Style/SymbolArray: + Enabled: true + Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Naming/FileName: + Enabled: false + +Naming/PredicateMethod: Enabled: false -Gemspec/RequiredRubyVersion: - Enabled: false \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0a3671d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# legion-crypt Agent Notes + +## Scope + +`legion-crypt` handles cryptography and secret workflows for Legion: cipher ops, Vault integration, JWT/JWKS verification, key lifecycle, mTLS, and lease/token renewers. + +## Fast Start + +```bash +bundle install +bundle exec rspec +bundle exec rubocop +``` + +## Primary Entry Points + +- `lib/legion/crypt.rb` +- `lib/legion/crypt/cipher.rb` +- `lib/legion/crypt/jwt.rb` +- `lib/legion/crypt/jwks_client.rb` +- `lib/legion/crypt/vault.rb` +- `lib/legion/crypt/lease_manager.rb` +- `lib/legion/crypt/token_renewer.rb` +- `lib/legion/crypt/mtls.rb` + +## Guardrails + +- Treat all changes as security-sensitive. Never log secrets, tokens, private keys, or decrypted plaintext. +- Preserve JWT behavior across HS256/RS256 and external JWKS validation. +- Keep Vault-dependent logic optional and safely guarded for environments without Vault. +- Background renewal/rotation threads must stop cleanly on shutdown and handle failure with bounded retry. +- Maintain compatibility for Kerberos, LDAP, and JWT Vault auth paths. +- Cryptographic defaults and key lifecycle behavior are contract-sensitive; change only with test coverage. + +## Known Risks + +- Vault-backed cluster secret sync is inconsistent today: config key mismatch, read/write path mismatch, and push happens before the new secret is stored. +- External JWKS verification currently accepts tokens without issuer/audience enforcement unless the caller passes both explicitly; fail closed when touching this path. +- Multi-cluster Vault behavior has correctness gaps around LDAP token propagation, default-cluster routing, and lease-manager client selection. +- SPIFFE X.509 fetch currently falls back to a self-signed SVID on Workload API failure; treat that path as security-sensitive and avoid expanding the fallback behavior. +- `Ed25519` and `Erasure` include helper paths that call `Legion::Crypt::Vault.read/write` directly; verify runtime behavior before relying on those helpers. +- Current specs pass, but some of the highest-risk paths above are under-covered or only covered with mocks that preserve the existing behavior. + +## Validation + +- Run targeted specs for changed auth/crypto paths first. +- Before handoff, run full `bundle exec rspec` and `bundle exec rubocop`. diff --git a/CHANGELOG.md b/CHANGELOG.md index fa62b9f..b4b3db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,424 @@ # Legion::Crypt +## [1.5.13] - 2026-05-09 + +### Removed +- Logging compat shims (`lib/legion/logging.rb` and `lib/legion/logging/helper.rb`) that redefined `Legion::Logging::Helper#log` with a `CompatLogger`, preventing TaggedLogger segment tags from rendering in log output for all modules loaded after crypt + +### Added +- `legion-json` gemspec dependency (was used but undeclared) + +## [1.5.12] - 2026-04-27 + +### Fixed +- `LeaseManager#trigger_reconnect` for `:postgresql` now calls `Legion::Data::Connection.reconnect_with_fresh_creds` (legion-data >= 1.6.26) instead of `sequel.disconnect` + `sequel.test_connection` — Sequel bakes credentials into the pool at `Sequel.connect` time, so the old approach reused stale credentials after Vault lease rotation, causing Apollo and other DB-backed services to silently lose access to data +- Fallback to legacy `disconnect`/`test_connection` path when `reconnect_with_fresh_creds` is not available, with explicit warning about potential stale credentials +- Reconnect failures now log at `:error` level (was `:warn`) since a failed reconnect means Apollo and DB-backed services are unavailable until the next rotation cycle +- Lease shutdown, logging fallback, and SPIFFE socket cleanup paths now emit warnings/debug logs instead of silently swallowing unexpected failures. + +## [1.5.11] - 2026-04-27 + +### Fixed +- Cipher decrypt now validates malformed authenticated, legacy, and keypair ciphertext inputs before Base64/OpenSSL decoding, raising actionable errors that identify missing non-secret fields such as auth tag, IV, or cluster secret instead of generic `unpack1` nil failures. +- Crypt's logging compatibility helper now preserves full exception backtraces instead of truncating fallback log output to 10 frames. + +## [1.5.10] - 2026-04-19 + +### Fixed +- `handle_exception` now passes the caller's `level:` kwarg through to `Legion::Logging.log_exception` instead of always defaulting to `:error` — optional missing-gem `LoadError`s log at the intended level (e.g. `:debug`). Fixes LegionIO/LegionIO#155 +- `exception_log_message` now suppresses backtrace for `:debug` level — previously only suppressed when the backtrace was empty + +## [1.5.9] - 2026-04-10 + +### Fixed +- Vault lease cascade revocation: all three service credentials (RabbitMQ, PostgreSQL, Redis) died at exactly 2 hours when the Vault Kerberos auth token expired — Vault cascade-revokes all child leases when the parent token dies, regardless of individual lease TTLs (closes #29) +- `TokenRenewer` now detects non-renewable tokens (`renewable=false`) and skips `renew_self` (which always fails for non-renewable tokens), going straight to `reauth_kerberos` before the token expires +- `TokenRenewer#reauth_kerberos` now triggers `LeaseManager.reissue_all` after obtaining a new token, re-issuing all active leases under the new token so they are not orphaned when the old token expires +- `LeaseManager#push_to_settings` symbol/string key mismatch: `resolve_secrets!` registers refs with string keys (`"rabbitmq"`) via `lease://` URI parsing, but `cache_lease` stores leases with symbol keys (`:rabbitmq` from `Legion::JSON.load`) — now tries both key types +- `LeaseManager#trigger_reconnect` for `:postgresql` — uses surgical Sequel pool `disconnect` + `test_connection` instead of `Data.shutdown + Data.setup` which tore down unrelated connections (Apollo SQLite, Local cache) +- `LeaseManager#trigger_reconnect` for `:redis` — uses `Cache.restart` (the actual method) instead of `Cache.reconnect` (which does not exist) + +### Added +- `LeaseManager#reissue_all` — re-issues all active leases under the current vault client token; called by `TokenRenewer` after successful Kerberos re-authentication to prevent cascade revocation of orphaned leases + +## [1.5.8] - 2026-04-09 + +### Added +- Configurable SSL verification for Vault connections via `crypt.vault.tls.verify` setting (`peer`/`none`/`mutual`, defaults to `peer`) +- Global Vault client (`vault.rb`) now sets `::Vault.ssl_verify` from `vault.tls.verify` setting +- Per-cluster Vault clients (`vault_cluster.rb`) now pass `ssl_verify:` to `::Vault::Client.new` from `config[:tls][:verify]` +- JWKS client (`jwks_client.rb`) now sets `Net::HTTP#verify_mode` from `crypt.jwt.jwks_tls_verify` setting (`peer`/`none`, defaults to `peer`) +- `jwks_tls_verify: 'peer'` default added to JWT settings +- `tls: { verify: 'peer' }` default added to Vault settings + +## [1.5.7] - 2026-04-08 + +### Fixed +- `LeaseManager#cache_lease` now stores the `:path` from static lease definitions, enabling `reissue_lease` fallback when `sys.renew` fails or leases hit max_ttl — previously static leases (configured via `crypt.vault.leases`) would silently expire after their TTL with no recovery (fixes #28) +- `LeaseManager#renew_lease` now logs a warning and falls back to reissue when the path is available, or warns explicitly when no path is available — previously renewal failures for pathless leases were silent + +### Added +- `LeaseManager#trigger_reconnect(name)` — dispatches reconnect to the appropriate service after credential reissue: `:rabbitmq` → `Transport::Connection.force_reconnect`, `:postgresql` → `Data.reconnect`, `:redis` → `Cache.reconnect`; all guarded with `defined?`/`respond_to?` and rescue-safe +- Comprehensive INFO/WARN logging across the entire lease lifecycle: + - INFO on lease fetch attempt, fetch success (with lease_id/ttl/renewable), renewal attempt, renewal success (with new_ttl), reissue attempt, reissue success (with new_lease_id/ttl), approaching expiry detection (with remaining/renewable/has_path), credentials changed during renewal, reconnect triggered, renewal loop start/exit + - WARN on non-renewable lease with no reissue path, renewal failure with no reissue path, reissue returning no data, reconnect failure, cannot reissue due to missing path + +### Changed +- `LeaseManager#reissue_lease` now calls `trigger_reconnect(name)` instead of inline `:rabbitmq`-only `force_reconnect`, extending credential rotation reconnect support to PostgreSQL and Redis + +## [1.5.6] - 2026-04-07 + +### Added +- `VaultEntity` module (`lib/legion/crypt/vault_entity.rb`) — Phase 7 Vault identity tracking + - `ensure_entity(principal_id:, canonical_name:, metadata: {})` — creates or finds a Vault entity for a Legion principal; entity names are prefixed with `legion-` to avoid collision; metadata includes `legion_principal_id`, `legion_canonical_name`, and `managed_by: 'legion'`; returns entity ID string or nil on failure (non-fatal) + - `ensure_alias(entity_id:, mount_accessor:, alias_name:)` — creates an entity alias linking an auth method mount to the entity; idempotent (`already exists` HTTPClientError is swallowed); all other `Vault::HTTPClientError` responses log warn and return nil (non-fatal) + - `find_by_name(canonical_name)` — looks up a Vault entity by its Legion canonical name via `identity/entity/name/legion-{name}`; returns entity ID or nil + - All operations are non-fatal — rescue and log warn on failure; boot/request flow is never blocked by entity tracking errors + - Delegates Vault API calls to `LeaseManager.instance.vault_logical` (public delegator) when available; falls back to `::Vault.logical` + +## [1.5.5] - 2026-04-07 + +### Added +- `RMQ_ROLE_MAP` constant mapping `:agent`/`:infra` → `'legionio-infra'` and `:worker` → `'legionio-worker'` for Vault RabbitMQ role selection (Phase 5 credential scoping) +- `dynamic_rmq_creds?` helper reads `Settings[:crypt][:vault][:dynamic_rmq_creds]` flag +- `fetch_bootstrap_rmq_creds` — fetches short-lived bootstrap RabbitMQ credentials from `rabbitmq/creds/legionio-bootstrap` and writes them to `Settings[:transport][:connection]`; gated on `vault_connected? && dynamic_rmq_creds?`; stores `@bootstrap_lease_id` for later revocation; rescue-safe +- `swap_to_identity_creds(mode:)` — fetches identity-scoped RabbitMQ credentials from the role matching `mode`, registers them with `LeaseManager` for renewal, updates transport settings, calls `Transport::Connection.force_reconnect`, and revokes the bootstrap lease; raises if reconnect fails (before revoking bootstrap) +- `revoke_bootstrap_lease` — revokes `@bootstrap_lease_id` via `LeaseManager#vault_sys`; non-fatal on failure; idempotent +- `LeaseManager#register_dynamic_lease` — registers a dynamically-fetched Vault lease into the cache and active lease tracking with mutex, stores `path` for `reissue_lease`, registers settings refs for rotation push-back +- `LeaseManager#reissue_lease(name)` — performs a full re-read (`logical.read(path)`) at credential rotation time, updates cache + active_leases in mutex, calls `push_to_settings`, triggers `Transport::Connection.force_reconnect` for `:rabbitmq` leases +- `LeaseManager#vault_logical` and `LeaseManager#vault_sys` — public delegators to the private `logical`/`sys` methods for use by `Crypt` bootstrap/swap operations +- `dynamic_rmq_creds: false` and `dynamic_pg_creds: false` defaults added to vault settings + +### Changed +- `start_lease_manager` now starts the renewal thread when `dynamic_rmq_creds: true` even if no static leases are configured, ensuring the renewal loop is running before identity-scoped leases are registered post-boot + +## [1.5.4] - 2026-04-06 + +### Added +- `JWT.issue_identity_token` — convenience method wrapping `JWT.issue` with identity claims from `Identity::Process` (Wire Format Phase 3); accepts `issuer:` kwarg (defaults to `'legion'`) passed through to `JWT.issue`; normalizes and rejects conflicting string-keyed extra claims before merging + +## [1.5.3] - 2026-04-06 + +### Added +- `JwksClient.prefetch!(url)` — fire-and-forget JWKS key fetch in background thread +- `JwksClient.start_background_refresh!(url, interval:)` — `Concurrent::TimerTask` for hourly key refresh +- `JwksClient.stop_background_refresh!` — stops background refresh timer task +- `bootstrap_lease_ttl: 300` in vault defaults (5-minute TTL for bootstrap credentials) + +### Changed +- `JwksClient.clear_cache` now also stops any running background refresh task + +## [1.5.2] - 2026-04-03 + +### Fixed +- LeaseManager `at_exit` hook now wraps shutdown in a 10s timeout to prevent process hang when Logger Monitor or network I/O is blocked during crash exit + +## [1.5.1] - 2026-04-03 + +### Fixed +- Vault `read` method no longer prepends a `legion/` mount prefix to paths — the default `type` parameter changed from `'legion'` to `nil` to match the actual KV v2 mount path in the `legionio` namespace +- LeaseManager now registers an `at_exit` hook to revoke active Vault leases on unclean process exit, preventing orphaned dynamic credentials (RabbitMQ users, PostgreSQL roles, Redis creds) + +## [1.5.0] - 2026-04-02 + +### Fixed +- Cluster-secret Vault synchronization now uses the documented `push_cluster_secret` setting, stores the new secret before pushing it, aligns Vault read/write path and field names, and invalidates cached derived key material on rotation +- External JWT/JWKS verification now fails closed on missing issuer or audience expectations, keeps reserved claims library-controlled, requires HTTPS JWKS transport, and avoids repeated fresh-cache re-fetches for unknown `kid` values +- Shared symmetric encryption now emits authenticated AES-256-GCM payloads for new ciphertexts while preserving decrypt compatibility with legacy AES-256-CBC payloads +- RSA keypair helper encryption now uses explicit OAEP padding for new ciphertexts while preserving decrypt compatibility with legacy PKCS#1 v1.5 payloads +- Multi-cluster Vault auth and helper routing now update live LDAP client tokens, keep per-cluster LDAP state local to the addressed cluster, sync the top-level Vault connected flag from cluster connectivity checks, allow explicit cluster-targeted helper calls, refresh lease metadata on renewal, and schedule short-lived token renewals before expiry +- SPIFFE X.509 fetch now fails closed by default, uses an explicit `allow_x509_fallback` development switch for self-signed fallback SVIDs, tags returned SVIDs with their source, and sends a valid 9-byte HTTP/2 SETTINGS frame during Workload API connection setup +- Ed25519 helper key persistence now uses the supported `Legion::Crypt` API with normalized KV paths, and tenant erasure verification no longer reports success when Vault access itself failed +- Background worker lifecycle is now serialized and cooperative: repeated `Legion::Crypt.start` calls no longer spawn duplicate workers, renewal/rotation threads no longer use `Thread#kill`, and timed-out joins keep their live thread references instead of dropping them + +### Changed +- Adopted `Legion::Logging::Helper` across `lib/` so library logs use structured component tagging instead of direct `Legion::Logging.*` calls +- Expanded `info`/`debug`/`error` coverage across crypt, Vault, JWT, lease, mTLS, SPIFFE, and auth flows to make background actions and failures visible without exposing secrets +- Replaced manual rescue logging with `handle_exception(...)` across library code paths and left Sinatra/API integration untouched for a later pass +- Removed remaining `log_info`/`log_warn`/`log_debug` wrapper methods in `lib/` so helper-backed logging is used directly throughout the library + +### Added +- Runtime dependency on `legion-logging` +- Compatibility shim for `Legion::Logging::Helper` so `handle_exception` and shared `log` access are available consistently during the uplift + +## [1.4.29] - 2026-03-31 + +### Changed +- `force_cluster_secret` and `settings_push_vault` default to `false` instead of always returning `true` +- Default settings: `push_cluster_secret` and `read_cluster_secret` now default to `false` +- Both methods now use `fetch` with explicit default, fixing `|| true` bug that made the settings impossible to disable + +## [1.4.28] - 2026-03-31 + +### Fixed +- `Helper#vault_write` now accepts keyword args (`**data`) and splats to `Crypt.write`, fixing `ArgumentError` on Ruby 3.4 when writing to Vault KV + +## [1.4.27] - 2026-03-31 + +### Fixed +- `connect_vault` now sets `::Vault.namespace` from `vault_namespace` setting, fixing 403 errors for non-cluster Vault connections in namespaced environments +- Extracted `resolve_vault_address` and `log_vault_connection_error` to reduce `connect_vault` complexity + +## [1.4.26] - 2026-03-28 + +### Fixed +- `push_cs_to_vault` now rescues `StandardError` and returns `false` instead of propagating Vault errors (e.g. 403 permission denied), ensuring `set_cluster_secret` always stores the cluster secret in Settings even when the Vault write fails + +### Added +- Specs for `push_cs_to_vault` rescue path: verifies the method returns false and does not raise on Vault errors, and logs a warning when `Legion::Logging` is available +- Specs for `set_cluster_secret` confirming Settings assignment completes when Vault push returns false + +## [1.4.25] - 2026-03-28 + +### Fixed +- `kv_client` and `logical_client` now route through the default cluster `Vault::Client` when multi-cluster is configured, preventing 403 errors caused by the un-initialized global `::Vault` singleton (closes #1) +- `WorkloadApiClient#decode_varint`: returns `[value, bytes_consumed]` correctly; previous implementation returned `[value, start_pos]` causing the protobuf field scanner to never advance past the first tag, breaking `extract_proto_field` for any non-empty input +- `WorkloadApiClient#self_signed_fallback`: Subject CN is now a plain string (`legion-fallback-svid`) instead of the full `spiffe://` URI, preventing `TypeError: no implicit conversion of nil into String` from `OpenSSL::X509::Name.parse` on Ruby 3.4 +- `spiffe_identity_helpers_spec.rb`: test cert helper uses plain CN for the same reason + +### Added +- Specs for `kv_client`/`logical_client` routing: 20 examples covering multi-cluster path (cluster client used, global singleton not touched) and single-server fallback path (global singleton used, `vault_client` not called) for `get`, `write`, `exist?`, `delete`, and `read` methods +- SPIFFE/SVID support implementing GitHub issue #8: `Spiffe::WorkloadApiClient` (Unix-domain gRPC for x509/JWT SVIDs with self-signed fallback), `Spiffe::SvidRotation` (background renewal at configurable window), `Spiffe::IdentityHelpers` mixin (sign/verify/extract/trust helpers); wired into `Crypt.start`/`shutdown` behind `spiffe.enabled: false` feature flag +- `spiffe` default settings block with `enabled`, `socket_path`, `trust_domain`, `workload_id`, `renewal_window` +- 82 specs covering SPIFFE ID parsing, SVID lifecycle, Workload API client (with mocked socket), self-signed fallback, protobuf field decoding, signing/verification, SAN extraction, and trust chain validation (closes #8) + +## [1.4.24] - 2026-03-28 + +### Fixed +- `LeaseManager#start`: no longer creates new Vault dynamic credentials when a valid cached lease already exists, preventing orphaned RabbitMQ users on repeated `start` calls (closes #6) +- `LeaseManager#start`: expired leases are now revoked before re-fetching, ensuring clean credential rotation + +### Added +- `LeaseManager#lease_valid?`: returns true when a named lease is cached and its `expires_at` is in the future +- `LeaseManager#revoke_expired_lease`: revokes and clears a stale cached lease entry before a re-fetch +- Specs for repeated `start` idempotency, expired-lease re-fetch, `lease_valid?` edge cases + +## [1.4.23] - 2026-03-27 + +### Fixed +- `connect_vault` now accepts Vault standby responses (429, 472, 473) as healthy, fixing connection failures against performance standby nodes +- `connect_all_clusters` uses the same standby-tolerant health check + +## [1.4.22] - 2026-03-27 + +### Changed +- Replace split `log.error(e.message); log.error(e.backtrace)` patterns with single `Legion::Logging.log_exception` calls in `vault.rb`, `cluster_secret.rb`, and `settings.rb` for structured exception events +- Guard all `log_exception` call sites in `vault.rb`, `settings.rb`, and `cluster_secret.rb` with `Legion::Logging` presence checks (`defined?` in `vault.rb`/`cluster_secret.rb`, `Legion.const_defined?('Logging')` in `settings.rb`) plus `Legion::Logging.respond_to?(:log_exception)`; fall back to `Legion::Logging.fatal`/`error` or `warn` to preserve structured logging in environments where `log_exception` is unavailable +- `from_transport` and `cs` rescue blocks in `cluster_secret.rb` now use the same 4-branch guard (log_exception / Logging.error / Logging.warn / Kernel.warn) and explicitly return `nil` to preserve expected return types +- Fallback `.error`/`.warn`/`Kernel.warn` branches in `from_transport` and `cs` include the first 10 backtrace lines for debuggability parity with the prior `e.backtrace[0..10]` logging; `Vault#connect_vault` warn fallback omits backtrace to keep health-check failure messages concise +- `cs` rescue adds final `Kernel.warn` fallback so exceptions are never silently swallowed when `Legion::Logging` is absent + +### Added +- Specs for `connect_vault` rescue logging: asserts `false` return and covers log_exception / Logging.error / warn fallback branches when `Vault.sys.health_status` raises +- Specs for `from_transport` and `cs` rescue paths: asserts `nil` return and covers all logging fallback branches (including `Kernel.warn`) plus `Legion::Logging` absent case +- Duplicate invocation eliminated in rescue-path specs: single call stored in `result`, both no-raise and return value asserted on that one call + +## [1.4.20] - 2026-03-27 + +### Fixed +- `Vault#read`: unwrap KV v2 response envelope — `logical.read` returns `{data: {keys}, metadata: {}}` for KV v2 mounts; the nested `:data` key is now auto-detected and unwrapped + +### Added +- Debug logging throughout Vault auth, read, and cluster connection paths (`vault.rb`, `vault_cluster.rb`, `kerberos_auth.rb`, `lease_manager.rb`) +- `Vault#log_read_context`: logs path and namespace context for each Vault read +- `Vault#unwrap_kv_v2`: detects and unwraps KV v2 envelope pattern +- `VaultCluster`: debug logging for cluster connection, client build, and Kerberos auth flow +- `KerberosAuth`: debug logging for SPN, token exchange, policies, and renewal metadata +- `LeaseManager`: debug logging for lease fetch, renewal, and revocation + +## [1.4.19] - 2026-03-26 + +### Fixed +- `LeaseManager`, `VaultJwtAuth`, `LdapAuth`, `VaultKerberosAuth`: use `renewable?` instead of `renewable` to match Vault gem API +- `LeaseManager#fetch`: handle string/symbol key mismatch between resolver (strings) and cache (symbols) +- `VaultCluster#connect_all_clusters`: set top-level `vault.connected` flag after any cluster connects via Kerberos/LDAP +- `Vault#add_session`: guard `@sessions` with lazy init to prevent nil error when using cluster-based auth + +## [1.4.18] - 2026-03-26 + +### Fixed +- `KerberosAuth.login`: clear `@kerberos_principal` at the start of each login attempt so a failed re-auth does not leave a stale principal from a previous successful login + +### Added +- `crypt_spec.rb`: delegation spec for `Legion::Crypt.kerberos_principal` +- `kerberos_auth_spec.rb`: spec verifying stale principal is cleared before a failing login attempt + +## [1.4.17] - 2026-03-26 + +### Added +- Store Kerberos principal after successful SPNEGO authentication (`KerberosAuth.kerberos_principal`) +- Expose `Legion::Crypt.kerberos_principal` delegation + +## [1.4.16] - 2026-03-26 + +### Changed +- `KerberosAuth#exchange_token`: removed namespace clear/restore logic — Kerberos auth is now mounted inside the target namespace, client namespace is preserved so the issued token is scoped correctly +- `VaultCluster#connect_kerberos_cluster`: set token on the cached vault_client after Kerberos auth (`vault_client(name).token = result[:token]`) so the memoized client is immediately usable +- `VaultCluster#build_vault_client`: fall back to `Settings[:crypt][:vault][:vault_namespace]` when `config[:namespace]` is absent, guarded with `defined?(Legion::Settings)` +- `TokenRenewer#stop`: revoke the Vault token on shutdown (only for Kerberos auth_method; token-based clusters are not revoked) +- `LeaseManager#start`: accepts optional `vault_client:` keyword argument; stores and routes `logical.read` through it when provided +- `LeaseManager#shutdown`: routes `sys.revoke` through the cluster vault_client when one was supplied +- `LeaseManager#renew_lease`: routes `sys.renew` through the cluster vault_client when one was supplied +- `Crypt#start_lease_manager`: triggers when `connected_clusters.any?` in addition to the single-cluster `vault.connected` flag; passes the default cluster client to the lease manager + +### Added +- `vault_namespace: 'legionio'` default in `Settings.vault` — used as namespace fallback for cluster clients when `config[:namespace]` is not set +- `TokenRenewer#revoke_token` private method: self-revokes the token via `auth_token.revoke_self`, guarded to Kerberos auth_method only + +### Fixed +- `TokenRenewer#stop`: skip token revocation when renewal thread is still alive after join timeout to prevent racy revocation against a running thread; log warning instead +- `Crypt#start_lease_manager`: use `vault_settings[:default]` (matching `VaultCluster#default_cluster_name`) instead of the nonexistent `:default_cluster` key so configured default cluster is honored +- `LeaseManager#start`: always assign `@vault_client` before early return so subsequent `shutdown`/`reset!` calls do not use a stale cluster client; clear `@vault_client` in both `shutdown` and `reset!` + +## [1.4.15] - 2026-03-26 + +### Fixed +- Route `get`, `write`, `read`, `delete`, `exist?` through default cluster client when multi-cluster Vault is configured (#1) +- Previously these methods used the global `::Vault` singleton which was never initialized when clusters were present, causing 403 errors against the wrong Vault server + +## [1.4.14] - 2026-03-26 + +### Fixed +- Vault Kerberos auth: send SPNEGO token as HTTP `Authorization` header instead of JSON body (Vault plugin reads headers, not body) +- Vault Kerberos auth: clear client namespace before auth request (Kerberos mount is at root namespace, not child) +- Vault Kerberos auth: use `Vault::SecretAuth#renewable?` accessor (not `#renewable`) + +## [1.4.13] - 2026-03-25 + +### Added +- Kerberos auto-auth to Vault on boot (`auth_method: 'kerberos'` per cluster) +- `KerberosAuth` module: client-side SPNEGO token acquisition via lex-kerberos, Vault token exchange +- `TokenRenewer`: plain-Thread token lifecycle (renew at 75% TTL, re-auth via Kerberos, exponential backoff 30s-10min) +- `kerberos` settings block in vault cluster config (`service_principal`, `auth_path`) +- `auth_method` dispatch in `connect_all_clusters` (kerberos, ldap, token) + +### Changed +- Token renewal no longer depends on `Extensions::Actors::Every` (starts at boot, not after extensions load) +- Removed actor-dependent renewer guard from `connect_vault` + +## [1.4.12] - 2026-03-25 + +### Fixed +- Ruby 4.0 compatibility: unfreeze `OpenSSL::SSL::SSLContext::DEFAULT_PARAMS` before requiring vault gem (vault 0.18.x mutates this hash in `Vault.setup!`) + +## [1.4.11] - 2026-03-24 + +### Added +- `Legion::Crypt::Mtls` module: Vault PKI cert issuance with `.issue_cert`, `.enabled?`, `.pki_path`, `.local_ip`; feature-flagged via `security.mtls.enabled` +- `Legion::Crypt::CertRotation` class: background cert rotation at 50% TTL boundary with `#start`, `#stop`, `#rotate!`, `#needs_renewal?`; emits `cert.rotated` event via `Legion::Events` + +## [1.4.10] - 2026-03-24 + +### Added +- `Legion::Crypt.delete(path)` for Vault KV path deletion (supports credential revocation on worker termination) + +## [1.4.9] - 2026-03-22 + +### Added +- `Legion::Crypt::Helper` module: injectable Vault mixin for LEX extensions +- Namespaced `vault_get`, `vault_write`, `vault_exist?` with automatic lex-prefixed paths + +## [1.4.8] - 2026-03-22 + +### Changed +- Added logging to all silent rescue blocks across attestation, cluster_secret, ed25519, erasure, jwks_client, ldap_auth, vault_jwt_auth, and vault_kerberos_auth + +## [1.4.7] - 2026-03-22 + +### Added +- Logging across vault, JWT, JWKS, Ed25519, PartitionKeys, Attestation, LdapAuth, VaultJwtAuth, VaultCluster operations +- `vault.rb`: `.info` on Vault connect, `.info` on cluster token renewal, `.debug` on read/write/get paths, `.warn` on read/write/get failures, `.debug` on renewal cycle start/complete +- `jwt.rb`: `.info` on JWT issue (subject, expiry, algorithm), `.debug` on verify success, `.warn` on verify failures (expired, invalid, decode) before raising +- `jwks_client.rb`: `.debug` on JWKS fetch URL, `.debug` on cache hit, `.warn` on fetch failure +- `ed25519.rb`: `.debug` on keypair generation, sign, verify, and Vault store/load paths +- `partition_keys.rb`: `.debug` on key derivation, `.warn` on encrypt/decrypt failures +- `attestation.rb`: `.debug` on attestation create/verify, `.warn` on verification failure +- `ldap_auth.rb`: `.info` on LDAP login success, `.warn` on LDAP login failure +- `vault_jwt_auth.rb`: `.warn` on JWT auth client/server errors in non-bang `login` +- `vault_cluster.rb`: `.info` on successful cluster connect + +## [1.4.6] - 2026-03-21 + +### Fixed +- Vault URL construction: normalize `address` field that contains a full URL with scheme (e.g. `https://host`) instead of just a hostname, preventing malformed `http://https://host:port` addresses + +## [1.4.5] - 2026-03-20 + +### Changed +- Refactored `Legion::Crypt::TLS` to standard `resolve` pattern: pure config normalizer with port auto-detect, vault URI resolution, legacy key migration, and three verification levels (none/peer/mutual) +- Removed consumer-specific `bunny_options` and `sequel_options` methods (moved to consuming gems) + +### Added +- `TLS.resolve(tls_config, port:)` — standard TLS config resolver +- `TLS.migrate_legacy(config)` — backwards-compat mapping for transport's old TLS keys +- `TLS::TLS_PORTS` — known TLS port auto-detection map (5671, 6380, 11207) +- Default `tls:` settings block in `Legion::Crypt::Settings` + +## [1.4.4] - 2026-03-18 + +### Added +- Multi-cluster Vault support: named clusters with `default` pointer in `crypt.vault.clusters` +- `VaultCluster` module: per-cluster `::Vault::Client` management, `connect_all_clusters` +- `LdapAuth` module: LDAP authentication via Vault HTTP API (`auth/ldap/login/:username`) +- `ldap_login_all` authenticates to all LDAP-configured clusters with single credentials +- `VaultRenewer` now renews tokens for all connected clusters +- Backward compatible: single-cluster config (`crypt.vault.address`) still works unchanged + +## [1.4.3] - 2026-03-17 + +### Added +- `Crypt::TLS`: mTLS configuration for RabbitMQ (Bunny) and PostgreSQL (Sequel) connections +- `TLS.ssl_context` builds OpenSSL::SSL::SSLContext with TLS 1.2+ and VERIFY_PEER +- `TLS.bunny_options` and `TLS.sequel_options` generate adapter-specific TLS option hashes +- Configurable cert/key/ca paths via settings with sensible defaults + +## [1.4.2] - 2026-03-16 + +### Added +- `Legion::Crypt::Ed25519`: Ed25519 key generation, signing, verification, Vault key storage +- `Legion::Crypt::PartitionKeys`: HKDF-based per-tenant key derivation with AES-256-GCM encrypt/decrypt +- `Legion::Crypt::Erasure`: cryptographic erasure via Vault master key deletion with event emission +- `Legion::Crypt::Attestation`: signed identity claims with Ed25519 signatures and freshness checking +- Dependency: `ed25519` gem ~> 1.3 + +## [1.4.1] - 2026-03-16 + +### Added +- `Legion::Crypt::MockVault` in-memory Vault mock for local development mode + +## [1.4.0] - 2026-03-16 + +### Added +- `JwksClient` module: fetch, parse, and cache public keys from JWKS endpoints (TTL 3600s, thread-safe) +- `JWT.verify_with_jwks` for RS256 token verification against external identity providers (Entra ID, Bot Framework) +- Multi-issuer support via `issuers:` array parameter +- Audience validation via `audience:` parameter +- `Crypt.verify_external_token` convenience method + +## [1.3.0] - 2026-03-16 + +### Added +- `LeaseManager` singleton for dynamic Vault secret lease management +- Named lease definitions in `crypt.vault.leases` settings +- Boot-time lease fetch with data caching +- Background renewal thread with rotation detection +- Settings push-back on credential rotation via reverse index +- `lease://name#key` URI references resolved by Settings resolver + +## v1.2.1 + +### Fixed +- `validate_hex` and `set_cluster_secret` now handle leading zeros correctly by padding the + base-32 round-trip result back to the original string length. Previously, secrets whose + hex representation started with one or more zero bytes would fail validation and cause + `find_cluster_secret` to return nil non-deterministically. + +### Added +- Comprehensive spec coverage for `Legion::Crypt::VaultJwtAuth` (`.login`, `.login!`, + `.worker_login`, `AuthError`, constants). +- `after` hook in `cluster_secret_spec` to restore `Legion::Settings[:crypt][:cluster_secret]` + between examples, eliminating ordering-dependent state pollution. +- TODO comments in `vault_spec` for tests that require live Vault connectivity. + ## v1.2.0 -Moving from BitBucket to GitHub inside the Optum org. All git history is reset from this point on +Moving from BitBucket to GitHub. All git history is reset from this point on diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1986523 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,216 @@ +# legion-crypt: Encryption and Vault Integration for LegionIO + +**Repository Level 3 Documentation** +- **Parent**: `/Users/miverso2/rubymine/legion/CLAUDE.md` + +## Purpose + +Handles encryption, decryption, secrets management, JWT token management, and HashiCorp Vault connectivity for the LegionIO framework. Provides AES-256-CBC message encryption, RSA key pair generation, cluster secret management, JWT issue/verify operations, and Vault token lifecycle management. + +**GitHub**: https://github.com/LegionIO/legion-crypt +**Version**: 1.5.7 +**License**: Apache-2.0 + +## Architecture + +``` +Legion::Crypt (singleton module) +├── .start # Initialize: generate keys, connect to Vault +├── .encrypt(string) # AES-256-CBC encryption +├── .decrypt(message) # AES-256-CBC decryption +├── .shutdown # Stop Vault renewer, close sessions +│ +├── Cipher # OpenSSL cipher operations (AES-256-CBC) +│ ├── .encrypt # Encrypt with cluster secret +│ ├── .decrypt # Decrypt with cluster secret +│ ├── .private_key # RSA private key (generated or loaded) +│ └── .public_key # RSA public key +│ +├── Vault # HashiCorp Vault integration +│ ├── .connect_vault # Establish Vault session +│ ├── .read(path) # Read secret from Vault +│ ├── .write(path) # Write secret to Vault +│ └── .renew_token # Token renewal +│ +├── JWT # JSON Web Token operations +│ ├── .issue # Create signed JWT (HS256 or RS256) +│ ├── .verify # Verify and decode JWT +│ ├── .verify_with_jwks # Verify RS256 token via external JWKS endpoint (Entra ID, etc.) +│ └── .decode # Decode without verification (inspection) +│ +├── JwksClient # External JWKS endpoint integration (thread-safe) +│ ├── .fetch_keys # Fetch and parse JWKS from a URL +│ ├── .find_key # Lookup key by kid (cache-first, re-fetch on miss) +│ └── .clear_cache # Clear the key cache +│ +├── ClusterSecret # Cluster-wide shared secret management +│ └── .cs # Generate/distribute cluster secret +│ +├── VaultJwtAuth # Vault JWT auth backend integration +│ ├── .login # Authenticate to Vault using a JWT token, returns Vault token hash +│ ├── .login! # Authenticate and set ::Vault.token for subsequent operations +│ └── .worker_login # Issue a Legion JWT and authenticate to Vault in one step +│ +├── VaultRenewer # Background Vault token renewal thread +├── Ed25519 # Ed25519 key generation, signing, verification, Vault storage +├── PartitionKeys # HKDF per-tenant key derivation, AES-256-GCM encrypt/decrypt +├── Erasure # Cryptographic erasure via Vault master key deletion +├── Attestation # Signed identity claims with Ed25519, freshness checking +├── LeaseManager # Dynamic Vault lease lifecycle: fetch, cache, renew, rotate, push-back +├── KerberosAuth # GSSAPI SPNEGO token acquisition; `disable_gssapi_finalizers` prevents GC segfault on macOS +├── VaultKerberosAuth # Vault Kerberos auth: SPNEGO as `Authorization` header, namespace clear/restore, TokenRenewer wiring +├── LdapAuth # Vault LDAP auth backend +├── Tls # TLS settings (cert/key/CA/verify_peer/Vault PKI) +├── Mtls # mTLS cert issuance (Vault PKI) + CertRotation background thread (50% TTL renewal) +├── TokenRenewer # Background renewal thread: 75% TTL renew, Kerberos re-auth on failure, exponential backoff +├── Spiffe # SPIFFE identity support: parse_id, valid_id?, X509Svid, JwtSvid structs; reads security.spiffe settings +├── Spiffe::IdentityHelpers # Mixin for SPIFFE identity operations +├── Spiffe::SvidRotation # Background SVID renewal at 50% TTL +├── Spiffe::WorkloadApiClient # gRPC workload API client for SPIRE agent (unix socket) +├── VaultEntity # Vault entity/alias lifecycle (ensure_entity, ensure_alias, find_by_name); all non-fatal +├── MockVault # In-memory Vault mock for local development mode +├── Settings # Default crypt config +└── Version +``` + +### Key Design Patterns + +- **Dynamic Keys**: By default, generates new RSA key pair per process start (no persistent keys) +- **Cluster Secret**: Shared AES key distributed across Legion nodes for inter-node encrypted communication +- **Vault Conditional**: Vault module is only included if the `vault` gem is available +- **Token Lifecycle**: VaultRenewer runs background thread for automatic token renewal +- **JWT Dual Algorithm**: HS256 (symmetric, cluster secret) for intra-cluster tokens; RS256 (asymmetric, RSA keypair) for tokens verifiable without sharing the signing key +- **JWKS External Validation**: `JwksClient` fetches public keys from external identity provider JWKS endpoints (Entra ID, Bot Framework). Keys cached for 1 hour (CACHE_TTL=3600s), thread-safe via Mutex, automatic re-fetch on cache miss handles key rotation + +## Default Settings + +```json +{ + "vault": { "..." : "see vault settings" }, + "jwt": { + "enabled": true, + "default_algorithm": "HS256", + "default_ttl": 3600, + "issuer": "legion", + "verify_expiration": true, + "verify_issuer": true + }, + "cs_encrypt_ready": false, + "dynamic_keys": true, + "cluster_secret": null, + "save_private_key": true, + "read_private_key": true +} +``` + +## Dependencies + +| Gem | Purpose | +|-----|---------| +| `ed25519` (~> 1.3) | Ed25519 key operations (pure Ruby) | +| `jwt` (>= 2.7) | JSON Web Token encoding/decoding | +| `vault` (>= 0.17) | HashiCorp Vault Ruby client | + +Dev dependencies: `legion-logging`, `legion-settings` + +## File Map + +| Path | Purpose | +|------|---------| +| `lib/legion/crypt.rb` | Module entry, start/shutdown lifecycle | +| `lib/legion/crypt/cipher.rb` | AES-256-CBC encrypt/decrypt, RSA key generation | +| `lib/legion/crypt/jwt.rb` | JWT issue/verify/decode/verify_with_jwks operations | +| `lib/legion/crypt/jwks_client.rb` | JWKS endpoint fetch, parse, cache (thread-safe, 1hr TTL) | +| `lib/legion/crypt/vault.rb` | Vault read/write/connect/renew operations | +| `lib/legion/crypt/cluster_secret.rb` | Cluster-wide shared secret management | +| `lib/legion/crypt/vault_jwt_auth.rb` | Vault JWT auth backend: `.login`, `.login!`, `.worker_login`; raises `AuthError` on failure | +| `lib/legion/crypt/vault_renewer.rb` | Background Vault token renewal | +| `lib/legion/crypt/lease_manager.rb` | Dynamic Vault lease lifecycle management | +| `lib/legion/crypt/settings.rb` | Default configuration | +| `lib/legion/crypt/ed25519.rb` | Ed25519 key generation, signing, verification, Vault storage | +| `lib/legion/crypt/partition_keys.rb` | HKDF per-tenant key derivation with AES-256-GCM | +| `lib/legion/crypt/erasure.rb` | Cryptographic erasure via Vault master key deletion | +| `lib/legion/crypt/attestation.rb` | Signed identity claims with Ed25519 signatures | +| `lib/legion/crypt/kerberos_auth.rb` | GSSAPI/Kerberos token acquisition; `obtain_spnego_token`, `disable_gssapi_finalizers` (prevents GC segfault on macOS) | +| `lib/legion/crypt/vault_kerberos_auth.rb` | Vault Kerberos auth backend: sends SPNEGO token as `Authorization` HTTP header, clears/restores namespace, wires `TokenRenewer` | +| `lib/legion/crypt/ldap_auth.rb` | Vault LDAP auth backend integration | +| `lib/legion/crypt/tls.rb` | TLS settings module (cert, key, CA paths, verify_peer, Vault PKI flag) | +| `lib/legion/crypt/mtls.rb` | mTLS certificate issuance from Vault PKI; `CertRotation` background renewal thread (50% TTL) | +| `lib/legion/crypt/token_renewer.rb` | Plain Thread renewer: renews at 75% TTL, re-auths via Kerberos on failure, exponential backoff | +| `lib/legion/crypt/spiffe.rb` | SPIFFE identity: parse/validate SPIFFE IDs, X509Svid/JwtSvid structs, settings helpers | +| `lib/legion/crypt/spiffe/identity_helpers.rb` | Mixin for SPIFFE identity operations | +| `lib/legion/crypt/spiffe/svid_rotation.rb` | Background SVID renewal thread (50% TTL) | +| `lib/legion/crypt/spiffe/workload_api_client.rb` | gRPC workload API client for SPIRE agent | +| `lib/legion/crypt/vault_entity.rb` | Vault entity/alias lifecycle: `ensure_entity`, `ensure_alias`, `find_by_name`; all operations non-fatal | +| `lib/legion/crypt/version.rb` | VERSION constant | + +## Role in LegionIO + +First service-level module initialized during `Legion::Service` startup (before transport). Provides: +1. Vault token for `legion-transport` to fetch RabbitMQ credentials +2. Message encryption for `legion-transport` (optional `transport.messages.encrypt`) +3. Cluster secret for inter-node encrypted communication +4. JWT tokens for node authentication and task authorization +5. External token verification for identity providers (Entra ID OIDC via JWKS) + +### Vault JWT Auth Usage + +```ruby +# Authenticate to Vault using a JWT (Vault must have JWT auth method enabled) +result = Legion::Crypt::VaultJwtAuth.login(jwt: token, role: 'legion-worker') +# => { token: '...', lease_duration: 3600, renewable: true, policies: [...], metadata: {} } + +# Authenticate and set Vault client token in one step +Legion::Crypt::VaultJwtAuth.login!(jwt: token) + +# Issue a Legion JWT and use it to authenticate to Vault (convenience for workers) +result = Legion::Crypt::VaultJwtAuth.worker_login(worker_id: 'abc', owner_msid: 'user@example.com') +``` + +Vault prerequisites: `vault auth enable jwt` + configure `auth/jwt/config` with JWKS URL or bound issuer. + +### JWT Usage + +```ruby +# Convenience methods (auto-selects keys from settings) +token = Legion::Crypt.issue_token({ node_id: 'abc' }, ttl: 3600) +claims = Legion::Crypt.verify_token(token) + +# Direct module usage (explicit keys) +token = Legion::Crypt::JWT.issue(payload, signing_key: key, algorithm: 'RS256') +claims = Legion::Crypt::JWT.verify(token, verification_key: pub_key, algorithm: 'RS256') +decoded = Legion::Crypt::JWT.decode(token) # no verification, inspection only +``` + +**Algorithms:** +- `HS256` (default): Uses cluster secret. All cluster nodes can issue and verify. +- `RS256`: Uses RSA keypair. Only the issuing node can sign; anyone with the public key can verify. + +### External Token Verification (JWKS) + +Verify tokens from external identity providers using their public JWKS endpoints: + +```ruby +# Convenience method +claims = Legion::Crypt.verify_external_token( + token, + jwks_url: 'https://login.microsoftonline.com/TENANT/discovery/v2.0/keys', + issuers: ['https://login.microsoftonline.com/TENANT/v2.0'], + audience: 'app-client-id' +) + +# Direct module usage +claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url) +``` + +**Flow:** decode JWT header (unverified) to extract `kid` -> `JwksClient.find_key` fetches the matching public key from cache or JWKS endpoint -> verify JWT signature with the public key. + +**Options:** `issuers:` (array, multi-issuer support), `audience:` (string), `verify_expiration:` (bool, default true). + +**Error hierarchy:** `ExpiredTokenError`, `InvalidTokenError` (bad signature, wrong issuer, wrong audience), `DecodeError` (malformed token) — all inherit from `Legion::Crypt::JWT::Error`. + +**Used by:** `lex-identity` Entra runner for Digital Worker OIDC token validation. + +--- + +**Maintained By**: Matthew Iverson (@Esity) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..a9ed84d --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,40 @@ +# Default owner — all files +* @Esity + +# Core library code +# lib/ @Esity @future-security-team + +# Cipher and key management +# lib/legion/crypt/cipher.rb @Esity @future-security-team +# lib/legion/crypt/partition_keys.rb @Esity @future-security-team +# lib/legion/crypt/ed25519.rb @Esity @future-security-team + +# Vault integration +# lib/legion/crypt/vault.rb @Esity @future-security-team +# lib/legion/crypt/vault_jwt_auth.rb @Esity @future-security-team +# lib/legion/crypt/vault_renewer.rb @Esity @future-security-team +# lib/legion/crypt/vault_cluster.rb @Esity @future-security-team +# lib/legion/crypt/lease_manager.rb @Esity @future-security-team + +# JWT and JWKS +# lib/legion/crypt/jwt.rb @Esity @future-security-team +# lib/legion/crypt/jwks_client.rb @Esity @future-security-team + +# Auth integrations +# lib/legion/crypt/ldap_auth.rb @Esity @future-security-team +# lib/legion/crypt/vault_kerberos_auth.rb @Esity @future-security-team + +# Cluster secret +# lib/legion/crypt/cluster_secret.rb @Esity @future-security-team + +# Cryptographic erasure +# lib/legion/crypt/erasure.rb @Esity @future-security-team + +# Specs +# spec/ @Esity @future-contributors + +# Documentation +# *.md @Esity @future-docs-team + +# CI/CD +# .github/ @Esity diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 52c7f95..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,75 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or -advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project email -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at [opensource@optum.com][email]. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ -[email]: mailto:opensource@optum.com \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b0c397d..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,55 +0,0 @@ -# Contribution Guidelines - -Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. Please also review our [Contributor License Agreement ("CLA")](INDIVIDUAL_CONTRIBUTOR_LICENSE.md) prior to submitting changes to the project. You will need to attest to this agreement following the instructions in the [Paperwork for Pull Requests](#paperwork-for-pull-requests) section below. - ---- - -# How to Contribute - -Now that we have the disclaimer out of the way, let's get into how you can be a part of our project. There are many different ways to contribute. - -## Issues - -We track our work using Issues in GitHub. Feel free to open up your own issue to point out areas for improvement or to suggest your own new experiment. If you are comfortable with signing the waiver linked above and contributing code or documentation, grab your own issue and start working. - -## Coding Standards - -We have some general guidelines towards contributing to this project. -Please run RSpec and Rubocop while developing code for LegionIO - -### Languages - -*Ruby* - -## Pull Requests - -If you've gotten as far as reading this section, then thank you for your suggestions. - -## Paperwork for Pull Requests - -* Please read this guide and make sure you agree with our [Contributor License Agreement ("CLA")](INDIVIDUAL_CONTRIBUTOR_LICENSE.md). -* Make sure git knows your name and email address: - ``` - $ git config user.name "J. Random User" - $ git config user.email "j.random.user@example.com" - ``` ->The name and email address must be valid as we cannot accept anonymous contributions. -* Write good commit messages. -> Concise commit messages that describe your changes help us better understand your contributions. -* The first time you open a pull request in this repository, you will see a comment on your PR with a link that will allow you to sign our Contributor License Agreement (CLA) if necessary. -> The link will take you to a page that allows you to view our CLA. You will need to click the `Sign in with GitHub to agree button` and authorize the cla-assistant application to access the email addresses associated with your GitHub account. Agreeing to the CLA is also considered to be an attestation that you either wrote or have the rights to contribute the code. All committers to the PR branch will be required to sign the CLA, but you will only need to sign once. This CLA applies to all repositories in the Optum org. - -## General Guidelines - -Ensure your pull request (PR) adheres to the following guidelines: - -* Try to make the name concise and descriptive. -* Give a good description of the change being made. Since this is very subjective, see the [Updating Your Pull Request (PR)](#updating-your-pull-request-pr) section below for further details. -* Every pull request should be associated with one or more issues. If no issue exists yet, please create your own. -* Make sure that all applicable issues are mentioned somewhere in the PR description. This can be done by typing # to bring up a list of issues. - -### Updating Your Pull Request (PR) - -A lot of times, making a PR adhere to the standards above can be difficult. If the maintainers notice anything that we'd like changed, we'll ask you to edit your PR before we merge it. This applies to both the content documented in the PR and the changed contained within the branch being merged. There's no need to open a new PR. Just edit the existing one. - -[email]: mailto:opensource@optum.com \ No newline at end of file diff --git a/Gemfile b/Gemfile index edaf657..e92660f 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec @@ -6,5 +8,8 @@ group :test do gem 'rspec' gem 'rspec_junit_formatter' gem 'rubocop' + gem 'rubocop-legion' gem 'simplecov' end +gem 'legion-logging' +gem 'legion-settings' diff --git a/INDIVIDUAL_CONTRIBUTOR_LICENSE.md b/INDIVIDUAL_CONTRIBUTOR_LICENSE.md deleted file mode 100644 index 79460dc..0000000 --- a/INDIVIDUAL_CONTRIBUTOR_LICENSE.md +++ /dev/null @@ -1,30 +0,0 @@ -# Individual Contributor License Agreement ("Agreement") V2.0 - -Thank you for your interest in this Optum project (the "PROJECT"). In order to clarify the intellectual property license granted with Contributions from any person or entity, the PROJECT must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of the PROJECT and its users; it does not change your rights to use your own Contributions for any other purpose. - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the PROJECT. In return, the PROJECT shall not use Your Contributions in a way that is inconsistent with stated project goals in effect at the time of the Contribution. Except for the license granted herein to the PROJECT and recipients of software distributed by the PROJECT, You reserve all right, title, and interest in and to Your Contributions. -1. Definitions. - -"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with the PROJECT. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the PROJECT for inclusion in, or documentation of, any of the products owned or managed by the PROJECT (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the PROJECT or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the PROJECT for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. - -Subject to the terms and conditions of this Agreement, You hereby grant to the PROJECT and to recipients of software distributed by the PROJECT a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. - -Subject to the terms and conditions of this Agreement, You hereby grant to the PROJECT and to recipients of software distributed by the PROJECT a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. Representations. - - (a) You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to the PROJECT, or that your employer has executed a separate Corporate CLA with the PROJECT. - - (b) You represent that each of Your Contributions is Your original creation (see section 6 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. - -5. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -6. Should You wish to submit work that is not Your original creation, You may submit it to the PROJECT separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -7. You agree to notify the PROJECT of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 72dfe3b..20cba51 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,6 @@ - Apache License Version 2.0, January 2004 - https://www.apache.org/licenses/ + http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -176,16 +175,27 @@ END OF TERMS AND CONDITIONS - Copyright 2021 Optum + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Esity Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - https://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/NOTICE.txt b/NOTICE.txt deleted file mode 100644 index 50703a1..0000000 --- a/NOTICE.txt +++ /dev/null @@ -1,9 +0,0 @@ - -Copyright 2020 Optum - -Project Description: -==================== - - -Author(s): - \ No newline at end of file diff --git a/README.md b/README.md index 4b76b44..868afb6 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,23 @@ -Legion::Crypt -===== +# legion-crypt -Legion::Crypt is the class responsible for encryption, managing secrets and connecting with Vault +Encryption, secrets management, JWT token management, and HashiCorp Vault integration for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides AES-256-CBC message encryption, RSA key pair generation, cluster secret management, JWT issue/verify operations, Vault token lifecycle management, and multi-cluster Vault connectivity. -Supported Ruby versions and implementations ------------------------------------------------- +**Version**: 1.5.6 -Legion::Crypt should work identically on: - -* JRuby 9.2+ -* Ruby 2.4+ - - -Installation and Usage ------------------------- - -You can verify your installation using this piece of code: +## Installation ```bash gem install legion-crypt ``` +Or add to your Gemfile: + +```ruby +gem 'legion-crypt' +``` + +## Usage + ```ruby require 'legion/crypt' @@ -29,8 +26,43 @@ Legion::Crypt.encrypt('this is my string') Legion::Crypt.decrypt(message) ``` -Settings ----------- +### JWT Tokens + +```ruby +# Issue a token (defaults to HS256 using cluster secret) +token = Legion::Crypt.issue_token({ node_id: 'abc' }, ttl: 3600) + +# Verify and decode a token +claims = Legion::Crypt.verify_token(token) + +# Use RS256 (RSA keypair) instead +token = Legion::Crypt.issue_token({ node_id: 'abc' }, algorithm: 'RS256') +claims = Legion::Crypt.verify_token(token, algorithm: 'RS256') + +# Inspect a token without verification +decoded = Legion::Crypt::JWT.decode(token) +``` + +### External Token Verification (JWKS) + +Verify tokens from external identity providers (Entra ID, Bot Framework) using their public JWKS endpoints: + +```ruby +# Verify an Entra ID OIDC token +claims = Legion::Crypt.verify_external_token( + token, + jwks_url: 'https://login.microsoftonline.com/TENANT/discovery/v2.0/keys', + issuers: ['https://login.microsoftonline.com/TENANT/v2.0'], + audience: 'app-client-id' +) + +# Or use the JWT module directly +claims = Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url) +``` + +Public keys are cached for 1 hour and automatically re-fetched on cache miss (handles key rotation). + +## Configuration ```json { @@ -40,17 +72,160 @@ Settings "address": "localhost", "port": 8200, "token": null, - "connected": false + "connected": false, + "renewer_time": 5, + "renewer": true, + "push_cluster_secret": true, + "read_cluster_secret": true, + "kv_path": "legion", + "leases": {} + }, + "jwt": { + "enabled": true, + "default_algorithm": "HS256", + "default_ttl": 3600, + "issuer": "legion", + "verify_expiration": true, + "verify_issuer": true }, "cs_encrypt_ready": false, "dynamic_keys": true, "cluster_secret": null, - "save_private_key": false, - "read_private_key": false + "save_private_key": true, + "read_private_key": true +} +``` + +### JWT Algorithms + +| Algorithm | Key | Use Case | +|-----------|-----|----------| +| `HS256` (default) | Cluster secret (symmetric) | Intra-cluster tokens — all nodes can issue and verify | +| `RS256` | RSA key pair (asymmetric) | Tokens verifiable by external services without sharing the signing key | + +### Vault Integration + +When `vault.token` is set (or via `VAULT_TOKEN_ID` env var), Crypt connects to Vault on `start`. The background `VaultRenewer` thread keeps the token alive. Vault is an optional runtime dependency — the Vault module is only included if the `vault` gem is available. + +### Dynamic Vault Leases + +The `LeaseManager` handles dynamic secrets from any Vault secrets engine (database, RabbitMQ, AWS, PKI, etc.). Define named leases in crypt settings — each lease maps a stable name to a Vault path: + +```json +{ + "crypt": { + "vault": { + "leases": { + "rabbitmq": { "path": "rabbitmq/creds/legion-role" }, + "bedrock": { "path": "aws/creds/bedrock-role" }, + "postgres": { "path": "database/creds/apollo-rw" } + } + } + } } ``` -Authors ----------- +Other settings files reference lease data using `lease://name#key`: + +```json +{ + "transport": { + "connection": { + "username": "lease://rabbitmq#username", + "password": "lease://rabbitmq#password" + } + } +} +``` + +Both `username` and `password` come from a single Vault read — one lease, one credential pair. The `LeaseManager`: + +- Fetches all leases at boot (during `Crypt.start`, before `resolve_secrets!`) +- Caches response data and lease metadata +- Renews leases in the background at 50% TTL +- Detects credential rotation and pushes new values into `Legion::Settings` in-place +- Revokes all leases on `Crypt.shutdown` + +Lease names are stable across environments. The actual Vault paths are deployment-specific config. + +## Multi-Cluster Vault + +`VaultCluster` supports connecting to multiple Vault clusters simultaneously. Each cluster has its own `::Vault::Client` instance. + +```json +{ + "crypt": { + "vault": { + "default": "primary", + "clusters": { + "primary": { + "protocol": "https", + "address": "vault.example.com", + "port": 8200, + "namespace": "my-namespace", + "auth_method": "ldap" + }, + "secondary": { + "protocol": "https", + "address": "vault2.example.com", + "port": 8200, + "auth_method": "ldap" + } + } + } + } +} +``` + +```ruby +# Authenticate to all LDAP-configured clusters at once +Legion::Crypt.ldap_login_all(username: 'user', password: 'pass') + +# Read from specific cluster +Legion::Crypt.read('secret/data/mykey', cluster: :secondary) + +# Get a Vault client for a specific cluster +client = Legion::Crypt.vault_client(:primary) +``` + +When `clusters` is empty, the legacy single-cluster path is used (backward compatible). + +## Kerberos Authentication + +When `crypt.vault.auth_method` is set to `kerberos`, `Crypt.start` performs Kerberos auto-auth to Vault using `KerberosAuth`: + +```ruby +# Settings +{ + "crypt": { + "vault": { + "auth_method": "kerberos", + "kerberos": { + "service_principal": "HTTP/vault.example.com@REALM", + "auth_path": "auth/kerberos/login" + } + } + } +} +``` + +The SPNEGO token is sent as an HTTP `Authorization` header (not JSON body). The Vault namespace is cleared before auth (Kerberos mount is at root) and restored after. Requires Homebrew MIT Kerberos (`brew install krb5`) on macOS — the system Heimdal library is not compatible. + +`TokenRenewer` keeps the Vault token alive: renews at 75% TTL, re-auths via Kerberos if renewal fails, uses exponential backoff. + +## mTLS + +`Crypt::Mtls` issues mTLS certificates from Vault PKI. `Crypt::CertRotation` runs a background thread renewing certs at 50% TTL. `Transport::Connection::Vault` applies tempfile-based Bunny mTLS. Feature-flagged via `security.mtls.enabled: false`. + +## Requirements + +- Ruby >= 3.4 +- `ed25519` (~> 1.3) +- `jwt` gem (>= 2.7) +- `vault` gem (>= 0.17, optional) +- HashiCorp Vault (optional, for secrets management) +- `gssapi` gem (optional, required for Kerberos auth) + +## License -* [Matthew Iverson](https://github.com/Esity) - current maintainer +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index acc4d53..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Supported Versions -| Version | Supported | -| ------- | ------------------ | -| 1.x.x | :white_check_mark: | - -## Reporting a Vulnerability -To be added diff --git a/attribution.txt b/attribution.txt deleted file mode 100644 index e4c875c..0000000 --- a/attribution.txt +++ /dev/null @@ -1 +0,0 @@ -Add attributions here. \ No newline at end of file diff --git a/legion-crypt.gemspec b/legion-crypt.gemspec index f1bb1e5..f488432 100644 --- a/legion-crypt.gemspec +++ b/legion-crypt.gemspec @@ -6,27 +6,29 @@ Gem::Specification.new do |spec| spec.name = 'legion-crypt' spec.version = Legion::Crypt::VERSION spec.authors = ['Esity'] - spec.email = %w[matthewdiverson@gmail.com ruby@optum.com] + spec.email = ['matthewdiverson@gmail.com'] spec.summary = 'Handles requests for encrypt, decrypting, connecting to Vault, among other things' spec.description = 'A gem used by the LegionIO framework for encryption' - spec.homepage = 'https://github.com/Optum/legion-crypt' + spec.homepage = 'https://github.com/LegionIO/legion-crypt' spec.license = 'Apache-2.0' spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 2.4' + spec.required_ruby_version = '>= 3.4' spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - spec.test_files = spec.files.select { |p| p =~ %r{^test/.*_test.rb} } - spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md] + spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md] spec.metadata = { - 'bug_tracker_uri' => 'https://github.com/Optum/legion-crypt/issues', - 'changelog_uri' => 'https://github.com/Optum/legion-crypt/src/main/CHANGELOG.md', - 'documentation_uri' => 'https://github.com/Optum/legion-crypt', - 'homepage_uri' => 'https://github.com/Optum/LegionIO', - 'source_code_uri' => 'https://github.com/Optum/legion-crypt', - 'wiki_uri' => 'https://github.com/Optum/legion-crypt/wiki' + 'bug_tracker_uri' => 'https://github.com/LegionIO/legion-crypt/issues', + 'changelog_uri' => 'https://github.com/LegionIO/legion-crypt/blob/main/CHANGELOG.md', + 'documentation_uri' => 'https://github.com/LegionIO/legion-crypt', + 'homepage_uri' => 'https://github.com/LegionIO/LegionIO', + 'source_code_uri' => 'https://github.com/LegionIO/legion-crypt', + 'wiki_uri' => 'https://github.com/LegionIO/legion-crypt/wiki', + 'rubygems_mfa_required' => 'true' } - spec.add_dependency 'vault', '>= 0.15.0' - - spec.add_development_dependency 'legion-logging' - spec.add_development_dependency 'legion-settings' + spec.add_dependency 'concurrent-ruby', '~> 1.3' + spec.add_dependency 'ed25519', '~> 1.3' + spec.add_dependency 'jwt', '>= 2.7' + spec.add_dependency 'legion-json', '>= 1.2.0' + spec.add_dependency 'legion-logging', '>= 1.5.0' + spec.add_dependency 'vault', '>= 0.17' end diff --git a/lib/legion/crypt.rb b/lib/legion/crypt.rb index fe72c1e..4da546c 100644 --- a/lib/legion/crypt.rb +++ b/lib/legion/crypt.rb @@ -1,14 +1,40 @@ +# frozen_string_literal: true + require 'openssl' require 'base64' +require 'legion/logging/helper' require 'legion/crypt/version' require 'legion/crypt/settings' require 'legion/crypt/cipher' +require 'legion/crypt/jwt' +require 'legion/crypt/vault_jwt_auth' +require 'legion/crypt/lease_manager' +require 'legion/crypt/vault_cluster' +require 'legion/crypt/ldap_auth' +require 'legion/crypt/token_renewer' +require 'legion/crypt/helper' +require 'legion/crypt/mtls' +require 'legion/crypt/cert_rotation' +require 'legion/crypt/spiffe' +require 'legion/crypt/spiffe/workload_api_client' +require 'legion/crypt/spiffe/svid_rotation' +require 'legion/crypt/spiffe/identity_helpers' module Legion module Crypt + extend Legion::Crypt::VaultCluster + extend Legion::Crypt::LdapAuth + + RMQ_ROLE_MAP = { + agent: 'legionio-infra', + worker: 'legionio-worker', + infra: 'legionio-infra' + }.freeze + class << self attr_reader :sessions + include Legion::Logging::Helper include Legion::Crypt::Cipher unless Gem::Specification.find_by_name('vault').nil? @@ -16,11 +42,53 @@ class << self include Legion::Crypt::Vault end + def vault_settings + Legion::Settings[:crypt][:vault] + end + + def kerberos_principal + KerberosAuth.kerberos_principal + end + + def spiffe_svid + @svid_rotation&.current_svid + end + + def fetch_svid + @workload_client ||= Spiffe::WorkloadApiClient.new + @workload_client.fetch_x509_svid + end + + def fetch_jwt_svid(audience:) + @workload_client ||= Spiffe::WorkloadApiClient.new + @workload_client.fetch_jwt_svid(audience: audience) + end + def start - Legion::Logging.debug 'Legion::Crypt is running start' - ::File.write('./legionio.key', private_key) if settings[:save_private_key] + lifecycle_mutex.synchronize do + if @started + log.info 'Legion::Crypt start ignored because the lifecycle is already running' + return + end + + log.info 'Legion::Crypt startup initiated' + log.debug 'Legion::Crypt start requested' + ::File.write('./legionio.key', private_key) if settings[:save_private_key] + @token_renewers ||= [] - connect_vault unless settings[:vault][:token].nil? + if vault_settings[:clusters]&.any? + log.info "Legion::Crypt connecting #{vault_settings[:clusters].size} Vault cluster(s)" + connect_all_clusters + start_token_renewers + else + log.info 'Legion::Crypt connecting primary Vault client' unless settings[:vault][:token].nil? + connect_vault unless settings[:vault][:token].nil? + end + start_lease_manager + start_svid_rotation + @started = true + log.info 'Legion::Crypt startup completed' + end end def settings @@ -31,9 +99,237 @@ def settings end end + def jwt_settings + settings[:jwt] || Legion::Crypt::Settings.jwt + end + + def dynamic_rmq_creds? + Legion::Settings.dig(:crypt, :vault, :dynamic_rmq_creds) == true + end + + def fetch_bootstrap_rmq_creds + return unless vault_connected? && dynamic_rmq_creds? + + Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default) if defined?(Legion::Transport::Settings) + + response = LeaseManager.instance.vault_logical.read('rabbitmq/creds/legionio-bootstrap') + return unless response&.data + + bootstrap_lease_ttl = Legion::Settings.dig(:crypt, :vault, :bootstrap_lease_ttl).to_i + bootstrap_lease_ttl = 300 if bootstrap_lease_ttl <= 0 + + @bootstrap_lease_id = response.lease_id + @bootstrap_lease_expires = Time.now + [response.lease_duration, bootstrap_lease_ttl].min + + settings = Legion::Settings.loader.settings + settings[:transport] ||= {} + settings[:transport][:connection] ||= {} + conn = settings[:transport][:connection] + username = response.data[:username] || response.data['username'] + password = response.data[:password] || response.data['password'] + + unless username && password + log.warn 'Bootstrap RMQ credential fetch returned nil username or password — skipping settings update' + return + end + + conn[:user] = username + conn[:password] = password + + log.info "Bootstrap RMQ credentials acquired (lease: #{@bootstrap_lease_id[0..7]}...)" + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.fetch_bootstrap_rmq_creds') + log.warn "Bootstrap RMQ credential fetch failed: #{e.message}" + end + + def swap_to_identity_creds(mode:) + return unless vault_connected? && dynamic_rmq_creds? + return if mode == :lite + + role = RMQ_ROLE_MAP.fetch(mode, "legionio-#{mode}") + response = LeaseManager.instance.vault_logical.read("rabbitmq/creds/#{role}") + raise "Failed to fetch identity-scoped RMQ creds for role #{role}" unless response&.data + + LeaseManager.instance.register_dynamic_lease( + name: :rabbitmq, + path: "rabbitmq/creds/#{role}", + response: response, + settings_refs: [ + { path: %i[transport connection user], key: :username }, + { path: %i[transport connection password], key: :password } + ] + ) + + settings = Legion::Settings.loader.settings + settings[:transport] ||= {} + settings[:transport][:connection] ||= {} + conn = settings[:transport][:connection] + username = response.data[:username] || response.data['username'] + password = response.data[:password] || response.data['password'] + raise "Identity-scoped RMQ creds for role #{role} missing username or password" unless username && password + + conn[:user] = username + conn[:password] = password + + if defined?(Legion::Transport::Connection) + Legion::Transport::Connection.force_reconnect + raise 'Transport reconnect failed after credential swap — bootstrap lease NOT revoked' unless Legion::Transport::Connection.session_open? + + log.info "Transport reconnected with identity-scoped creds (role: #{role})" + end + + revoke_bootstrap_lease + end + + def revoke_bootstrap_lease + return unless @bootstrap_lease_id + + LeaseManager.instance.vault_sys.revoke(@bootstrap_lease_id) + log.info "Bootstrap RMQ lease revoked (#{@bootstrap_lease_id[0..7]}...)" + @bootstrap_lease_id = nil + @bootstrap_lease_expires = nil + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.revoke_bootstrap_lease') + log.warn "Bootstrap lease revocation failed: #{e.message} — lease will expire naturally" + @bootstrap_lease_id = nil + @bootstrap_lease_expires = nil + end + + def vault_connected? + return true if settings.dig(:vault, :connected) == true + return true if respond_to?(:connected_clusters) && connected_clusters.any? + + false + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.vault_connected?') + false + end + + def issue_token(payload = {}, ttl: nil, algorithm: nil) + jwt = jwt_settings + algo = algorithm || jwt[:default_algorithm] + token_ttl = ttl || jwt[:default_ttl] + + signing_key = algo == 'RS256' ? private_key : settings[:cluster_secret] + + Legion::Crypt::JWT.issue(payload, signing_key: signing_key, algorithm: algo, ttl: token_ttl, + issuer: jwt[:issuer]) + end + + def verify_token(token, algorithm: nil) + jwt = jwt_settings + algo = algorithm || jwt[:default_algorithm] + + verification_key = algo == 'RS256' ? OpenSSL::PKey::RSA.new(public_key) : settings[:cluster_secret] + + Legion::Crypt::JWT.verify(token, verification_key: verification_key, algorithm: algo, + verify_expiration: jwt[:verify_expiration], + verify_issuer: jwt[:verify_issuer], + issuer: jwt[:issuer]) + end + + def verify_external_token(token, jwks_url:, **) + Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url, **) + end + def shutdown - shutdown_renewer - close_sessions + lifecycle_mutex.synchronize do + unless @started + log.info 'Legion::Crypt shutdown ignored because the lifecycle is not running' + return + end + + log.info 'Legion::Crypt shutdown initiated' + Legion::Crypt::LeaseManager.instance.shutdown + stop_token_renewers + shutdown_renewer + close_sessions + stop_svid_rotation + @started = false + log.info 'Legion::Crypt shutdown completed' + end + end + + private + + def start_lease_manager + leases = settings.dig(:vault, :leases) || {} + vault_ok = connected_clusters.any? || settings.dig(:vault, :connected) + + return if leases.empty? && !dynamic_rmq_creds? + return unless vault_ok + + client = nil + + if connected_clusters.any? + selected_cluster = selected_connected_cluster_name + client = selected_cluster ? vault_client(selected_cluster) : nil + elsif settings.dig(:vault, :connected) + client = vault_client + end + + lease_manager = Legion::Crypt::LeaseManager.instance + lease_manager.start(leases, vault_client: client) + lease_manager.start_renewal_thread + + unless leases.empty? + fetched = lease_manager.fetched_count + defined = leases.size + if fetched == defined + log.info "LeaseManager: #{fetched} lease(s) initialized" + else + log.warn "LeaseManager: #{fetched}/#{defined} lease(s) initialized (#{defined - fetched} failed)" + end + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.start_lease_manager') + end + + def start_token_renewers + started = 0 + clusters.each do |name, config| + next unless config[:auth_method]&.to_s == 'kerberos' && config[:connected] + + renewer = Legion::Crypt::TokenRenewer.new( + cluster_name: name, + config: config, + vault_client: vault_client(name) + ) + renewer.start + @token_renewers << renewer + started += 1 + end + log.info "Legion::Crypt started #{started} token renewer(s)" if started.positive? + end + + def stop_token_renewers + return unless @token_renewers + + @token_renewers.each(&:stop) + log.info "Legion::Crypt stopped #{@token_renewers.size} token renewer(s)" if @token_renewers.any? + @token_renewers.clear + end + + def start_svid_rotation + return unless Spiffe.enabled? + + log.info 'Legion::Crypt starting SPIFFE SVID rotation' + @svid_rotation = Spiffe::SvidRotation.new + @svid_rotation.start + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.start_svid_rotation') + end + + def stop_svid_rotation + return unless @svid_rotation + + log.info 'Legion::Crypt stopping SPIFFE SVID rotation' + @svid_rotation.stop + @svid_rotation = nil + end + + def lifecycle_mutex + @lifecycle_mutex ||= Mutex.new end end end diff --git a/lib/legion/crypt/attestation.rb b/lib/legion/crypt/attestation.rb new file mode 100644 index 0000000..01bca0d --- /dev/null +++ b/lib/legion/crypt/attestation.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'legion/logging/helper' + +module Legion + module Crypt + module Attestation + extend Legion::Logging::Helper + + class << self + def create(agent_id:, capabilities:, state:, private_key:) + claim = { + agent_id: agent_id, + capabilities: Array(capabilities), + state: state.to_s, + timestamp: Time.now.utc.iso8601, + nonce: SecureRandom.hex(16) + } + + payload = Legion::JSON.dump(claim) + signature = Legion::Crypt::Ed25519.sign(payload, private_key) + log.info "Attestation created for agent #{agent_id}, state=#{state}" + + { claim: claim, signature: signature.unpack1('H*'), payload: payload } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.attestation.create', agent_id: agent_id, state: state) + raise + end + + def verify(claim_hash:, signature_hex:, public_key:) + payload = Legion::JSON.dump(claim_hash) + signature = [signature_hex].pack('H*') + result = Legion::Crypt::Ed25519.verify(payload, signature, public_key) + agent_id = claim_hash[:agent_id] || claim_hash['agent_id'] + if result + log.info "Attestation verified for agent #{agent_id}" + else + log.warn "Attestation verification failed for agent #{agent_id}" + end + result + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.attestation.verify', + agent_id: claim_hash[:agent_id] || claim_hash['agent_id']) + raise + end + + def fresh?(claim_hash, max_age_seconds: 300) + timestamp = Time.parse(claim_hash[:timestamp]) + Time.now.utc - timestamp < max_age_seconds + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.attestation.fresh?', + agent_id: claim_hash[:agent_id] || claim_hash['agent_id']) + false + end + end + end + end +end diff --git a/lib/legion/crypt/cert_rotation.rb b/lib/legion/crypt/cert_rotation.rb new file mode 100644 index 0000000..f1ee25b --- /dev/null +++ b/lib/legion/crypt/cert_rotation.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + class CertRotation + include Legion::Logging::Helper + + DEFAULT_CHECK_INTERVAL = 43_200 # 12 hours + + attr_reader :check_interval, :current_cert, :issued_at + + def initialize(check_interval: DEFAULT_CHECK_INTERVAL) + @check_interval = check_interval + @current_cert = nil + @issued_at = nil + @running = false + @thread = nil + @mutex = Mutex.new + end + + def start + return unless Legion::Crypt::Mtls.enabled? + return if running? + + @running = true + @thread = Thread.new { rotation_loop } + log.info('[mTLS] CertRotation started') + end + + def stop + @running = false + begin + @thread&.wakeup + rescue ThreadError => e + handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.stop') + nil + end + @thread&.join(2) + if @thread&.alive? + log.warn '[mTLS] CertRotation thread did not stop within timeout' + else + @thread = nil + end + log.info('[mTLS] CertRotation stopped') + end + + def running? + (@running && @thread&.alive?) || false + end + + def rotate! + node_name = node_common_name + new_cert = Legion::Crypt::Mtls.issue_cert(common_name: node_name) + @mutex.synchronize do + @current_cert = new_cert + @issued_at = Time.now + end + log.info("[mTLS] Certificate rotated: serial=#{new_cert[:serial]} expiry=#{new_cert[:expiry]}") + emit_rotated_event(new_cert) + new_cert + end + + def needs_renewal? + current_cert = nil + issued_at = nil + @mutex.synchronize do + current_cert = @current_cert + issued_at = @issued_at + end + return false if current_cert.nil? || issued_at.nil? + + expiry = current_cert[:expiry] + total = expiry - issued_at + return true if total <= 0 + + remaining = expiry - Time.now + fraction = remaining / total + fraction < renewal_window + end + + private + + def rotation_loop + rotate! + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cert_rotation.rotation_loop') + log.error("[mTLS] Initial rotation failed: #{e.message}") + ensure + loop_check + end + + def loop_check + while @running + interruptible_sleep(@check_interval) + next unless @running && needs_renewal? + + begin + rotate! + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cert_rotation.loop_check') + log.error("[mTLS] Rotation check failed: #{e.message}") + end + end + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cert_rotation.loop_check') + log.error("[mTLS] CertRotation loop error: #{e.message}") + retry if @running + end + + def interruptible_sleep(seconds) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds + loop do + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + break if remaining <= 0 || !@running + + sleep([remaining, 1.0].min) + end + end + + def renewal_window + return 0.5 unless defined?(Legion::Settings) + + security = Legion::Settings[:security] + return 0.5 if security.nil? + + mtls = security[:mtls] || security['mtls'] || {} + mtls[:renewal_window] || mtls['renewal_window'] || 0.5 + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.renewal_window') + 0.5 + end + + def node_common_name + return 'legion.internal' unless defined?(Legion::Settings) + + name = Legion::Settings[:client]&.dig(:name) || Legion::Settings[:client]&.dig('name') + name || 'legion.internal' + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.cert_rotation.node_common_name') + 'legion.internal' + end + + def emit_rotated_event(cert) + return unless defined?(Legion::Events) + + Legion::Events.emit('cert.rotated', serial: cert[:serial], expiry: cert[:expiry]) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.cert_rotation.emit_rotated_event') + log.warn("[mTLS] Event emit failed: #{e.message}") + end + end + end +end diff --git a/lib/legion/crypt/cipher.rb b/lib/legion/crypt/cipher.rb index 256c7fa..9741095 100644 --- a/lib/legion/crypt/cipher.rb +++ b/lib/legion/crypt/cipher.rb @@ -1,41 +1,78 @@ +# frozen_string_literal: true + require 'securerandom' +require 'legion/logging/helper' require 'legion/crypt/cluster_secret' module Legion module Crypt module Cipher + AUTHENTICATED_CIPHER = 'aes-256-gcm' + LEGACY_CIPHER = 'aes-256-cbc' + AUTHENTICATED_PREFIX = 'gcm' + RSA_OAEP_PREFIX = 'oaep' + RSA_OAEP_PADDING = OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING + RSA_LEGACY_PADDING = OpenSSL::PKey::RSA::PKCS1_PADDING + include Legion::Crypt::ClusterSecret + include Legion::Logging::Helper def encrypt(message) - cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher = OpenSSL::Cipher.new(AUTHENTICATED_CIPHER) cipher.encrypt cipher.key = cs iv = cipher.random_iv - { enciphered_message: Base64.encode64(cipher.update(message) + cipher.final), iv: Base64.encode64(iv) } + ciphertext = cipher.update(message) + cipher.final + encoded_ciphertext = Base64.strict_encode64(ciphertext) + encoded_auth_tag = Base64.strict_encode64(cipher.auth_tag) + result = { + enciphered_message: "#{AUTHENTICATED_PREFIX}:#{encoded_ciphertext}:#{encoded_auth_tag}", + iv: Base64.strict_encode64(iv) + } + log.debug 'Cipher encrypt completed' + result + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cipher.encrypt') + raise end - def decrypt(message, iv) - until cs.is_a?(String) || Legion::Settings[:client][:shutting_down] - Legion::Logging.debug('sleeping Legion::Crypt.decrypt due to CS not being set') - sleep(0.5) - end - - decipher = OpenSSL::Cipher.new('aes-256-cbc') - decipher.decrypt - decipher.key = cs - decipher.iv = Base64.decode64(iv) - message = Base64.decode64(message) - decipher.update(message) + decipher.final + def decrypt(message, init_vector) + secret = wait_for_cluster_secret + result = if authenticated_ciphertext?(message) + decrypt_authenticated(message, init_vector, secret) + else + decrypt_legacy(message, init_vector, secret) + end + log.debug 'Cipher decrypt completed' + result + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cipher.decrypt') + raise end def encrypt_from_keypair(message:, pub_key: public_key) rsa_public_key = OpenSSL::PKey::RSA.new(pub_key) - Base64.encode64(rsa_public_key.public_encrypt(message)) + encrypted_message = rsa_public_key.public_encrypt(message, RSA_OAEP_PADDING) + encoded_message = "#{RSA_OAEP_PREFIX}:#{Base64.strict_encode64(encrypted_message)}" + log.debug 'Cipher keypair encryption completed' + encoded_message + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cipher.encrypt_from_keypair') + raise end def decrypt_from_keypair(message:, **_opts) - private_key.private_decrypt(Base64.decode64(message)) + decrypted_message = if rsa_oaep_ciphertext?(message) + decrypt_oaep_from_keypair(message) + else + decrypt_legacy_from_keypair(message) + end + log.debug 'Cipher keypair decryption completed' + decrypted_message + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cipher.decrypt_from_keypair') + raise end def public_key @@ -44,10 +81,133 @@ def public_key def private_key @private_key ||= if Legion::Settings[:crypt][:read_private_key] && File.exist?('./legionio.key') + log.info 'Cipher loading RSA private key from disk' OpenSSL::PKey::RSA.new File.read './legionio.key' else + log.info 'Cipher generating RSA private key' OpenSSL::PKey::RSA.new 2048 end + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.cipher.private_key') + raise + end + + private + + def wait_for_cluster_secret + loop do + secret = cs + return secret if secret.is_a?(String) + break if Legion::Settings[:client][:shutting_down] + + log.debug('sleeping Legion::Crypt.decrypt due to CS not being set') + sleep(0.5) + end + + cs + end + + def authenticated_ciphertext?(message) + message.start_with?("#{AUTHENTICATED_PREFIX}:") + end + + def decrypt_authenticated(message, init_vector, secret) + _, encoded_ciphertext, encoded_auth_tag = message.split(':', 3) + validate_authenticated_ciphertext!( + encoded_ciphertext: encoded_ciphertext, + encoded_auth_tag: encoded_auth_tag, + init_vector: init_vector, + secret: secret, + message: message + ) + + decipher = OpenSSL::Cipher.new(AUTHENTICATED_CIPHER) + decipher.decrypt + decipher.key = secret + decipher.iv = Base64.strict_decode64(init_vector) + decipher.auth_tag = Base64.strict_decode64(encoded_auth_tag) + decipher.update(Base64.strict_decode64(encoded_ciphertext)) + decipher.final + end + + def decrypt_legacy(message, init_vector, secret) + validate_legacy_ciphertext!(message: message, init_vector: init_vector, secret: secret) + + decipher = OpenSSL::Cipher.new(LEGACY_CIPHER) + decipher.decrypt + decipher.key = secret + decipher.iv = Base64.decode64(init_vector) + decipher.update(Base64.decode64(message)) + decipher.final + end + + def rsa_oaep_ciphertext?(message) + message.start_with?("#{RSA_OAEP_PREFIX}:") + end + + def decrypt_oaep_from_keypair(message) + _, encoded_message = message.split(':', 2) + validate_keypair_ciphertext!(encoded_message: encoded_message, message: message, scheme: RSA_OAEP_PREFIX) + + private_key.private_decrypt(Base64.strict_decode64(encoded_message), RSA_OAEP_PADDING) + end + + def decrypt_legacy_from_keypair(message) + validate_keypair_ciphertext!(encoded_message: message, message: message, scheme: 'legacy') + + private_key.private_decrypt(Base64.decode64(message), RSA_LEGACY_PADDING) + end + + def validate_authenticated_ciphertext!(encoded_ciphertext:, encoded_auth_tag:, init_vector:, secret:, message:) + missing = [] + missing << 'ciphertext' if blank?(encoded_ciphertext) + missing << 'auth_tag' if blank?(encoded_auth_tag) + missing << 'iv' if blank?(init_vector) + missing << 'cluster_secret' if blank?(secret) + return if missing.empty? + + raise ArgumentError, 'invalid authenticated ciphertext: missing ' \ + "#{missing.join(', ')} " \ + "(scheme=#{AUTHENTICATED_PREFIX} " \ + "message_bytes=#{byte_size(message)} " \ + "ciphertext_present=#{present?(encoded_ciphertext)} " \ + "auth_tag_present=#{present?(encoded_auth_tag)} " \ + "iv_present=#{present?(init_vector)} " \ + "cluster_secret_present=#{present?(secret)})" + end + + def validate_legacy_ciphertext!(message:, init_vector:, secret:) + missing = [] + missing << 'ciphertext' if blank?(message) + missing << 'iv' if blank?(init_vector) + missing << 'cluster_secret' if blank?(secret) + return if missing.empty? + + raise ArgumentError, 'invalid legacy ciphertext: missing ' \ + "#{missing.join(', ')} " \ + "(message_bytes=#{byte_size(message)} " \ + "iv_present=#{present?(init_vector)} " \ + "cluster_secret_present=#{present?(secret)})" + end + + def validate_keypair_ciphertext!(encoded_message:, message:, scheme:) + return unless blank?(encoded_message) + + raise ArgumentError, 'invalid keypair ciphertext: missing ciphertext ' \ + "(scheme=#{scheme} message_bytes=#{byte_size(message)})" + end + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + + def present?(value) + !blank?(value) + end + + def byte_size(value) + return 0 if value.nil? + return value.bytesize if value.respond_to?(:bytesize) + + value.to_s.bytesize end end end diff --git a/lib/legion/crypt/cluster_secret.rb b/lib/legion/crypt/cluster_secret.rb index 11a56ac..de79efc 100644 --- a/lib/legion/crypt/cluster_secret.rb +++ b/lib/legion/crypt/cluster_secret.rb @@ -1,15 +1,20 @@ +# frozen_string_literal: true + require 'securerandom' +require 'legion/logging/helper' module Legion module Crypt module ClusterSecret + include Legion::Logging::Helper + def find_cluster_secret %i[from_settings from_vault from_transport generate_secure_random].each do |method| result = send(method) next if result.nil? unless validate_hex(result) - Legion::Logging.warn("Legion::Crypt.#{method} gave a value but it isn't a valid hex") + log.warn("Legion::Crypt.#{method} gave a value but it isn't a valid hex") next end @@ -20,17 +25,22 @@ def find_cluster_secret key = generate_secure_random set_cluster_secret(key) + log.info 'Cluster secret generated locally because this node is the only member' key end def from_vault - return nil unless method_defined? :get + return nil unless Legion::Crypt.respond_to?(:get) && Legion::Crypt.respond_to?(:exist?) return nil unless Legion::Settings[:crypt][:vault][:read_cluster_secret] return nil unless Legion::Settings[:crypt][:vault][:connected] - return nil unless Legion::Crypt.exist?('crypt') + return nil unless Legion::Crypt.exist?(cluster_secret_vault_path) + + data = Legion::Crypt.get(cluster_secret_vault_path) + return nil unless data.is_a?(Hash) - get('crypt')[:cluster_secret] - rescue StandardError + data[:cluster_secret] || data['cluster_secret'] + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.from_vault') nil end @@ -39,12 +49,12 @@ def from_settings end alias cluster_secret from_settings - def from_transport # rubocop:disable Metrics/AbcSize + def from_transport return nil unless Legion::Settings[:transport][:connected] require 'legion/transport/messages/request_cluster_secret' - Legion::Logging.info 'Requesting cluster secret via public key' - Legion::Logging.warn 'cluster_secret already set but we are requesting a new value' unless from_settings.nil? + log.info 'Requesting cluster secret via public key' + log.warn 'cluster_secret already set but we are requesting a new value' unless from_settings.nil? start = Time.now Legion::Transport::Messages::RequestClusterSecret.new.publish sleep_time = 0.001 @@ -54,44 +64,53 @@ def from_transport # rubocop:disable Metrics/AbcSize end unless from_settings.nil? - Legion::Logging.info "Received cluster secret in #{((Time.new - start) * 1000.0).round}ms" - from_settings + log.info "Received cluster secret in #{((Time.new - start) * 1000.0).round}ms" + return from_settings end - Legion::Logging.error 'Cluster secret is still unknown!' + log.error 'Cluster secret is still unknown!' + nil rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace[0..10] + handle_exception(e, level: :error, operation: 'crypt.cluster_secret.from_transport') + nil end def force_cluster_secret - Legion::Settings[:crypt][:force_cluster_secret] || true + Legion::Settings[:crypt].fetch(:force_cluster_secret, false) end def settings_push_vault - Legion::Settings[:crypt][:vault][:push_cs_to_vault] || true + vault_settings = Legion::Settings[:crypt][:vault] + return vault_settings[:push_cluster_secret] unless vault_settings[:push_cluster_secret].nil? + + vault_settings.fetch(:push_cs_to_vault, false) end def only_member? Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.zero? - rescue StandardError + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.only_member?') nil end def set_cluster_secret(value, push_to_vault = true) # rubocop:disable Style/OptionalBooleanParameter - raise TypeError unless value.to_i(32).to_s(32) == value.downcase + raise TypeError unless validate_hex(value) + Legion::Settings[:crypt][:cluster_secret] = value + @cs = nil Legion::Settings[:crypt][:cs_encrypt_ready] = true push_cs_to_vault if push_to_vault && settings_push_vault - - Legion::Settings[:crypt][:cluster_secret] = value + log.info "Cluster secret loaded into settings push_to_vault=#{push_to_vault}" end def push_cs_to_vault return false unless Legion::Settings[:crypt][:vault][:connected] && Legion::Settings[:crypt][:cluster_secret] - Legion::Logging.info 'Pushing Cluster Secret to Vault' - Legion::Crypt.write('cluster', secret: Legion::Settings[:crypt][:cluster_secret]) + log.info 'Pushing Cluster Secret to Vault' + Legion::Crypt.write(cluster_secret_vault_path, cluster_secret: Legion::Settings[:crypt][:cluster_secret]) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.push_cs_to_vault') + false end def cluster_secret_timeout @@ -99,7 +118,7 @@ def cluster_secret_timeout end def secret_length - Legion::Settings[:crypt][:cluster_lenth] || 32 + Legion::Settings[:crypt][:cluster_length] || Legion::Settings[:crypt][:cluster_lenth] || 32 end def generate_secure_random(length = secret_length) @@ -109,12 +128,23 @@ def generate_secure_random(length = secret_length) def cs @cs ||= Digest::SHA256.digest(find_cluster_secret) rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace[0..10] + handle_exception(e, level: :error, operation: 'crypt.cluster_secret.cs') + nil end def validate_hex(value, length = secret_length) - value.to_i(length).to_s(length) == value.downcase + return false unless value.is_a?(String) + return false if value.empty? + return false unless value.match?(/\A\h+\z/) + + expected_length = length.to_i * 2 + return true if expected_length.zero? + + value.length == expected_length + end + + def cluster_secret_vault_path + 'crypt' end end end diff --git a/lib/legion/crypt/ed25519.rb b/lib/legion/crypt/ed25519.rb new file mode 100644 index 0000000..d5eefb6 --- /dev/null +++ b/lib/legion/crypt/ed25519.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'ed25519' +require 'legion/logging/helper' + +module Legion + module Crypt + module Ed25519 + extend Legion::Logging::Helper + + class << self + def generate_keypair + signing_key = ::Ed25519::SigningKey.generate + log.info 'Ed25519 keypair generated' + { + private_key: signing_key.to_bytes, + public_key: signing_key.verify_key.to_bytes, + public_key_hex: signing_key.verify_key.to_bytes.unpack1('H*') + } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.ed25519.generate_keypair') + raise + end + + def sign(message, private_key_bytes) + signing_key = ::Ed25519::SigningKey.new(private_key_bytes) + result = signing_key.sign(message) + log.debug 'Ed25519 sign complete' + result + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.ed25519.sign') + raise + end + + def verify(message, signature, public_key_bytes) + verify_key = ::Ed25519::VerifyKey.new(public_key_bytes) + verify_key.verify(signature, message) + log.debug 'Ed25519 verify success' + true + rescue ::Ed25519::VerifyError => e + handle_exception(e, level: :debug, operation: 'crypt.ed25519.verify.signature_mismatch') + log.warn 'Ed25519 signature verification failed' + false + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.ed25519.verify') + raise + end + + def store_keypair(agent_id:, keypair: nil) + keypair ||= generate_keypair + if Legion::Crypt.respond_to?(:write) + log.info "Ed25519 storing keypair for agent #{agent_id}" + Legion::Crypt.write( + vault_key_path(agent_id), + private_key: keypair[:private_key].unpack1('H*'), + public_key: keypair[:public_key_hex] + ) + else + log.warn "Ed25519 keypair generated for agent #{agent_id} but Vault is unavailable" + end + keypair + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.ed25519.store_keypair', agent_id: agent_id) + raise + end + + def load_private_key(agent_id:) + log.debug "Ed25519 loading private key for agent #{agent_id}" + return nil unless Legion::Crypt.respond_to?(:get) + + data = Legion::Crypt.get(vault_key_path(agent_id)) + if data&.dig(:private_key) + log.info "Ed25519 private key loaded for agent #{agent_id}" + [data[:private_key]].pack('H*') + else + log.warn "Ed25519 private key missing for agent #{agent_id}" + nil + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.ed25519.load_private_key', agent_id: agent_id) + nil + end + + private + + def key_prefix + begin + Legion::Settings[:crypt][:ed25519][:vault_key_prefix] + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.ed25519.key_prefix') + nil + end || 'keys' + end + + def vault_key_path(agent_id) + normalize_kv_path("#{key_prefix}/#{agent_id}") + end + + def normalize_kv_path(path) + kv_path = Legion::Settings.dig(:crypt, :vault, :kv_path) + return path if kv_path.nil? || kv_path.empty? + + normalized = path.sub(%r{\Asecret/data/#{Regexp.escape(kv_path)}/}, '') + normalized.sub(%r{\A#{Regexp.escape(kv_path)}/}, '') + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.ed25519.normalize_kv_path') + path + end + end + end + end +end diff --git a/lib/legion/crypt/erasure.rb b/lib/legion/crypt/erasure.rb new file mode 100644 index 0000000..91bbeb3 --- /dev/null +++ b/lib/legion/crypt/erasure.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module Erasure + extend Legion::Logging::Helper + + class << self + def erase_tenant(tenant_id:) + key_path = "#{tenant_prefix}/#{tenant_id}/master_key" + + log.info "[crypt] Erasing tenant #{tenant_id}" + if Legion::Crypt.respond_to?(:delete) + Legion::Crypt.delete(key_path) + elsif defined?(Legion::Crypt::Vault) + delete_vault_key(key_path) + end + Legion::Events.emit('crypt.tenant_erased', { tenant_id: tenant_id, erased_at: Time.now.utc }) if defined?(Legion::Events) + log.warn "[crypt] Tenant #{tenant_id} cryptographically erased" + + { erased: true, tenant_id: tenant_id, path: key_path } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.erasure.erase_tenant', tenant_id: tenant_id) + { erased: false, tenant_id: tenant_id, error: e.message } + end + + def verify_erasure(tenant_id:) + key_path = "#{tenant_prefix}/#{tenant_id}/master_key" + raise 'Legion::Crypt.read is unavailable' unless Legion::Crypt.respond_to?(:read) + + data = Legion::Crypt.read(key_path, nil) + erased = data.nil? + log.info "Tenant erasure verification completed for #{tenant_id}: erased=#{erased}" + { erased: erased, tenant_id: tenant_id } + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.erasure.verify_erasure', tenant_id: tenant_id) + { erased: false, tenant_id: tenant_id, error: e.message } + end + + private + + def delete_vault_key(path) + ::Vault.logical.delete(path) + end + + def tenant_prefix + begin + Legion::Settings[:crypt][:partition_keys][:vault_tenant_prefix] + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.erasure.tenant_prefix') + nil + end || 'secret/data/legion/tenants' + end + end + end + end +end diff --git a/lib/legion/crypt/helper.rb b/lib/legion/crypt/helper.rb new file mode 100644 index 0000000..7618861 --- /dev/null +++ b/lib/legion/crypt/helper.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Legion + module Crypt + module Helper + def vault_namespace + @vault_namespace ||= derive_vault_namespace + end + + def vault_get(path = nil) + Legion::Crypt.get(vault_path(path)) + end + + def vault_write(path, **data) + Legion::Crypt.write(vault_path(path), **data) + end + + def vault_exist?(path = nil) + Legion::Crypt.exist?(vault_path(path)) + end + + private + + def vault_path(suffix = nil) + base = vault_namespace + suffix ? "#{base}/#{suffix}" : base + end + + def derive_vault_namespace + if respond_to?(:lex_filename) + fname = lex_filename + fname.is_a?(Array) ? fname.first : fname + else + derive_vault_namespace_from_class + end + end + + def derive_vault_namespace_from_class + name = respond_to?(:ancestors) ? ancestors.first.to_s : self.class.to_s + parts = name.split('::') + ext_idx = parts.index('Extensions') + target = if ext_idx && parts[ext_idx + 1] + parts[ext_idx + 1] + else + parts.last + end + target.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end + end + end +end diff --git a/lib/legion/crypt/jwks_client.rb b/lib/legion/crypt/jwks_client.rb new file mode 100644 index 0000000..475c180 --- /dev/null +++ b/lib/legion/crypt/jwks_client.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' +require 'openssl' +require 'jwt' +require 'legion/logging/helper' +require 'concurrent' + +module Legion + module Crypt + module JwksClient + CACHE_TTL = 3600 + + @cache = {} + @cache_mutex = Mutex.new + @locks = {} + @locks_mutex = Mutex.new + + class << self + include Legion::Logging::Helper + + def fetch_keys(jwks_url) + with_url_lock(jwks_url) do + log.debug "JWKS fetch: #{jwks_url}" + response = http_get(jwks_url) + jwks_data = parse_response(response) + keys = parse_jwks(jwks_data) + + cache_write(jwks_url, keys) + log.info "JWKS fetched url=#{jwks_url} keys=#{keys.size}" + keys + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.jwks.fetch_keys', jwks_url: jwks_url) + raise + end + + def find_key(jwks_url, kid) + cached = cache_read(jwks_url) + + if cached && !expired?(cached[:fetched_at]) + key = cached[:keys][kid] + if key + log.debug "JWKS cache hit: kid=#{kid}" + return key + end + + log.debug "JWKS cache miss for kid=#{kid}; refreshing keys" + end + + keys = fetch_keys(jwks_url) + key = keys[kid] + return key if key + + raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}" + end + + def prefetch!(jwks_url) + Thread.new do + fetch_keys(jwks_url) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.jwks.prefetch', jwks_url: jwks_url) if respond_to?(:handle_exception) + log.debug "JWKS prefetch failed for #{jwks_url}: #{e.message}" if respond_to?(:log) + end + end + + def start_background_refresh!(jwks_url, interval: CACHE_TTL) + stop_background_refresh! + + @refresh_task = Concurrent::TimerTask.new(execution_interval: interval, run_now: false) do + fetch_keys(jwks_url) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.jwks.background_refresh', jwks_url: jwks_url) if respond_to?(:handle_exception) + log.debug "JWKS background refresh failed: #{e.message}" if respond_to?(:log) + end + @refresh_task.execute + log.info "JWKS background refresh started (interval=#{interval}s)" if respond_to?(:log) + end + + def stop_background_refresh! + @refresh_task&.shutdown + @refresh_task = nil + end + + def clear_cache + stop_background_refresh! + @cache_mutex.synchronize { @cache = {} } + @locks_mutex.synchronize { @locks = {} } + log.info 'JWKS cache cleared' + end + + private + + def cache_read(jwks_url) + @cache_mutex.synchronize { @cache[jwks_url] } + end + + def cache_write(jwks_url, keys) + @cache_mutex.synchronize do + @cache[jwks_url] = { keys: keys, fetched_at: Time.now } + end + end + + def expired?(fetched_at) + Time.now - fetched_at > CACHE_TTL + end + + def http_get(url) + uri = URI.parse(url) + raise Legion::Crypt::JWT::Error, 'failed to fetch JWKS: HTTPS is required' unless uri.scheme == 'https' + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = jwks_ssl_verify_mode + http.open_timeout = 10 + http.read_timeout = 10 + + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) + + raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + response.body + rescue StandardError => e + unless e.is_a?(Legion::Crypt::JWT::Error) + handle_exception(e, level: :warn, operation: 'crypt.jwks.http_get', url: url) if respond_to?(:handle_exception) + raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}" + end + + raise + end + + def parse_response(body) + parsed = ::JSON.parse(body) + raise Legion::Crypt::JWT::Error, 'invalid JWKS response: missing keys' unless parsed.is_a?(Hash) && parsed['keys'].is_a?(Array) + + parsed + rescue ::JSON::ParserError => e + handle_exception(e, level: :warn, operation: 'crypt.jwks.parse_response') + raise Legion::Crypt::JWT::Error, "invalid JWKS response: #{e.message}" + end + + def parse_jwks(jwks_data) + keys = {} + + jwks_data['keys'].each do |jwk_hash| + kid = jwk_hash['kid'] + next unless kid + + jwk = ::JWT::JWK.new(jwk_hash) + keys[kid] = jwk.public_key + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.jwks.parse_jwks', kid: kid) + next + end + + keys + end + + def jwks_ssl_verify_mode + return OpenSSL::SSL::VERIFY_PEER unless defined?(Legion::Settings) + + verify = Legion::Settings[:crypt][:jwt][:jwks_tls_verify]&.to_s + verify == 'none' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER + rescue StandardError + OpenSSL::SSL::VERIFY_PEER + end + + def with_url_lock(jwks_url, &) + lock = @locks_mutex.synchronize { @locks[jwks_url] ||= Mutex.new } + lock.synchronize(&) + end + end + end + end +end diff --git a/lib/legion/crypt/jwt.rb b/lib/legion/crypt/jwt.rb new file mode 100644 index 0000000..0cec5af --- /dev/null +++ b/lib/legion/crypt/jwt.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'jwt' +require 'securerandom' +require 'legion/logging/helper' +require 'legion/crypt/jwks_client' + +module Legion + module Crypt + module JWT + class Error < StandardError; end + class ExpiredTokenError < Error; end + class InvalidTokenError < Error; end + class DecodeError < Error; end + + SUPPORTED_ALGORITHMS = %w[HS256 RS256].freeze + + extend Legion::Logging::Helper + + def self.issue(payload, signing_key:, algorithm: 'HS256', ttl: 3600, issuer: 'legion') + validate_algorithm!(algorithm) + + now = Time.now.to_i + claims = sanitize_payload(payload).merge( + iss: issuer, + iat: now, + exp: now + ttl, + jti: SecureRandom.uuid + ) + + token = ::JWT.encode(claims, signing_key, algorithm) + log.info "JWT issued: sub=#{claims[:sub]}, exp=#{Time.at(claims[:exp]).utc.iso8601}, alg=#{algorithm}" + token + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.jwt.issue', algorithm: algorithm, issuer: issuer) + raise + end + + def self.issue_identity_token(signing_key:, extra_claims: {}, algorithm: 'HS256', ttl: 3600, issuer: 'legion') + unless defined?(Legion::Identity::Process) && Legion::Identity::Process.resolved? + raise ArgumentError, + 'Identity::Process not resolved' + end + + identity = Legion::Identity::Process.identity_hash + identity_fields = { + sub: identity[:canonical_name], + principal_id: identity[:id], + canonical_name: identity[:canonical_name], + kind: identity[:kind].to_s, + mode: identity[:mode].to_s, + groups: (identity[:groups] || [])[0, 50] + } + normalized_extra_claims = symbolize_keys(extra_claims || {}).reject do |key, _value| + identity_fields.key?(key) + end + payload = normalized_extra_claims.merge(identity_fields) + + issue(payload, signing_key: signing_key, algorithm: algorithm, ttl: ttl, issuer: issuer) + end + + def self.verify(token, verification_key:, **opts) + algorithm = opts.fetch(:algorithm, 'HS256') + verify_expiration = opts.fetch(:verify_expiration, true) + verify_issuer = opts.fetch(:verify_issuer, true) + issuer = opts.fetch(:issuer, 'legion') + + validate_algorithm!(algorithm) + + decode_opts = { + algorithm: algorithm, + verify_expiration: verify_expiration, + verify_iss: verify_issuer + } + decode_opts[:iss] = issuer if verify_issuer + + payload, _header = ::JWT.decode(token, verification_key, true, decode_opts) + result = symbolize_keys(payload) + log.debug "JWT verify success: sub=#{result[:sub]}, jti=#{result[:jti]}" + result + rescue ::JWT::ExpiredSignature => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify.expired', algorithm: algorithm) + log.warn 'JWT verify failed: token has expired' + raise ExpiredTokenError, 'token has expired' + rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify.signature', algorithm: algorithm) + log.warn 'JWT verify failed: signature verification failed' + raise InvalidTokenError, 'token signature verification failed' + rescue ::JWT::DecodeError => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify.decode', algorithm: algorithm) + log.warn "JWT verify failed: #{e.message}" + raise DecodeError, "failed to decode token: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.jwt.verify', algorithm: algorithm) + raise + end + + def self.decode(token) + payload, _header = ::JWT.decode(token, nil, false) + symbolize_keys(payload) + rescue ::JWT::DecodeError => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.decode') + raise DecodeError, "failed to decode token: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.jwt.decode') + raise + end + + def self.verify_with_jwks(token, jwks_url:, **opts) + header = decode_header(token) + kid = header['kid'] + algorithm = header['alg'] || 'RS256' + + raise InvalidTokenError, 'token header missing kid' unless kid + + validate_algorithm!(algorithm) + + public_key = Legion::Crypt::JwksClient.find_key(jwks_url, kid) + + verify_expiration = opts.fetch(:verify_expiration, true) + issuers = opts[:issuers] + audience = opts[:audience] + validate_external_requirements!(issuers: issuers, audience: audience) + + decode_opts = { + algorithm: algorithm, + verify_expiration: verify_expiration, + verify_iss: true, + iss: issuers, + verify_aud: true, + aud: audience + } + + payload, _header = ::JWT.decode(token, public_key, true, decode_opts) + result = symbolize_keys(payload) + log.info "JWT JWKS verify success: sub=#{result[:sub]}, kid=#{kid}" + result + rescue ::JWT::ExpiredSignature => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.expired', jwks_url: jwks_url, kid: kid) + log.warn "JWT JWKS verify failed: token has expired, kid=#{kid}" + raise ExpiredTokenError, 'token has expired' + rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.signature', jwks_url: jwks_url, kid: kid) + log.warn "JWT JWKS verify failed: signature verification failed, kid=#{kid}" + raise InvalidTokenError, 'token signature verification failed' + rescue ::JWT::InvalidIssuerError => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.issuer', jwks_url: jwks_url, kid: kid) + log.warn "JWT JWKS verify failed: issuer not allowed, kid=#{kid}" + raise InvalidTokenError, 'token issuer not allowed' + rescue ::JWT::InvalidAudError => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.audience', jwks_url: jwks_url, kid: kid) + log.warn "JWT JWKS verify failed: audience mismatch, kid=#{kid}" + raise InvalidTokenError, 'token audience mismatch' + rescue ::JWT::DecodeError => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.decode', jwks_url: jwks_url, kid: kid) + log.warn "JWT JWKS verify failed: #{e.message}, kid=#{kid}" + raise DecodeError, "failed to decode token: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.jwt.verify_with_jwks', jwks_url: jwks_url, kid: kid) + raise + end + + def self.decode_header(token) + parts = token.to_s.split('.') + raise DecodeError, 'invalid token format' unless parts.size == 3 + + header_json = Base64.urlsafe_decode64(parts[0]) + ::JSON.parse(header_json) + rescue ::JSON::ParserError, ArgumentError => e + handle_exception(e, level: :warn, operation: 'crypt.jwt.decode_header') + raise DecodeError, "failed to decode token header: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.jwt.decode_header') + raise + end + + def self.validate_algorithm!(algorithm) + return if SUPPORTED_ALGORITHMS.include?(algorithm) + + raise ArgumentError, "unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.join(', ')}" + end + + def self.symbolize_keys(hash) + hash.transform_keys(&:to_sym) + end + + def self.sanitize_payload(payload) + payload.each_with_object({}) do |(key, value), sanitized| + next if %w[iss iat exp jti].include?(key.to_s) + + sanitized[key] = value + end + end + + def self.validate_external_requirements!(issuers:, audience:) + raise ArgumentError, 'issuers is required for JWKS verification' if blank_external_requirement?(issuers) + raise ArgumentError, 'audience is required for JWKS verification' if blank_external_requirement?(audience) + end + + def self.blank_external_requirement?(value) + return true if value.nil? + return true if value.respond_to?(:empty?) && value.empty? + + false + end + + private_class_method :validate_algorithm!, :symbolize_keys, :decode_header, :sanitize_payload, + :validate_external_requirements!, :blank_external_requirement? + end + end +end diff --git a/lib/legion/crypt/kerberos_auth.rb b/lib/legion/crypt/kerberos_auth.rb new file mode 100644 index 0000000..be35a4e --- /dev/null +++ b/lib/legion/crypt/kerberos_auth.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module KerberosAuth + class AuthError < StandardError; end + class GemMissingError < StandardError; end + + DEFAULT_AUTH_PATH = 'auth/kerberos/login' + + @kerberos_principal = nil + extend Legion::Logging::Helper + + class << self + attr_reader :kerberos_principal + end + + def self.login(vault_client:, service_principal:, auth_path: DEFAULT_AUTH_PATH) + raise GemMissingError, 'lex-kerberos gem is required for Kerberos auth' unless spnego_available? + + log.info "KerberosAuth login requested auth_path=#{auth_path}" + log.debug("KerberosAuth: login: SPN=#{service_principal}, auth_path=#{auth_path}") + addr = vault_client.respond_to?(:address) ? vault_client.address : 'n/a' + ns = vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a' + log.debug("KerberosAuth: login: vault_client.address=#{addr}, namespace=#{ns}") + + @kerberos_principal = nil + token = obtain_token(service_principal) + log.debug("KerberosAuth: login: SPNEGO token obtained (#{token.length} chars)") + + result = exchange_token(vault_client, token, auth_path) + @kerberos_principal = result[:metadata]&.dig('username') || result[:metadata]&.dig(:username) + log.debug("KerberosAuth: login: authenticated as #{@kerberos_principal.inspect}, policies=#{result[:policies].inspect}") + log.debug("KerberosAuth: login: renewable=#{result[:renewable]}, ttl=#{result[:lease_duration]}s") + log.info "KerberosAuth login success principal=#{@kerberos_principal || 'unknown'} auth_path=#{auth_path}" + result + end + + def self.spnego_available? + return @spnego_available unless @spnego_available.nil? + + @spnego_available = begin + require 'legion/extensions/kerberos/helpers/spnego' + true + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'crypt.kerberos_auth.spnego_available') + # check if constant was already defined (e.g. stubbed in tests or loaded via another path) + defined?(Legion::Extensions::Kerberos::Helpers::Spnego) ? true : false + end + end + + def self.reset! + @spnego_available = nil + @kerberos_principal = nil + end + + class << self + private + + def obtain_token(service_principal) + helper = Object.new.extend(Legion::Extensions::Kerberos::Helpers::Spnego) + result = helper.obtain_spnego_token(service_principal: service_principal) + raise AuthError, "SPNEGO token acquisition failed: #{result[:error]}" unless result[:success] + + log.info 'KerberosAuth obtained SPNEGO token' + result[:token] + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.kerberos_auth.obtain_token', auth_method: 'kerberos') + raise + end + + def exchange_token(vault_client, spnego_token, auth_path) + # Kerberos auth is mounted inside the target namespace. Keep the + # client namespace so the token is scoped to it. + + # The Vault Kerberos plugin reads the SPNEGO token from the HTTP + # Authorization header, not the JSON body. + namespace = vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a' + log.debug("KerberosAuth: exchange_token: PUT /v1/#{auth_path} (namespace=#{namespace})") + json = vault_client.put( + "/v1/#{auth_path}", + '{}', + 'Authorization' => "Negotiate #{spnego_token}" + ) + response = ::Vault::Secret.decode(json) + raise AuthError, 'Vault Kerberos auth returned no auth data' unless response&.auth + + auth = response.auth + { + token: auth.client_token, + lease_duration: auth.lease_duration, + renewable: auth.renewable?, + policies: auth.policies, + metadata: auth.metadata + } + rescue ::Vault::HTTPClientError => e + handle_exception(e, level: :warn, operation: 'crypt.kerberos_auth.exchange_token', auth_path: auth_path) + log.debug("KerberosAuth: exchange_token: HTTP error: #{e.message}") + raise AuthError, "Vault Kerberos auth failed: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.kerberos_auth.exchange_token', auth_path: auth_path) + raise + end + end + end + end +end diff --git a/lib/legion/crypt/ldap_auth.rb b/lib/legion/crypt/ldap_auth.rb new file mode 100644 index 0000000..70e217f --- /dev/null +++ b/lib/legion/crypt/ldap_auth.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module LdapAuth + include Legion::Logging::Helper + + def ldap_login(cluster_name:, username:, password:) + cluster_name = cluster_name.to_sym + log.info "LDAP login requested user=#{username} cluster=#{cluster_name}" + client = vault_client(cluster_name) + secret = client.logical.write("auth/ldap/login/#{username}", password: password) + auth = secret.auth + token = auth.client_token + + clusters[cluster_name][:token] = token + clusters[cluster_name][:connected] = true + client.token = token if client.respond_to?(:token=) + + log.info "LDAP login success: user=#{username}, cluster=#{cluster_name}" + { token: token, lease_duration: auth.lease_duration, + renewable: auth.renewable?, policies: auth.policies } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.ldap_auth.ldap_login', cluster_name: cluster_name, username: username) + log.error "LDAP login failed: user=#{username}, cluster=#{cluster_name}: #{e.message}" + raise + end + + def ldap_login_all(username:, password:) + results = {} + clusters.each do |name, config| + next unless config[:auth_method] == 'ldap' + + results[name] = ldap_login(cluster_name: name, username: username, password: password) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.ldap_auth.ldap_login_all', cluster_name: name, username: username) + log.warn("Legion::Crypt::LdapAuth#ldap_login_all cluster=#{name} failed: #{e.message}") + results[name] = { error: e.message } + end + log.info "LDAP login_all complete successes=#{results.count { |_, result| result.is_a?(Hash) && !result.key?(:error) }} attempted=#{results.size}" + results + end + end + end +end diff --git a/lib/legion/crypt/lease_manager.rb b/lib/legion/crypt/lease_manager.rb new file mode 100644 index 0000000..c7d36d1 --- /dev/null +++ b/lib/legion/crypt/lease_manager.rb @@ -0,0 +1,526 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'singleton' +require 'timeout' + +module Legion + module Crypt + class LeaseManager + include Singleton + include Legion::Logging::Helper + + RENEWAL_CHECK_INTERVAL = 5 + + def initialize + @lease_cache = {} + @active_leases = {} + @refs = {} + @running = false + @renewal_thread = nil + @state_mutex = Mutex.new + end + + def start(definitions, vault_client: nil) + @state_mutex.synchronize { @vault_client = vault_client } + return if definitions.nil? || definitions.empty? + + register_at_exit_hook + + log.info "LeaseManager start requested definitions=#{definitions.size}" + definitions.each do |name, opts| + path = opts['path'] || opts[:path] + next unless path + + if lease_valid?(name) + log.debug("LeaseManager: reusing valid cached lease for '#{name}'") + next + end + + revoke_expired_lease(name) + + begin + log.info("LeaseManager: fetching lease '#{name}' from #{path}") + response = logical.read(path) + unless response + log.warn("LeaseManager: no data at '#{name}' (#{path}) — path may not exist or role not configured") + next + end + + log_lease_response(name, response) + cache_lease(name, response, path: path) + log.info("LeaseManager: fetched lease '#{name}' from #{path} " \ + "(lease_id=#{response.lease_id.to_s[0..11]}... ttl=#{response.lease_duration}s renewable=#{response.renewable?})") + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.lease_manager.start', lease_name: name, path: path) + log.warn("LeaseManager: failed to fetch lease '#{name}' from #{path}: #{e.message}") + end + end + end + + def fetched_count + @state_mutex.synchronize { @active_leases.size } + end + + def fetch(name, key) + data = @state_mutex.synchronize do + @lease_cache[name.to_sym] || @lease_cache[name.to_s] + end + return nil unless data + + data[key.to_sym] || data[key.to_s] + end + + def lease_data(name) + @state_mutex.synchronize { @lease_cache[name] } + end + + attr_reader :active_leases + + def register_ref(name, key, path) + @state_mutex.synchronize do + @refs[name] ||= {} + @refs[name][key] = path + end + end + + def push_to_settings(name) + refs, data = @state_mutex.synchronize do + r = @refs[name] || @refs[name.to_s] || @refs[name.to_sym] + d = @lease_cache[name] || @lease_cache[name.to_s] || @lease_cache[name.to_sym] + [r&.dup, d&.dup] + end + return if refs.nil? || refs.empty? + return unless data + + refs.each do |key, path| + value = data[key.to_sym] || data[key.to_s] + write_setting(path, value) + end + + log.info("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)") + end + + # Public Vault client accessors used by Crypt for bootstrap/swap operations. + # Delegates to the configured vault_client or falls back to ::Vault. + def vault_logical + logical + end + + def vault_sys + sys + end + + def reissue_all + log.info('LeaseManager: reissue_all — re-issuing all active leases under new token') + lease_names = @state_mutex.synchronize { @active_leases.keys.dup } + + lease_names.each do |name| + lease = @state_mutex.synchronize { @active_leases[name]&.dup } + next unless lease && lease[:path] + + reissue_lease(name) + end + log.info('LeaseManager: reissue_all complete') + end + + def register_dynamic_lease(name:, path:, response:, settings_refs:) + register_at_exit_hook + + @state_mutex.synchronize do + @lease_cache[name] = response.data || {} + @active_leases[name] = { + lease_id: response.lease_id, + lease_duration: response.lease_duration, + expires_at: Time.now + (response.lease_duration || 0), + fetched_at: Time.now, + renewable: response.renewable?, + path: path + } + end + settings_refs.each do |ref| + register_ref(name, ref[:key], ref[:path]) + end + log.info("LeaseManager: registered dynamic lease '#{name}' (path: #{path})") + end + + def reissue_lease(name) + lease = @state_mutex.synchronize { @active_leases[name]&.dup } + unless lease && lease[:path] + log.warn("LeaseManager: cannot reissue lease '#{name}' — no path stored for re-read") + return + end + + log.info("LeaseManager: reissuing lease '#{name}' from #{lease[:path]}") + response = logical.read(lease[:path]) + unless response&.data + log.warn("LeaseManager: reissue for '#{name}' returned no data from #{lease[:path]}") + return + end + + updated = @state_mutex.synchronize do + active_lease = @active_leases[name] + next false unless active_lease + + @lease_cache[name] = response.data + active_lease.merge!( + lease_id: response.lease_id, + lease_duration: response.lease_duration, + expires_at: Time.now + (response.lease_duration || 0), + fetched_at: Time.now, + renewable: response.renewable? + ) + true + end + unless updated + log.warn("LeaseManager: reissue for '#{name}' skipped — lease was removed during reissue (likely shutdown)") + return + end + + lease_id_preview = response.lease_id.to_s[0..11] + log.info("LeaseManager: reissued lease '#{name}' " \ + "(new_lease_id=#{lease_id_preview}... ttl=#{response.lease_duration}s)") + push_to_settings(name) + trigger_reconnect(name) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.lease_manager.reissue_lease', lease_name: name) + log.warn("LeaseManager: failed to reissue lease '#{name}': #{e.message}") + end + + def start_renewal_thread + @state_mutex.synchronize do + return if @renewal_thread&.alive? + + @running = true + @renewal_thread = Thread.new { renewal_loop } + end + log.info 'LeaseManager renewal thread started' + end + + def renewal_thread_alive? + @state_mutex.synchronize { @renewal_thread&.alive? || false } + end + + def shutdown + log.info 'LeaseManager shutdown requested' + stop_renewal_thread + + leases = @state_mutex.synchronize { @active_leases.dup } + leases.each do |name, meta| + lease_id = meta[:lease_id] + next if lease_id.nil? || lease_id.empty? + + begin + sys.revoke(lease_id) + log.debug("LeaseManager: revoked lease '#{name}' (#{lease_id})") + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.lease_manager.shutdown', lease_name: name) + log.warn("LeaseManager: failed to revoke lease '#{name}' (#{lease_id}): #{e.message}") + end + end + + @state_mutex.synchronize do + @lease_cache.clear + @active_leases.clear + @refs.clear + @vault_client = nil + end + log.info 'LeaseManager shutdown complete' + end + + def reset! + @state_mutex.synchronize do + @running = false + @lease_cache.clear + @active_leases.clear + @refs.clear + @vault_client = nil + @renewal_thread = nil + end + end + + private + + def register_at_exit_hook + return if @at_exit_registered + + at_exit do + next if @state_mutex.synchronize { @active_leases.empty? } + + Timeout.timeout(10) { shutdown } + rescue Timeout::Error => e + log.warn("[LeaseManager] at_exit shutdown timed out after 10s: #{e.message}") + rescue StandardError => e # best effort on crash + log.warn("[LeaseManager] at_exit shutdown failed: #{e.class}: #{e.message}") + end + @at_exit_registered = true + end + + def cache_lease(name, response, path: nil) + @state_mutex.synchronize do + @lease_cache[name] = response.data || {} + @active_leases[name] = { + lease_id: response.lease_id, + lease_duration: response.lease_duration, + renewable: response.renewable?, + expires_at: Time.now + (response.lease_duration || 0), + fetched_at: Time.now, + path: path + } + end + end + + def log_lease_response(name, response) + data_keys = response.data&.keys&.map(&:to_s) || [] + log.debug("LeaseManager[#{name}]: lease_id=#{response.lease_id}, " \ + "lease_duration=#{response.lease_duration}s, " \ + "renewable=#{response.renewable?}, " \ + "data_keys=#{data_keys.inspect}") + return unless response.data&.key?(:username) + + log.debug("LeaseManager[#{name}]: username=#{response.data[:username]}, " \ + "password_length=#{response.data[:password]&.length || 0}, " \ + "vhost=#{response.data[:vhost] || 'N/A'}, " \ + "tags=#{response.data[:tags] || 'N/A'}") + end + + def logical + client = @state_mutex.synchronize { @vault_client } + client ? client.logical : ::Vault.logical + end + + def sys + client = @state_mutex.synchronize { @vault_client } + client ? client.sys : ::Vault.sys + end + + def stop_renewal_thread + thread = @state_mutex.synchronize do + @running = false + @renewal_thread + end + return unless thread + + begin + thread.wakeup if thread.alive? + rescue ThreadError => e + handle_exception(e, level: :debug, operation: 'crypt.lease_manager.stop_renewal_thread') + end + thread.join(2) + if thread.alive? + log.warn 'LeaseManager renewal thread did not stop within timeout' + else + @state_mutex.synchronize { @renewal_thread = nil } + log.debug 'LeaseManager renewal thread stopped' + end + end + + def renewal_loop + log.info 'LeaseManager: renewal loop started' + while running? + interruptible_sleep(RENEWAL_CHECK_INTERVAL) + renew_approaching_leases if running? + end + log.info 'LeaseManager: renewal loop exiting' + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop') + log.error("LeaseManager: renewal loop error: #{e.message} — restarting") + retry if running? + end + + def renew_approaching_leases + leases = @state_mutex.synchronize { @active_leases.keys } + leases.each do |name| + lease = @state_mutex.synchronize { @active_leases[name]&.dup } + next unless lease + next unless approaching_expiry?(lease) + + remaining = lease[:expires_at] ? (lease[:expires_at] - Time.now).round(1) : 'unknown' + log.debug("LeaseManager: lease '#{name}' approaching expiry " \ + "(remaining=#{remaining}s renewable=#{lease[:renewable]} has_path=#{!lease[:path].nil?})") + + if lease[:renewable] + renew_lease(name, lease) + elsif lease[:path] + log.info("LeaseManager: lease '#{name}' is non-renewable — re-issuing from #{lease[:path]}") + reissue_lease(name) + else + log.warn("LeaseManager: lease '#{name}' is non-renewable and has no path for reissue — " \ + "will expire at #{lease[:expires_at]}") + end + end + end + + def renew_lease(name, lease) + lease_id = lease[:lease_id].to_s + if lease_id.empty? + log.warn("LeaseManager: lease '#{name}' is renewable but has no lease_id") + if lease[:path] + log.warn("LeaseManager: falling back to reissue for '#{name}' from #{lease[:path]}") + reissue_lease(name) + else + log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \ + "lease will expire at #{lease[:expires_at]}") + end + return + end + + log.info("LeaseManager: renewing lease '#{name}' (lease_id=#{lease_id[0..11]}...)") + response = sys.renew(lease_id) + new_ttl = response.respond_to?(:lease_duration) ? response.lease_duration : nil + @state_mutex.synchronize do + current_lease = @active_leases[name] + next unless current_lease + + if new_ttl + current_lease[:lease_duration] = new_ttl + current_lease[:expires_at] = Time.now + new_ttl + end + current_lease[:renewable] = response.renewable? if response.respond_to?(:renewable?) + end + if new_ttl + log.info("LeaseManager: renewed lease '#{name}' (new_ttl=#{new_ttl}s)") + else + log.warn("LeaseManager: renewed lease '#{name}' but Vault returned no lease_duration — keeping previous TTL") + end + + cached_data = @state_mutex.synchronize { @lease_cache[name] } + if response.data && response.data != cached_data + @state_mutex.synchronize { @lease_cache[name] = response.data } + push_to_settings(name) + log.info("LeaseManager: lease '#{name}' credentials changed during renewal — settings updated") + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.lease_manager.renew_lease', lease_name: name) + log.warn("LeaseManager: failed to renew lease '#{name}': #{e.message}") + if lease[:path] + log.warn("LeaseManager: falling back to reissue for '#{name}' from #{lease[:path]}") + reissue_lease(name) + else + log.warn("LeaseManager: lease '#{name}' renewal failed and no path available for reissue — " \ + "lease will expire at #{lease[:expires_at]}") + end + end + + def lease_valid?(name) + meta = @state_mutex.synchronize { @active_leases[name]&.dup } + return false unless meta + + expires_at = meta[:expires_at] + return false unless expires_at + + expires_at > Time.now + end + + def revoke_expired_lease(name) + meta = @state_mutex.synchronize { @active_leases[name]&.dup } + return unless meta + + lease_id = meta[:lease_id] + return if lease_id.nil? || lease_id.empty? + + begin + sys.revoke(lease_id) + log.debug("LeaseManager: revoked expired lease '#{name}' (#{lease_id}) before re-fetch") + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.lease_manager.revoke_expired_lease', lease_name: name) + log.warn("LeaseManager: failed to revoke expired lease '#{name}' (#{lease_id}): #{e.message}") + ensure + @state_mutex.synchronize do + @active_leases.delete(name) + @lease_cache.delete(name) + end + end + end + + def approaching_expiry?(lease) + expires_at = lease[:expires_at] + lease_duration = lease[:lease_duration] + + return true if expires_at.nil? || lease_duration.nil? + + remaining = expires_at - Time.now + remaining < (lease_duration * 0.5) + end + + def write_setting(path, value) + return if path.nil? || path.empty? + + target = path[1..-2].reduce(Legion::Settings[path[0]]) do |node, segment| + break nil unless node.is_a?(Hash) + + node[segment] + end + target[path.last] = value if target.is_a?(Hash) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.lease_manager.write_setting', path: path.join('.')) + log.warn("LeaseManager: failed to write setting at #{path.join('.')}: #{e.message}") + end + + def trigger_reconnect(name) + name = name.to_sym if name.respond_to?(:to_sym) + case name + when :rabbitmq + return unless defined?(Legion::Transport::Connection) + + Legion::Transport::Connection.force_reconnect + log.info("LeaseManager: triggered transport reconnect after '#{name}' reissue") + when :postgresql + trigger_postgresql_reconnect(name) + when :redis + return unless defined?(Legion::Cache) + + if Legion::Cache.respond_to?(:restart) + Legion::Cache.restart + elsif Legion::Cache.respond_to?(:reconnect) + Legion::Cache.reconnect + end + log.info("LeaseManager: triggered cache reconnect after '#{name}' reissue") + end + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.lease_manager.trigger_reconnect', lease_name: name) + log.error("LeaseManager: reconnect for '#{name}' FAILED: #{e.message} — " \ + 'services may be unavailable until the next lease rotation') + end + + def trigger_postgresql_reconnect(name) + unless defined?(Legion::Data::Connection) + log.debug("LeaseManager: no Legion::Data::Connection loaded for '#{name}' reconnect") + return + end + + if Legion::Data::Connection.respond_to?(:reconnect_with_fresh_creds) + success = Legion::Data::Connection.reconnect_with_fresh_creds + if success + log.info("LeaseManager: reconnected data layer with fresh credentials after '#{name}' reissue") + else + log.error("LeaseManager: FAILED to reconnect data layer after '#{name}' reissue — " \ + 'Apollo and other DB-backed services may be unavailable') + end + elsif Legion::Data::Connection.respond_to?(:sequel) && Legion::Data::Connection.sequel + log.warn('LeaseManager: legion-data does not support reconnect_with_fresh_creds — ' \ + 'falling back to pool disconnect (may use stale credentials)') + Legion::Data::Connection.sequel.disconnect + Legion::Data::Connection.sequel.test_connection + log.info("LeaseManager: triggered data pool reconnect after '#{name}' reissue (legacy path)") + else + log.warn("LeaseManager: no active data connection to reconnect after '#{name}' reissue") + end + end + + def running? + @state_mutex.synchronize { @running } + end + + def interruptible_sleep(seconds) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds + loop do + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + break if remaining <= 0 || !running? + + sleep([remaining, 1.0].min) + end + end + end + end +end diff --git a/lib/legion/crypt/mock_vault.rb b/lib/legion/crypt/mock_vault.rb new file mode 100644 index 0000000..33f0ddc --- /dev/null +++ b/lib/legion/crypt/mock_vault.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Legion + module Crypt + module MockVault + @store = {} + @mutex = Mutex.new + + class << self + def read(path) + @mutex.synchronize { @store[path]&.dup } + end + + def write(path, data) + @mutex.synchronize { @store[path] = data.dup } + true + end + + def delete(path) + @mutex.synchronize { @store.delete(path) } + true + end + + def list(prefix) + @mutex.synchronize do + @store.keys.select { |k| k.start_with?(prefix) } + end + end + + def reset! + @mutex.synchronize { @store.clear } + end + + def connected? + true + end + end + end + end +end diff --git a/lib/legion/crypt/mtls.rb b/lib/legion/crypt/mtls.rb new file mode 100644 index 0000000..18e133c --- /dev/null +++ b/lib/legion/crypt/mtls.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'socket' + +module Legion + module Crypt + module Mtls + DEFAULT_PKI_PATH = 'pki/issue/legion-internal' + DEFAULT_TTL = '24h' + extend Legion::Logging::Helper + + class << self + def enabled? + security = safe_security_settings + return false if security.nil? + + mtls = security[:mtls] || security['mtls'] + return false if mtls.nil? + + mtls[:enabled] || mtls['enabled'] || false + end + + def pki_path + security = safe_security_settings + return DEFAULT_PKI_PATH if security.nil? + + mtls = security[:mtls] || security['mtls'] || {} + mtls[:vault_pki_path] || mtls['vault_pki_path'] || DEFAULT_PKI_PATH + end + + def issue_cert(common_name:, ttl: nil) + resolved_ttl = ttl || cert_ttl_setting || DEFAULT_TTL + log.info "[mTLS] certificate issue requested common_name=#{common_name} ttl=#{resolved_ttl}" + + response = ::Vault.logical.write( + pki_path, + common_name: common_name, + ttl: resolved_ttl, + ip_sans: local_ip, + alt_names: '' + ) + + raise "Vault PKI returned nil for #{pki_path} (common_name=#{common_name})" if response.nil? + + data = response.data + + { + cert: data[:certificate], + key: data[:private_key], + ca_chain: Array(data[:ca_chain]), + serial: data[:serial_number], + expiry: Time.at(data[:expiration].to_i) + } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.mtls.issue_cert', common_name: common_name, ttl: resolved_ttl) + raise + end + + def local_ip + Socket.ip_address_list.find { |a| a.ipv4? && !a.ipv4_loopback? }&.ip_address || '127.0.0.1' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.mtls.local_ip') + '127.0.0.1' + end + + private + + def safe_security_settings + return nil unless defined?(Legion::Settings) + + Legion::Settings[:security] + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.mtls.safe_security_settings') + nil + end + + def cert_ttl_setting + security = safe_security_settings + return nil if security.nil? + + mtls = security[:mtls] || security['mtls'] || {} + mtls[:cert_ttl] || mtls['cert_ttl'] + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.mtls.cert_ttl_setting') + nil + end + end + end + end +end diff --git a/lib/legion/crypt/partition_keys.rb b/lib/legion/crypt/partition_keys.rb new file mode 100644 index 0000000..ffaddc0 --- /dev/null +++ b/lib/legion/crypt/partition_keys.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'openssl' +require 'legion/logging/helper' + +module Legion + module Crypt + module PartitionKeys + extend Legion::Logging::Helper + + class << self + def derive_key(master_key:, tenant_id:, context: nil) + context ||= begin + Legion::Settings[:crypt][:partition_keys][:derivation_context] + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.partition_keys.derivation_context', tenant_id: tenant_id) + nil + end || 'legion-partition' + log.debug "PartitionKeys deriving key for tenant #{tenant_id}" + salt = OpenSSL::Digest::SHA256.digest(tenant_id.to_s) + OpenSSL::KDF.hkdf(master_key, salt: salt, info: context, length: 32, hash: 'SHA256') + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.partition_keys.derive_key', tenant_id: tenant_id) + raise + end + + def encrypt_for_tenant(plaintext:, tenant_id:, master_key:) + key = derive_key(master_key: master_key, tenant_id: tenant_id) + cipher = OpenSSL::Cipher.new('aes-256-gcm') + cipher.encrypt + cipher.key = key + iv = cipher.random_iv + ciphertext = cipher.update(plaintext) + cipher.final + auth_tag = cipher.auth_tag + + log.debug "PartitionKeys encrypted payload for tenant #{tenant_id}" + { ciphertext: ciphertext, iv: iv, auth_tag: auth_tag } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.partition_keys.encrypt_for_tenant', tenant_id: tenant_id) + raise + end + + def decrypt_for_tenant(ciphertext:, init_vector:, auth_tag:, tenant_id:, master_key:) + key = derive_key(master_key: master_key, tenant_id: tenant_id) + decipher = OpenSSL::Cipher.new('aes-256-gcm') + decipher.decrypt + decipher.key = key + decipher.iv = init_vector + decipher.auth_tag = auth_tag + plaintext = decipher.update(ciphertext) + decipher.final + log.debug "PartitionKeys decrypted payload for tenant #{tenant_id}" + plaintext + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.partition_keys.decrypt_for_tenant', tenant_id: tenant_id) + raise + end + end + end + end +end diff --git a/lib/legion/crypt/settings.rb b/lib/legion/crypt/settings.rb index ea984f2..770e816 100644 --- a/lib/legion/crypt/settings.rb +++ b/lib/legion/crypt/settings.rb @@ -1,30 +1,85 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + module Legion module Crypt module Settings + extend Legion::Logging::Helper + + def self.tls + { + enabled: false, + verify: 'peer', + ca: nil, + cert: nil, + key: nil + } + end + + def self.spiffe + { + enabled: false, + socket_path: '/tmp/spire-agent/public/api.sock', + trust_domain: 'legion.internal', + workload_id: nil, + renewal_window: 0.5, + allow_x509_fallback: false + } + end + def self.default { - vault: vault, + vault: vault, + jwt: jwt, + tls: tls, cs_encrypt_ready: false, - dynamic_keys: true, - cluster_secret: nil, + dynamic_keys: true, + cluster_secret: nil, save_private_key: true, read_private_key: true } end + def self.jwt + { + enabled: true, + default_algorithm: 'HS256', + default_ttl: 3600, + issuer: 'legion', + verify_expiration: true, + verify_issuer: true, + jwks_tls_verify: 'peer' + } + end + def self.vault { - enabled: !Gem::Specification.find_by_name('vault').nil?, - protocol: 'http', - address: 'localhost', - port: 8200, - token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil, - connected: false, - renewer_time: 5, - renewer: true, - push_cluster_secret: true, - read_cluster_secret: true, - kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion' + enabled: !Gem::Specification.find_by_name('vault').nil?, + protocol: 'http', + address: 'localhost', + port: 8200, + token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil, + connected: false, + renewer_time: 5, + renewer: true, + push_cluster_secret: false, + read_cluster_secret: false, + kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion', + leases: {}, + default: nil, + vault_namespace: 'legionio', + kerberos: { + service_principal: nil, + auth_path: 'auth/kerberos/login' + }, + tls: { + verify: 'peer' + }, + clusters: {}, + bootstrap_lease_ttl: 300, + dynamic_rmq_creds: false, + dynamic_pg_creds: false } end end @@ -32,11 +87,13 @@ def self.vault end begin - Legion::Settings.merge_settings('crypt', Legion::Crypt::Settings.default) if Legion.const_defined?('Settings') + if Legion.const_defined?('Settings') + Legion::Settings.merge_settings('crypt', Legion::Crypt::Settings.default) + Legion::Crypt::Settings.log.info('Legion::Crypt settings defaults merged') + end rescue StandardError => e - if Legion.const_defined?('Logging') && Legion::Logging.method_defined?(:fatal) - Legion::Logging.fatal(e.message) - Legion::Logging.fatal(e.backtrace) + if Legion::Crypt::Settings.respond_to?(:handle_exception) + Legion::Crypt::Settings.handle_exception(e, level: :fatal, operation: 'crypt.settings.merge_defaults') else puts e.message puts e.backtrace diff --git a/lib/legion/crypt/spiffe.rb b/lib/legion/crypt/spiffe.rb new file mode 100644 index 0000000..3e76197 --- /dev/null +++ b/lib/legion/crypt/spiffe.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'uri' +require 'openssl' + +module Legion + module Crypt + module Spiffe + SPIFFE_SCHEME = 'spiffe' + DEFAULT_SOCKET_PATH = '/tmp/spire-agent/public/api.sock' + DEFAULT_TRUST_DOMAIN = 'legion.internal' + SVID_RENEWAL_RATIO = 0.5 + + class Error < StandardError; end + class InvalidSpiffeIdError < Error; end + class WorkloadApiError < Error; end + class SvidError < Error; end + + # Parsed representation of a SPIFFE ID. + # A SPIFFE ID has the form: spiffe:/// + SpiffeId = Struct.new(:trust_domain, :path) do + def to_s + "#{SPIFFE_SCHEME}://#{trust_domain}#{path}" + end + + def ==(other) + other.is_a?(SpiffeId) && trust_domain == other.trust_domain && path == other.path + end + end + + # Parsed X.509 SVID (SPIFFE Verifiable Identity Document). + X509Svid = Struct.new(:spiffe_id, :cert_pem, :key_pem, :bundle_pem, :expiry, :source) do + def expired? + Time.now >= expiry + end + + def valid? + !cert_pem.nil? && !key_pem.nil? && !expired? + end + + # Seconds remaining until expiry (negative if already expired). + def ttl + expiry - Time.now + end + end + + # Parsed JWT SVID. + JwtSvid = Struct.new(:spiffe_id, :token, :audience, :expiry, :source) do + def expired? + Time.now >= expiry + end + + def valid? + !token.nil? && !expired? + end + end + + class << self + include Legion::Logging::Helper + + # Parse a SPIFFE ID string into a SpiffeId struct. + # Raises InvalidSpiffeIdError on malformed input. + def parse_id(spiffe_id_string) + raise InvalidSpiffeIdError, 'SPIFFE ID must be a non-empty string' if spiffe_id_string.nil? || spiffe_id_string.empty? + + uri = URI.parse(spiffe_id_string) + validate_uri!(uri, spiffe_id_string) + + SpiffeId.new( + trust_domain: uri.host, + path: uri.path.empty? ? '/' : uri.path + ) + rescue URI::InvalidURIError => e + handle_exception(e, level: :warn, operation: 'crypt.spiffe.parse_id', spiffe_id: spiffe_id_string) + raise InvalidSpiffeIdError, "Invalid SPIFFE ID '#{spiffe_id_string}': #{e.message}" + end + + def valid_id?(spiffe_id_string) + parse_id(spiffe_id_string) + true + rescue InvalidSpiffeIdError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.valid_id', spiffe_id: spiffe_id_string) + false + end + + def enabled? + security = safe_security_settings + return false if security.nil? + + spiffe = security[:spiffe] || security['spiffe'] + return false if spiffe.nil? + + spiffe[:enabled] || spiffe['enabled'] || false + end + + def socket_path + security = safe_security_settings + return DEFAULT_SOCKET_PATH if security.nil? + + spiffe = security[:spiffe] || security['spiffe'] || {} + spiffe[:socket_path] || spiffe['socket_path'] || DEFAULT_SOCKET_PATH + end + + def trust_domain + security = safe_security_settings + return DEFAULT_TRUST_DOMAIN if security.nil? + + spiffe = security[:spiffe] || security['spiffe'] || {} + spiffe[:trust_domain] || spiffe['trust_domain'] || DEFAULT_TRUST_DOMAIN + end + + def workload_id + security = safe_security_settings + return nil if security.nil? + + spiffe = security[:spiffe] || security['spiffe'] || {} + spiffe[:workload_id] || spiffe['workload_id'] + end + + def allow_x509_fallback? + security = safe_security_settings + return false if security.nil? + + spiffe = security[:spiffe] || security['spiffe'] || {} + spiffe[:allow_x509_fallback] || spiffe['allow_x509_fallback'] || false + end + + private + + def validate_uri!(uri, raw) + unless uri.scheme == SPIFFE_SCHEME + raise InvalidSpiffeIdError, + "SPIFFE ID must use 'spiffe://' scheme, got '#{uri.scheme}://'" + end + raise InvalidSpiffeIdError, "SPIFFE ID missing trust domain in '#{raw}'" if uri.host.nil? || uri.host.empty? + return unless uri.userinfo || uri.port || (uri.query && !uri.query.empty?) || (uri.fragment && !uri.fragment.empty?) + + raise InvalidSpiffeIdError, "SPIFFE ID must not contain userinfo, port, query, or fragment in '#{raw}'" + end + + def safe_security_settings + return nil unless defined?(Legion::Settings) + + Legion::Settings[:security] + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.safe_security_settings') + nil + end + end + end + end +end diff --git a/lib/legion/crypt/spiffe/identity_helpers.rb b/lib/legion/crypt/spiffe/identity_helpers.rb new file mode 100644 index 0000000..8a951f1 --- /dev/null +++ b/lib/legion/crypt/spiffe/identity_helpers.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'openssl' +require 'base64' + +module Legion + module Crypt + module Spiffe + # Helpers for signing, verifying, and inspecting SPIFFE SVIDs. + # + # These methods work directly with X509Svid and JwtSvid structs + # returned by WorkloadApiClient. No external gem is required — + # all operations use the Ruby stdlib OpenSSL bindings. + module IdentityHelpers + include Legion::Logging::Helper + + # Sign arbitrary data with the private key from an X.509 SVID. + # Returns the signature as a Base64-encoded string. + # + # @param data [String] The bytes to sign (any encoding; treated as binary). + # @param svid [X509Svid] An X509Svid whose key_pem is populated. + # @return [String] Base64-encoded DER signature. + def sign_with_svid(data, svid:) + raise SvidError, 'Cannot sign: SVID is nil' if svid.nil? + raise SvidError, "Cannot sign: SVID '#{svid.spiffe_id}' has expired" if svid.expired? + raise SvidError, 'Cannot sign: SVID private key is missing' if svid.key_pem.nil? + + key = OpenSSL::PKey.read(svid.key_pem) + digest = OpenSSL::Digest.new('SHA256') + signature = key.sign(digest, data.b) + log.debug("SPIFFE signed payload with SVID id=#{svid.spiffe_id}") + Base64.strict_encode64(signature) + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.sign_with_svid', spiffe_id: svid&.spiffe_id&.to_s) + raise + end + + # Verify a Base64-encoded signature produced by sign_with_svid. + # + # @param data [String] Original data that was signed. + # @param signature_b64 [String] Base64-encoded signature from sign_with_svid. + # @param svid [X509Svid] The SVID whose public certificate is used for verification. + # @return [Boolean] true if the signature is valid. + def verify_svid_signature(data, signature_b64:, svid:) + raise SvidError, 'Cannot verify: SVID is nil' if svid.nil? + raise SvidError, "Cannot verify: SVID '#{svid.spiffe_id}' has expired" if svid.expired? + raise SvidError, 'Cannot verify: SVID certificate is missing' if svid.cert_pem.nil? + + cert = OpenSSL::X509::Certificate.new(svid.cert_pem) + digest = OpenSSL::Digest.new('SHA256') + signature = Base64.strict_decode64(signature_b64) + result = cert.public_key.verify(digest, signature, data.b) + log.debug("SPIFFE signature verification completed id=#{svid.spiffe_id} valid=#{result}") + result + rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::CertificateError, ArgumentError => e + handle_exception(e, level: :warn, operation: 'crypt.spiffe.verify_svid_signature', spiffe_id: svid&.spiffe_id&.to_s) + log.warn("[SPIFFE] SVID signature verification error: #{e.message}") + false + end + + # Extract the SPIFFE ID embedded in an X.509 certificate's SAN URI extension. + # Returns a SpiffeId struct or nil if none is found. + # + # @param cert_pem [String] PEM-encoded X.509 certificate. + # @return [SpiffeId, nil] + def extract_spiffe_id_from_cert(cert_pem) + cert = OpenSSL::X509::Certificate.new(cert_pem) + san = cert.extensions.find { |e| e.oid == 'subjectAltName' } + return nil unless san + + san.value.split(',').each do |entry| + entry = entry.strip + next unless entry.start_with?('URI:spiffe://') + + uri = entry.sub('URI:', '') + return Legion::Crypt::Spiffe.parse_id(uri) + rescue InvalidSpiffeIdError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.extract_spiffe_id_from_cert', san_entry: entry) + next + end + + nil + rescue OpenSSL::X509::CertificateError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.extract_spiffe_id_from_cert') + nil + end + + # Validate that a certificate chain is trusted by the bundle embedded + # in the given SVID. Returns true if the leaf cert chains up to the + # bundle CA, false otherwise. + # + # @param cert_pem [String] PEM-encoded leaf certificate to validate. + # @param svid [X509Svid] SVID whose bundle_pem contains the trust anchor. + # @return [Boolean] + def trusted_cert?(cert_pem, svid:) + raise SvidError, 'Cannot check trust: SVID is nil' if svid.nil? + return false if svid.bundle_pem.nil? + + store = OpenSSL::X509::Store.new + store.add_cert(OpenSSL::X509::Certificate.new(svid.bundle_pem)) + + leaf = OpenSSL::X509::Certificate.new(cert_pem) + store.verify(leaf) + rescue OpenSSL::X509::CertificateError, OpenSSL::X509::StoreError => e + handle_exception(e, level: :warn, operation: 'crypt.spiffe.trusted_cert?', spiffe_id: svid&.spiffe_id&.to_s) + false + end + + # Return a hash of identity information extracted from an SVID. + # + # @param svid [X509Svid, JwtSvid] Any SVID type. + # @return [Hash] + def svid_identity(svid) + return {} if svid.nil? + + base = { + spiffe_id: svid.spiffe_id.to_s, + trust_domain: svid.spiffe_id.trust_domain, + workload_path: svid.spiffe_id.path, + expiry: svid.expiry, + expired: svid.expired? + } + + case svid + when X509Svid + base.merge(type: :x509, ttl_seconds: svid.ttl.to_i) + when JwtSvid + base.merge(type: :jwt, audience: svid.audience) + else + base + end + end + end + end + end +end diff --git a/lib/legion/crypt/spiffe/svid_rotation.rb b/lib/legion/crypt/spiffe/svid_rotation.rb new file mode 100644 index 0000000..216ea51 --- /dev/null +++ b/lib/legion/crypt/spiffe/svid_rotation.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module Spiffe + # Background thread that keeps the current X.509 SVID fresh. + # + # Mirrors the pattern used by CertRotation (mTLS) but targets the + # SPIFFE Workload API instead of Vault PKI. The check interval + # defaults to 60 seconds; renewal fires when the SVID is past 50% + # of its lifetime (configurable via security.spiffe.renewal_window). + class SvidRotation + include Legion::Logging::Helper + + DEFAULT_CHECK_INTERVAL = 60 + + attr_reader :check_interval, :current_svid + + def initialize(check_interval: DEFAULT_CHECK_INTERVAL, client: nil) + @check_interval = check_interval + @client = client || WorkloadApiClient.new + @current_svid = nil + @issued_at = nil + @running = false + @thread = nil + @mutex = Mutex.new + end + + def start + return unless Legion::Crypt::Spiffe.enabled? + return if running? + + @running = true + @thread = Thread.new { rotation_loop } + @thread.name = 'spiffe-svid-rotation' + log.info '[SPIFFE] SvidRotation started' + end + + def stop + @running = false + begin + @thread&.wakeup + rescue ThreadError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.svid_rotation.stop') + nil + end + @thread&.join(3) + if @thread&.alive? + log.warn '[SPIFFE] SvidRotation thread did not stop within timeout' + else + @thread = nil + end + log.info '[SPIFFE] SvidRotation stopped' + end + + def running? + (@running && @thread&.alive?) || false + end + + def rotate! + svid = @client.fetch_x509_svid + @mutex.synchronize do + @current_svid = svid + @issued_at = Time.now + end + log.info("[SPIFFE] SVID rotated: id=#{svid.spiffe_id} expiry=#{svid.expiry}") + svid + end + + def needs_renewal? + svid = nil + issued_at = nil + @mutex.synchronize do + svid = @current_svid + issued_at = @issued_at + end + + return true if svid.nil? || issued_at.nil? + return true if svid.expired? + + total = svid.expiry - issued_at + return true if total <= 0 + + remaining = svid.expiry - Time.now + fraction = remaining / total + fraction < renewal_window + end + + private + + def rotation_loop + begin + rotate! + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.svid_rotation.rotation_loop') + log.error("[SPIFFE] Initial SVID fetch failed: #{e.message}") + end + loop_check + end + + def loop_check + while @running + interruptible_sleep(@check_interval) + next unless @running && needs_renewal? + + begin + log.info('[SPIFFE] SVID renewal window reached, rotating current SVID') + rotate! + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.svid_rotation.loop_check') + log.error("[SPIFFE] SVID rotation failed: #{e.message}") + end + end + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.svid_rotation.loop_check') + log.error("[SPIFFE] SvidRotation loop error: #{e.message}") + retry if @running + end + + def interruptible_sleep(seconds) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds + loop do + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + break if remaining <= 0 || !@running + + sleep([remaining, 1.0].min) + end + end + + def renewal_window + return SVID_RENEWAL_RATIO unless defined?(Legion::Settings) + + security = Legion::Settings[:security] + return SVID_RENEWAL_RATIO if security.nil? + + spiffe = security[:spiffe] || security['spiffe'] || {} + spiffe[:renewal_window] || spiffe['renewal_window'] || SVID_RENEWAL_RATIO + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.svid_rotation.renewal_window') + SVID_RENEWAL_RATIO + end + end + end + end +end diff --git a/lib/legion/crypt/spiffe/workload_api_client.rb b/lib/legion/crypt/spiffe/workload_api_client.rb new file mode 100644 index 0000000..2e364f7 --- /dev/null +++ b/lib/legion/crypt/spiffe/workload_api_client.rb @@ -0,0 +1,443 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'socket' +require 'openssl' + +module Legion + module Crypt + module Spiffe + # Minimal SPIFFE Workload API client. + # + # The SPIFFE Workload API is served over a Unix domain socket by a local + # SPIRE agent. The wire protocol is gRPC/HTTP2, but we avoid pulling in + # a full gRPC stack by implementing just enough of the HTTP/2 framing to + # send a single unary RPC call and parse a single response. + # + # For environments that cannot make a real SPIRE call (CI, lite mode, + # no socket present) the client returns a self-signed fallback SVID so + # that callers never have to special-case the nil case. + class WorkloadApiClient + include Legion::Logging::Helper + + # gRPC content-type and method path for the Workload API FetchX509SVID RPC. + GRPC_CONTENT_TYPE = 'application/grpc' + FETCH_X509_METHOD = '/spiffe.workload.SpiffeWorkloadAPI/FetchX509SVID' + FETCH_JWT_METHOD = '/spiffe.workload.SpiffeWorkloadAPI/FetchJWTSVID' + + # Handshake + settings frames required to open an HTTP/2 connection. + HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + HTTP2_SETTINGS_FRAME = "\x00\x00\x00\x04\x00\x00\x00\x00\x00".b + + CONNECT_TIMEOUT = 5 + READ_TIMEOUT = 10 + + def initialize(socket_path: nil, trust_domain: nil, allow_x509_fallback: nil) + @socket_path = socket_path || Legion::Crypt::Spiffe.socket_path + @trust_domain = trust_domain || Legion::Crypt::Spiffe.trust_domain + @allow_x509_fallback = allow_x509_fallback.nil? ? Legion::Crypt::Spiffe.allow_x509_fallback? : allow_x509_fallback + end + + # Fetch an X.509 SVID from the SPIRE Workload API. + # Returns a populated X509Svid struct. + # Falls back to a self-signed certificate when the Workload API is unavailable. + def fetch_x509_svid + log.info("[SPIFFE] Fetching X.509 SVID from Workload API socket=#{@socket_path}") + raw = call_workload_api(FETCH_X509_METHOD, '') + parse_x509_svid_response(raw) + rescue WorkloadApiError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Errno::EPIPE => e + handle_exception(e, level: :warn, operation: 'crypt.spiffe.workload_api_client.fetch_x509_svid', + socket_path: @socket_path, fallback: @allow_x509_fallback) + unless @allow_x509_fallback + log.error("[SPIFFE] Workload API unavailable (#{e.message}); X.509 fallback disabled") + raise SvidError, "Failed to fetch X.509 SVID: #{e.message}" + end + + log.warn("[SPIFFE] Workload API unavailable (#{e.message}); using self-signed fallback") + self_signed_fallback + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.workload_api_client.fetch_x509_svid', + socket_path: @socket_path) + log.error("[SPIFFE] X.509 SVID fetch failed: #{e.message}") + raise + end + + # Fetch a JWT SVID from the SPIRE Workload API for the given audience. + def fetch_jwt_svid(audience:) + log.info("[SPIFFE] Fetching JWT SVID from Workload API audience=#{audience}") + payload = encode_jwt_request(audience) + raw = call_workload_api(FETCH_JWT_METHOD, payload) + parse_jwt_svid_response(raw, audience) + rescue WorkloadApiError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Errno::EPIPE => e + handle_exception(e, level: :warn, operation: 'crypt.spiffe.workload_api_client.fetch_jwt_svid', + socket_path: @socket_path, audience: audience) + log.warn("[SPIFFE] JWT SVID fetch failed (#{e.message})") + raise SvidError, "Failed to fetch JWT SVID for audience '#{audience}': #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.workload_api_client.fetch_jwt_svid', + socket_path: @socket_path, audience: audience) + log.error("[SPIFFE] JWT SVID fetch failed: #{e.message}") + raise SvidError, "Failed to fetch JWT SVID for audience '#{audience}': #{e.message}" + end + + # Returns true when the SPIRE agent socket exists and is reachable. + def available? + return false unless ::File.exist?(@socket_path) + + sock = UNIXSocket.new(@socket_path) + sock.close + true + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.available', + socket_path: @socket_path) + false + end + + private + + # Minimal HTTP/2 + gRPC unary call over a Unix domain socket. + # This is intentionally simple: one request frame, one response frame. + def call_workload_api(method_path, request_body) + log.debug("[SPIFFE] Calling Workload API method=#{method_path}") + sock = connect_socket + begin + send_grpc_request(sock, method_path, request_body) + read_grpc_response(sock) + ensure + close_workload_api_socket(sock, method_path) + end + end + + def close_workload_api_socket(sock, method_path) + sock.close + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.close_socket', + method_path: method_path, socket_path: @socket_path) + nil + end + + def connect_socket + raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'" unless ::File.exist?(@socket_path) + + sock = UNIXSocket.new(@socket_path) + # Write HTTP/2 connection preface and initial SETTINGS frame. + sock.write(HTTP2_PREFACE) + sock.write(HTTP2_SETTINGS_FRAME) + sock.flush + sock + rescue Errno::ENOENT => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.connect_socket', + socket_path: @socket_path) + raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'" + rescue Errno::ECONNREFUSED, Errno::EACCES => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.connect_socket', + socket_path: @socket_path) + raise WorkloadApiError, "Cannot connect to SPIRE agent socket: #{e.message}" + end + + # Build and send a minimal gRPC/HTTP2 HEADERS + DATA frame. + # We encode only the fields the SPIRE agent needs to accept the request. + def send_grpc_request(sock, method_path, body) + headers = build_grpc_headers(method_path) + headers_frame = encode_http2_frame(type: 0x01, flags: 0x04, stream_id: 1, payload: headers) + data_frame = encode_grpc_data_frame(body) + sock.write(headers_frame + data_frame) + sock.flush + end + + def build_grpc_headers(method_path) + # Minimal set of pseudo-headers and gRPC headers encoded as HPACK literals. + # We use the no-indexing literal representation for simplicity. + encode_header(':method', 'POST') + + encode_header(':path', method_path) + + encode_header(':scheme', 'http') + + encode_header(':authority', 'localhost') + + encode_header('content-type', GRPC_CONTENT_TYPE) + + encode_header('te', 'trailers') + end + + # Encode a single HPACK literal header field (no indexing). + def encode_header(name, value) + name_bytes = name.b + value_bytes = value.b + [0x00].pack('C') + + encode_hpack_string(name_bytes) + + encode_hpack_string(value_bytes) + end + + def encode_hpack_string(bytes) + # Length prefix (non-Huffman). + len = bytes.bytesize + if len < 128 + [len].pack('C') + bytes + else + # Multi-byte length encoding (RFC 7541 §5.1). + parts = [0x80 | (len & 0x7F)].pack('C') + len >>= 7 + parts += [(len.positive? ? 0x80 : 0x00) | (len & 0x7F)].pack('C') + parts + bytes + end + end + + # Encode a gRPC message as a DATA frame (5-byte gRPC header + body). + def encode_grpc_data_frame(body) + grpc_header = [0, body.bytesize].pack('CN') # compressed-flag + length + payload = grpc_header + body.b + encode_http2_frame(type: 0x00, flags: 0x01, stream_id: 1, payload: payload) + end + + # Build an HTTP/2 frame (RFC 7540 §4.1). + def encode_http2_frame(type:, flags:, stream_id:, payload:) + length = payload.bytesize + # 3-byte length + 1-byte type + 1-byte flags + 4-byte stream_id (MSB=0) + [length >> 16, (length >> 8) & 0xFF, length & 0xFF, type, flags].pack('CCCCC') + + [stream_id & 0x7FFFFFFF].pack('N') + + payload.b + end + + def read_grpc_response(sock) + # Read until we see a DATA frame containing a gRPC message or timeout. + deadline = Time.now + READ_TIMEOUT + buffer = ''.b + + loop do + raise WorkloadApiError, 'Workload API read timeout' if Time.now > deadline + + ready = sock.wait_readable(1.0) + next unless ready + + chunk = sock.read_nonblock(4096, exception: false) + break if chunk == :wait_readable || chunk.nil? + + buffer += chunk.b + result = extract_grpc_body(buffer) + return result if result + end + + raise WorkloadApiError, 'No valid gRPC response received from Workload API' + end + + # Scan the raw HTTP/2 buffer for a DATA frame (type=0x00) that contains + # a non-empty gRPC message and return the message body bytes. + def extract_grpc_body(buffer) + pos = 0 + while pos + 9 <= buffer.bytesize + frame_length = (buffer.getbyte(pos) << 16) | (buffer.getbyte(pos + 1) << 8) | buffer.getbyte(pos + 2) + frame_type = buffer.getbyte(pos + 3) + pos += 9 # skip frame header + + if pos + frame_length > buffer.bytesize + # Incomplete frame — need more data. + return nil + end + + payload = buffer.byteslice(pos, frame_length) + pos += frame_length + + next unless frame_type.zero? && payload && payload.bytesize >= 5 + + # gRPC message: 1-byte compressed flag + 4-byte length + body + compressed = payload.getbyte(0) + msg_length = (payload.getbyte(1) << 24) | (payload.getbyte(2) << 16) | + (payload.getbyte(3) << 8) | payload.getbyte(4) + next if msg_length.zero? + + msg_body = payload.byteslice(5, msg_length) + next if msg_body.nil? || msg_body.bytesize < msg_length + + # Compressed gRPC responses are not expected from SPIRE; skip them. + next unless compressed.zero? + + return msg_body + end + nil + end + + # Minimal protobuf encoding for JWTSVIDParams { audience: [string], id: SpiffeID }. + # We only need field 1 (audience, repeated string). + def encode_jwt_request(audience) + audience_bytes = audience.b + # Field 1, wire type 2 (length-delimited) = tag 0x0A + "\n#{[audience_bytes.bytesize].pack('C')}#{audience_bytes}" + end + + # Parse the raw protobuf bytes from FetchX509SVIDResponse into an X509Svid. + # Field layout (spiffe.workload.X509SVIDResponse.svids[0]): + # svids: repeated X509SVID (field 1) + # spiffe_id: string (field 1) + # x509_svid: bytes (field 2) — DER-encoded cert chain + # x509_svid_key: bytes (field 3) — DER-encoded private key (PKCS8) + # bundle: bytes (field 4) — DER-encoded CA bundle + def parse_x509_svid_response(raw) + svid_bytes = extract_proto_field(raw, field_number: 1) + raise SvidError, 'Empty X.509 SVID response from Workload API' if svid_bytes.nil? || svid_bytes.empty? + + spiffe_id_str = extract_proto_string(svid_bytes, field_number: 1) + cert_der = extract_proto_bytes(svid_bytes, field_number: 2) + key_der = extract_proto_bytes(svid_bytes, field_number: 3) + bundle_der = extract_proto_bytes(svid_bytes, field_number: 4) + + raise SvidError, 'X.509 SVID missing certificate data' if cert_der.nil? || cert_der.empty? + raise SvidError, 'X.509 SVID missing private key data' if key_der.nil? || key_der.empty? + + cert = OpenSSL::X509::Certificate.new(cert_der) + key = OpenSSL::PKey.read(key_der) + bundle_pem = bundle_der ? OpenSSL::X509::Certificate.new(bundle_der).to_pem : nil + spiffe_id = Legion::Crypt::Spiffe.parse_id(spiffe_id_str) + + X509Svid.new( + spiffe_id: spiffe_id, + cert_pem: cert.to_pem, + key_pem: key.private_to_pem, + bundle_pem: bundle_pem, + expiry: cert.not_after, + source: :spire + ) + rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::PKeyError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.workload_api_client.parse_x509_svid_response') + raise SvidError, "Failed to parse X.509 SVID: #{e.message}" + end + + # Parse the raw protobuf bytes from FetchJWTSVIDResponse into a JwtSvid. + # Field layout: + # svids: repeated JWTSVID (field 1) + # spiffe_id: string (field 1) + # svid: string (field 2) — the JWT token + def parse_jwt_svid_response(raw, audience) + svid_bytes = extract_proto_field(raw, field_number: 1) + raise SvidError, 'Empty JWT SVID response from Workload API' if svid_bytes.nil? || svid_bytes.empty? + + spiffe_id_str = extract_proto_string(svid_bytes, field_number: 1) + token = extract_proto_string(svid_bytes, field_number: 2) + + raise SvidError, 'JWT SVID missing token' if token.nil? || token.empty? + + expiry = extract_jwt_expiry(token) + spiffe_id = Legion::Crypt::Spiffe.parse_id(spiffe_id_str) + + JwtSvid.new( + spiffe_id: spiffe_id, + token: token, + audience: audience, + expiry: expiry, + source: :spire + ) + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.workload_api_client.parse_jwt_svid_response', + audience: audience) + raise + end + + # Build a self-signed X.509 SVID for use when SPIRE is not available. + # The SPIFFE ID is placed in the SAN URI extension per the SPIFFE spec. + # The Subject CN is a plain workload name (no URI) so OpenSSL parses cleanly. + def self_signed_fallback + log.info("[SPIFFE] Generating self-signed fallback SVID trust_domain=#{@trust_domain}") + key = OpenSSL::PKey::EC.generate('prime256v1') + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = OpenSSL::BN.rand(128) + cert.not_before = Time.now + cert.not_after = Time.now + 3600 + + spiffe_id_str = "#{SPIFFE_SCHEME}://#{@trust_domain}/workload/legion" + subject = OpenSSL::X509::Name.parse('/CN=legion-fallback-svid') + cert.subject = subject + cert.issuer = subject + cert.public_key = key + + ext_factory = OpenSSL::X509::ExtensionFactory.new(cert, cert) + cert.add_extension(ext_factory.create_extension('subjectAltName', "URI:#{spiffe_id_str}", false)) + cert.add_extension(ext_factory.create_extension('basicConstraints', 'CA:FALSE', true)) + cert.sign(key, OpenSSL::Digest.new('SHA256')) + + X509Svid.new( + spiffe_id: Legion::Crypt::Spiffe.parse_id(spiffe_id_str), + cert_pem: cert.to_pem, + key_pem: key.private_to_pem, + bundle_pem: nil, + expiry: cert.not_after, + source: :fallback + ) + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.spiffe.workload_api_client.self_signed_fallback', + trust_domain: @trust_domain) + log.error("[SPIFFE] Self-signed fallback generation failed: #{e.message}") + raise + end + + # --- Minimal protobuf decoder --- + # Only handles wire types 0 (varint) and 2 (length-delimited). + + def extract_proto_field(bytes, field_number:) + pos = 0 + while pos < bytes.bytesize + tag, consumed = decode_varint(bytes, pos) + pos += consumed + wire_type = tag & 0x07 + field = tag >> 3 + + case wire_type + when 0 # varint — skip + _, consumed = decode_varint(bytes, pos) + pos += consumed + when 2 # length-delimited + len, consumed = decode_varint(bytes, pos) + pos += consumed + data = bytes.byteslice(pos, len) + pos += len + return data if field == field_number + else + break # Unknown wire type — stop parsing + end + end + nil + end + + def extract_proto_string(bytes, field_number:) + raw = extract_proto_field(bytes, field_number: field_number) + raw&.force_encoding('UTF-8') + end + + alias extract_proto_bytes extract_proto_field + + # Decode a protobuf varint starting at +start+ in +bytes+. + # Returns [decoded_value, bytes_consumed]. + def decode_varint(bytes, start) + result = 0 + shift = 0 + current = start + loop do + byte = bytes.getbyte(current) + return [result, 0] if byte.nil? + + current += 1 + result |= (byte & 0x7F) << shift + shift += 7 + break unless (byte & 0x80).nonzero? + end + [result, current - start] + end + + # Extract the `exp` claim from the JWT payload without verifying the signature. + def extract_jwt_expiry(token) + parts = token.split('.') + return Time.now + 3600 unless parts.length >= 2 + + payload_json = Base64.urlsafe_decode64("#{parts[1]}==") + claims = begin + Legion::JSON.parse(payload_json) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.extract_jwt_expiry') + {} + end + exp = claims['exp'] || claims[:exp] + exp ? Time.at(exp.to_i) : Time.now + 3600 + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.extract_jwt_expiry') + Time.now + 3600 + end + end + end + end +end diff --git a/lib/legion/crypt/tls.rb b/lib/legion/crypt/tls.rb new file mode 100644 index 0000000..cd84d3d --- /dev/null +++ b/lib/legion/crypt/tls.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module TLS + TLS_PORTS = { + 5671 => 'amqp', + 6380 => 'redis', + 11_207 => 'memcached' + }.freeze + + extend Legion::Logging::Helper + + class << self + def resolve(tls_config, port: nil) + config = symbolize_keys(migrate_legacy(tls_config || {})) + + enabled = config[:enabled] + auto_detected = false + + if enabled.nil? && port && TLS_PORTS.key?(port.to_i) + enabled = true + auto_detected = true + log.warn("TLS auto-enabled for port #{port}") + end + + enabled = false if enabled.nil? + + verify = normalize_verify(config[:verify]) + ca = resolve_uri(config[:ca]) + cert = resolve_uri(config[:cert]) + key = resolve_uri(config[:key]) + + if verify == :mutual && (cert.nil? || key.nil?) + log.warn('TLS mutual requested but cert or key missing, downgrading to peer') + verify = :peer + end + + log.info "TLS resolved enabled=#{enabled} verify=#{verify} auto_detected=#{auto_detected}" + + { + enabled: enabled, + verify: verify, + ca: ca, + cert: cert, + key: key, + auto_detected: auto_detected + } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.tls.resolve', port: port) + raise + end + + def migrate_legacy(config) + config = symbolize_keys(config) + return config unless config.key?(:use_tls) && !config.key?(:enabled) + + { + enabled: config[:use_tls], + verify: config[:verify_peer] ? 'peer' : 'none', + ca: config[:ca_certs], + cert: config[:tls_cert], + key: config[:tls_key] + } + end + + private + + def symbolize_keys(hash) + hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } + end + + def normalize_verify(value) + case value.to_s + when 'none' then :none + when 'mutual' then :mutual + else :peer + end + end + + def resolve_uri(value) + return nil if value.nil? + + if defined?(Legion::Settings::Resolver) + Legion::Settings::Resolver.resolve_value(value) + else + value + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.tls.resolve_uri') + raise + end + end + end + end +end diff --git a/lib/legion/crypt/token_renewer.rb b/lib/legion/crypt/token_renewer.rb new file mode 100644 index 0000000..69db3b9 --- /dev/null +++ b/lib/legion/crypt/token_renewer.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'legion/crypt/kerberos_auth' + +module Legion + module Crypt + class TokenRenewer + include Legion::Logging::Helper + + INITIAL_BACKOFF = 30 + MAX_BACKOFF = 600 + MIN_SLEEP = 30 + RENEWAL_RATIO = 0.75 + + attr_reader :cluster_name + + def initialize(cluster_name:, config:, vault_client:) + @cluster_name = cluster_name + @config = config + @vault_client = vault_client + @thread = nil + @stop = false + @backoff = INITIAL_BACKOFF + end + + def start + return if running? + + @stop = false + @thread = Thread.new { renewal_loop } + @thread.name = "vault-renewer-#{@cluster_name}" + log.info("TokenRenewer[#{@cluster_name}]: token renewal thread started") + end + + def stop + @stop = true + @thread&.wakeup + rescue ThreadError => e + handle_exception(e, level: :debug, operation: 'crypt.token_renewer.stop', cluster_name: @cluster_name) + nil + ensure + stop_thread_and_revoke + end + + def running? + @thread&.alive? == true + end + + def renew_token + result = @vault_client.auth_token.renew_self + @config[:lease_duration] = result.auth.lease_duration + @config[:renewable] = result.auth.renewable? if result.auth.respond_to?(:renewable?) + log.info("TokenRenewer[#{@cluster_name}]: token renewed, ttl=#{result.auth.lease_duration}s") + true + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.renew_token', cluster_name: @cluster_name) + log.warn("TokenRenewer[#{@cluster_name}]: token renewal failed: #{e.message}") + false + end + + def reauth_kerberos + krb_config = @config[:kerberos] || {} + result = Legion::Crypt::KerberosAuth.login( + vault_client: @vault_client, + service_principal: krb_config[:service_principal], + auth_path: krb_config[:auth_path] || KerberosAuth::DEFAULT_AUTH_PATH + ) + + @config[:token] = result[:token] + @config[:lease_duration] = result[:lease_duration] + @config[:renewable] = result[:renewable] + @config[:connected] = true + @vault_client.token = result[:token] + log.info("TokenRenewer[#{@cluster_name}]: re-authenticated via Kerberos, ttl=#{result[:lease_duration]}s") + + reissue_all_leases + true + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.reauth_kerberos', cluster_name: @cluster_name) + log.warn("TokenRenewer[#{@cluster_name}]: Kerberos re-auth failed: #{e.message}") + false + end + + def sleep_duration + lease_duration = @config[:lease_duration].to_i + duration = [(lease_duration * RENEWAL_RATIO).to_i, 1].max + return [duration, lease_duration - 1].min if lease_duration.positive? && lease_duration < MIN_SLEEP + + [duration, MIN_SLEEP].max + end + + def next_backoff + current = @backoff + @backoff = [@backoff * 2, MAX_BACKOFF].min + current + end + + def reset_backoff + @backoff = INITIAL_BACKOFF + end + + private + + def renewal_loop + interruptible_sleep(sleep_duration) + + until @stop + if @config[:renewable] + if renew_token || reauth_kerberos + on_renewal_success + else + on_renewal_failure + end + else + if reauth_kerberos # rubocop:disable Style/IfInsideElse + on_renewal_success + else + on_renewal_failure + end + end + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.renewal_loop', cluster_name: @cluster_name) + log.warn("TokenRenewer[#{@cluster_name}]: renewal loop error: #{e.message}") + retry unless @stop + end + + def on_renewal_success + reset_backoff + interruptible_sleep(sleep_duration) + end + + def on_renewal_failure + @config[:connected] = false + delay = next_backoff + log.warn("TokenRenewer[#{@cluster_name}]: backoff retry in #{delay}s") + interruptible_sleep(delay) + end + + def reissue_all_leases + return unless defined?(Legion::Crypt::LeaseManager) + + Legion::Crypt::LeaseManager.instance.reissue_all + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.reissue_all_leases', cluster_name: @cluster_name) + log.warn("TokenRenewer[#{@cluster_name}]: failed to reissue leases after reauth: #{e.message}") + end + + def interruptible_sleep(seconds) + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds + loop do + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + break if remaining <= 0 || @stop + + sleep([remaining, 1.0].min) + end + end + + def stop_thread_and_revoke + return unless @thread + + log.info("TokenRenewer[#{@cluster_name}]: stopping token renewal thread") + @thread.join(5) + thread_still_running = @thread.alive? + + if thread_still_running + log.warn("TokenRenewer[#{@cluster_name}]: token renewal thread did not stop within timeout; skipping token revocation") + else + @thread = nil + revoke_token + log.info("TokenRenewer[#{@cluster_name}]: token renewal thread stopped") + end + end + + def revoke_token + return unless @vault_client&.token + return unless @config[:auth_method]&.to_s == 'kerberos' + + @vault_client.auth_token.revoke_self + log.info("TokenRenewer[#{@cluster_name}]: Vault token revoked") + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.revoke_token', cluster_name: @cluster_name) + log.warn("TokenRenewer[#{@cluster_name}]: Vault token revoke failed: #{e.message}") + end + end + end +end diff --git a/lib/legion/crypt/vault.rb b/lib/legion/crypt/vault.rb index e59569d..0078d51 100644 --- a/lib/legion/crypt/vault.rb +++ b/lib/legion/crypt/vault.rb @@ -1,63 +1,117 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'uri' require 'vault' module Legion module Crypt module Vault + include Legion::Logging::Helper + attr_accessor :sessions def settings Legion::Settings[:crypt][:vault] end - def connect_vault # rubocop:disable Metrics/AbcSize + def connect_vault @sessions = [] - ::Vault.address = "#{Legion::Settings[:crypt][:vault][:protocol]}://#{Legion::Settings[:crypt][:vault][:address]}:#{Legion::Settings[:crypt][:vault][:port]}" # rubocop:disable Layout/LineLength + vault_settings = Legion::Settings[:crypt][:vault] + ::Vault.address = resolve_vault_address(vault_settings) + ::Vault.ssl_verify = resolve_ssl_verify(vault_settings[:tls]) + namespace = vault_settings[:vault_namespace] + log.info "Vault connection requested address=#{::Vault.address} namespace=#{namespace || 'none'} ssl_verify=#{::Vault.ssl_verify}" Legion::Settings[:crypt][:vault][:token] = ENV['VAULT_DEV_ROOT_TOKEN_ID'] if ENV.key? 'VAULT_DEV_ROOT_TOKEN_ID' return nil if Legion::Settings[:crypt][:vault][:token].nil? ::Vault.token = Legion::Settings[:crypt][:vault][:token] - Legion::Settings[:crypt][:vault][:connected] = true if ::Vault.sys.health_status.initialized? - return unless Legion.const_defined? 'Extensions::Actors::Every' - - require_relative 'vault_renewer' - @renewer = Legion::Crypt::Vault::Renewer.new + ::Vault.namespace = namespace if namespace + if vault_healthy? + Legion::Settings[:crypt][:vault][:connected] = true + log.info "Vault connected at #{::Vault.address} (namespace=#{namespace || 'none'})" + end rescue StandardError => e - Legion::Logging.error e.message + log_vault_connection_error(e) Legion::Settings[:crypt][:vault][:connected] = false false end - def read(path, type = 'legion') - full_path = type.nil? || type.empty? ? "#{type}/#{path}" : path - lease = ::Vault.logical.read(full_path) - add_session(path: lease.lease_id) if lease.respond_to? :lease_id - lease.data + def vault_healthy? + ::Vault.sys.health_status.initialized? + rescue ::Vault::HTTPError => e + # 429 = standby, 472 = DR secondary, 473 = performance standby + # All indicate an initialized, healthy Vault — just not the active node. + return true if e.message =~ /\b(429|472|473)\b/ + + raise end - def get(path) - result = ::Vault.kv(settings[:vault][:kv_path]).read(path) - return nil if result.nil? + def read(path, type = nil, cluster_name: nil) + full_path = type.nil? || type.empty? ? path : "#{type}/#{path}" + log_read_context(full_path, cluster_name: cluster_name) + lease = logical_client(cluster_name: cluster_name).read(full_path) + if lease.nil? + log_vault_debug("Vault read: #{full_path} returned nil") + return nil + end + add_session(path: lease.lease_id) if lease.respond_to?(:lease_id) && lease.lease_id && !lease.lease_id.empty? + data = lease.data + log_vault_debug("Vault read: #{full_path} returned keys=#{data&.keys&.inspect}") + unwrap_kv_v2(data, full_path) + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault.read', path: full_path) + raise + end + + def get(path, cluster_name: nil) + log.debug "Vault kv get: path=#{path}" + result = kv_client(cluster_name: cluster_name).read(path) + if result.nil? + log.debug "Vault kv get: #{path} returned nil" + return nil + end + + log.debug "Vault kv get: #{path} returned keys=#{result.data&.keys&.inspect}" result.data + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault.get', path: path) + raise end - def write(path, **hash) - ::Vault.kv(settings[:vault][:kv_path]).write(path, **hash) + def write(path, cluster_name: nil, **hash) + log.info "Vault kv write requested path=#{path}" + kv_client(cluster_name: cluster_name).write(path, **hash) + log.info "Vault kv write complete path=#{path}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault.write', path: path) + raise end - def exist?(path) - !::Vault.kv(settings[:vault][:kv_path]).read_metadata(path).nil? + def delete(path, cluster_name: nil) + logical_client(cluster_name: cluster_name).delete(path) + log.info "Vault delete complete path=#{path}" + { success: true, path: path } + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault.delete', path: path) + { success: false, path: path, error: e.message } + end + + def exist?(path, cluster_name: nil) + !kv_client(cluster_name: cluster_name).read_metadata(path).nil? end def add_session(path:) + @sessions ||= [] @sessions.push(path) end def close_sessions return if @sessions.nil? - Legion::Logging.info 'Closing all Legion::Crypt vault sessions' + log.info 'Closing all Legion::Crypt vault sessions' @sessions.each do |session| close_session(session: session) @@ -68,7 +122,7 @@ def shutdown_renewer return unless Legion::Settings[:crypt][:vault][:connected] return if @renewer.nil? - Legion::Logging.debug 'Shutting down Legion::Crypt::Vault::Renewer' + log.info 'Shutting down Legion::Crypt::Vault::Renewer' @renewer.cancel end @@ -81,14 +135,110 @@ def renew_session(session:) end def renew_sessions(**_opts) - @sessions.each do |session| - renew_session(session: session) + log.debug 'Vault renewal cycle start' + result = if respond_to?(:connected_clusters) && connected_clusters.any? + renew_cluster_tokens + else + @sessions.each do |session| + renew_session(session: session) + end + end + log.debug 'Vault renewal cycle complete' + result + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault.renew_sessions') + raise + end + + def renew_cluster_tokens + connected_clusters.each_key do |name| + client = vault_client(name) + client.auth_token.renew_self + log.info "Vault token renewed for cluster #{name}" + rescue StandardError => e + log_vault_error(name, e) end end def vault_exists?(name) ::Vault.sys.mounts.key?(name.to_sym) end + + private + + def kv_client(cluster_name: nil) + if respond_to?(:connected_clusters) && connected_clusters.any? + connected_vault_client(cluster_name).kv(settings[:kv_path]) + else + raise ArgumentError, "Vault cluster not connected: #{cluster_name}" if cluster_name + + ::Vault.kv(settings[:kv_path]) + end + end + + def logical_client(cluster_name: nil) + if respond_to?(:connected_clusters) && connected_clusters.any? + connected_vault_client(cluster_name).logical + else + raise ArgumentError, "Vault cluster not connected: #{cluster_name}" if cluster_name + + ::Vault.logical + end + end + + def log_read_context(full_path, cluster_name: nil) + namespace = if respond_to?(:connected_clusters) && connected_clusters.any? + client = connected_vault_client(cluster_name) + client.respond_to?(:namespace) ? client.namespace : 'n/a' + else + 'n/a (global client)' + end + log.debug "Vault read: path=#{full_path}, namespace=#{namespace}" + end + + def connected_vault_client(cluster_name = nil) + selected_cluster = selected_connected_cluster_name(cluster_name) + raise ArgumentError, "Vault cluster not connected: #{cluster_name}" if selected_cluster.nil? && cluster_name + + selected_cluster ? vault_client(selected_cluster) : nil + end + + def unwrap_kv_v2(data, full_path) + return data unless data.is_a?(Hash) && data.key?(:data) && data[:data].is_a?(Hash) && data.key?(:metadata) + + log_vault_debug("Vault read: #{full_path} detected KV v2 envelope, unwrapping :data key") + data[:data] + end + + def resolve_ssl_verify(tls_config) + return true if tls_config.nil? + + verify = tls_config[:verify]&.to_s + verify != 'none' + end + + def resolve_vault_address(vault_settings) + protocol = vault_settings[:protocol] || 'http' + address = vault_settings[:address] || 'localhost' + port = vault_settings[:port] || 8200 + + if address.match?(%r{\Ahttps?://}) + uri = URI.parse(address) + protocol = uri.scheme + address = uri.host + port = uri.port if vault_settings[:port].nil? + end + + "#{protocol}://#{address}:#{port}" + end + + def log_vault_connection_error(error) + handle_exception(error, level: :error, operation: 'crypt.vault.connect_vault', address: ::Vault.address) + end + + def log_vault_debug(message) + log.debug(message) + end end end end diff --git a/lib/legion/crypt/vault_cluster.rb b/lib/legion/crypt/vault_cluster.rb new file mode 100644 index 0000000..9a02eb6 --- /dev/null +++ b/lib/legion/crypt/vault_cluster.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +# Ruby 4.0 freezes OpenSSL::SSL::SSLContext::DEFAULT_PARAMS by default. +# The vault gem (0.18.x) mutates this hash in Vault.setup! — replace it +# with a mutable dup so the require succeeds on Ruby 4.0+. +require 'legion/logging/helper' +require 'openssl' +if OpenSSL::SSL::SSLContext::DEFAULT_PARAMS.frozen? + unfrozen = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS.dup + OpenSSL::SSL::SSLContext.send(:remove_const, :DEFAULT_PARAMS) + OpenSSL::SSL::SSLContext.const_set(:DEFAULT_PARAMS, unfrozen) +end + +require 'vault' + +module Legion + module Crypt + module VaultCluster + include Legion::Logging::Helper + + def vault_client(name = nil) + name = resolve_cluster_name(name) + @vault_clients ||= {} + @vault_clients[name] ||= build_vault_client(clusters[name]) + end + + def cluster(name = nil) + name = resolve_cluster_name(name) + clusters[name] + end + + def default_cluster_name + name = vault_settings[:default] + name ? name.to_sym : clusters.keys.first + end + + def clusters + vault_settings[:clusters] || {} + end + + def connected_clusters + clusters.select { |_, config| config[:token] && config[:connected] } + end + + def connect_all_clusters + log.info "Vault cluster connect requested configured_clusters=#{clusters.size}" + log_vault_debug("connect_all_clusters: #{clusters.size} cluster(s) configured") + results = {} + clusters.each do |name, config| + log_vault_debug("connect_all_clusters: #{name} (auth_method=#{config[:auth_method].inspect})") + case config[:auth_method]&.to_s + when 'kerberos' + results[name] = connect_kerberos_cluster(name, config) + when 'ldap' + next # handled by ldap_login_all + else + next unless config[:token] + + client = vault_client(name) + config[:connected] = cluster_healthy?(client) + results[name] = config[:connected] + log_cluster_connected(name, config) if config[:connected] + end + rescue StandardError => e + config[:connected] = false + results[name] = false + log_vault_error(name, e, operation: 'crypt.vault_cluster.connect_all_clusters') + end + + connected = results.select { |_, v| v } + log.info "Vault cluster connect complete connected=#{connected.size} attempted=#{results.size}" + log_vault_debug("connect_all_clusters: #{connected.size}/#{results.size} connected") + sync_vault_connected(connected.any?) + results + end + + private + + def cluster_healthy?(client) + client.sys.health_status.initialized? + rescue ::Vault::HTTPError => e + return true if e.message =~ /\b(429|472|473)\b/ + + handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.cluster_healthy') + raise + end + + def sync_vault_connected(connected) + return unless defined?(Legion::Settings) + + Legion::Settings[:crypt][:vault][:connected] = connected + end + + def selected_connected_cluster_name(name = nil) + active_clusters = connected_clusters + return nil if active_clusters.empty? + + if name + cluster_name = name.to_sym + raise ArgumentError, "Vault cluster not connected: #{cluster_name}" unless active_clusters.key?(cluster_name) + + return cluster_name + end + + default_name = vault_settings[:default]&.to_sym + return default_name if default_name && active_clusters.key?(default_name) + + active_clusters.keys.first + end + + def resolve_cluster_name(name) + return name.to_sym if name + + default_cluster_name + end + + def build_vault_client(config) + return nil unless config.is_a?(Hash) + + addr = "#{config[:protocol]}://#{config[:address]}:#{config[:port]}" + ssl_verify = resolve_cluster_ssl_verify(config[:tls]) + log.info "Building Vault client address=#{addr} namespace=#{config[:namespace].inspect} ssl_verify=#{ssl_verify}" + log_vault_debug("build_vault_client: address=#{addr}") + client = ::Vault::Client.new( + address: addr, + token: config[:token], + ssl_verify: ssl_verify + ) + namespace = + if config.key?(:namespace) + config[:namespace] + elsif defined?(Legion::Settings) + crypt_settings = Legion::Settings[:crypt] + crypt_settings.respond_to?(:dig) ? crypt_settings.dig(:vault, :vault_namespace) : nil + end + client.namespace = namespace if namespace + log_vault_debug("build_vault_client: namespace=#{namespace.inspect}") + client + end + + def resolve_cluster_ssl_verify(tls_config) + return true if tls_config.nil? + + verify = tls_config[:verify]&.to_s + verify != 'none' + end + + def log_vault_error(name, error, operation: 'crypt.vault_cluster.error') + handle_exception(error, level: :error, operation: operation, cluster_name: name) + end + + def connect_kerberos_cluster(name, config) + krb_config = config[:kerberos] || {} + spn = krb_config[:service_principal] + auth_path = krb_config[:auth_path] || Legion::Crypt::KerberosAuth::DEFAULT_AUTH_PATH + + log_vault_debug("connect_kerberos_cluster[#{name}]: SPN=#{spn}, auth_path=#{auth_path}, namespace=#{config[:namespace].inspect}") + + unless spn + log_vault_warn(name, 'Kerberos auth missing service_principal, skipping') + config[:connected] = false + return false + end + + require 'legion/crypt/kerberos_auth' + client = vault_client(name) + log_vault_debug("connect_kerberos_cluster[#{name}]: client.namespace=#{client.respond_to?(:namespace) ? client.namespace.inspect : 'n/a'}") + log.info "Connecting Vault cluster #{name} via Kerberos auth_path=#{auth_path}" + + result = Legion::Crypt::KerberosAuth.login( + vault_client: client, + service_principal: spn, + auth_path: auth_path + ) + + config[:token] = result[:token] + config[:lease_duration] = result[:lease_duration] + config[:renewable] = result[:renewable] + config[:connected] = true + vault_client(name).token = result[:token] + log_vault_debug("connect_kerberos_cluster[#{name}]: policies=#{result[:policies].inspect}") + log_cluster_connected(name, config) + true + rescue Legion::Crypt::KerberosAuth::GemMissingError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.connect_kerberos_cluster', cluster_name: name) + log_vault_warn(name, e.message) + config[:connected] = false + false + rescue Legion::Crypt::KerberosAuth::AuthError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_cluster.connect_kerberos_cluster', cluster_name: name) + log_vault_warn(name, "Kerberos auth failed: #{e.message}") + config[:connected] = false + false + end + + def log_cluster_connected(name, config) + log.info "Vault cluster connected: #{name} at #{config[:address]}" + end + + def log_vault_warn(name, message) + log.warn("Vault cluster #{name}: #{message}") + end + + def log_vault_debug(message) + log.debug(message) + end + end + end +end diff --git a/lib/legion/crypt/vault_entity.rb b/lib/legion/crypt/vault_entity.rb new file mode 100644 index 0000000..c68212f --- /dev/null +++ b/lib/legion/crypt/vault_entity.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module VaultEntity + extend Legion::Logging::Helper + + # Create or lookup a Vault entity for a Legion principal. + # Returns the Vault entity ID string, or nil on failure. + def self.ensure_entity(principal_id:, canonical_name:, metadata: {}) + existing = find_by_name(canonical_name) + return existing if existing + + response = vault_logical.write( + 'identity/entity', + name: "legion-#{canonical_name}", + metadata: metadata.merge( + legion_principal_id: principal_id, + legion_canonical_name: canonical_name, + managed_by: 'legion' + ) + ) + extract_id(response) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_entity.ensure_entity', canonical_name: canonical_name) + nil + end + + # Create an alias linking an auth method mount to the entity. + # Idempotent — swallows "already exists" 4xx errors. + def self.ensure_alias(entity_id:, mount_accessor:, alias_name:) + vault_logical.write( + 'identity/entity-alias', + name: alias_name, + canonical_id: entity_id, + mount_accessor: mount_accessor + ) + rescue ::Vault::HTTPClientError => e + if e.message.include?('already exists') + log.debug 'Vault entity alias already exists (idempotent)' + else + handle_exception(e, level: :warn, operation: 'crypt.vault_entity.ensure_alias', alias_name: alias_name) + end + nil + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_entity.ensure_alias', alias_name: alias_name) + nil + end + + # Look up a Vault entity by its Legion canonical name. + # Returns the Vault entity ID string, or nil if not found. + def self.find_by_name(canonical_name) + response = vault_logical.read("identity/entity/name/legion-#{canonical_name}") + extract_id(response) + rescue ::Vault::HTTPClientError => e + unless e.message.match?(/not found|does not exist|404/i) + handle_exception(e, level: :warn, operation: 'crypt.vault_entity.find_by_name', canonical_name: canonical_name) + end + nil + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_entity.find_by_name', canonical_name: canonical_name) + nil + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + def self.vault_logical + if defined?(Legion::Crypt::LeaseManager) + Legion::Crypt::LeaseManager.instance.vault_logical + else + ::Vault.logical + end + end + private_class_method :vault_logical + + # Extract entity ID from a Vault response, supporting both symbol and + # string keys (Vault SDK may return either depending on version/transport). + def self.extract_id(response) + data = response&.data + return nil unless data + + data[:id] || data['id'] + end + private_class_method :extract_id + end + end +end diff --git a/lib/legion/crypt/vault_jwt_auth.rb b/lib/legion/crypt/vault_jwt_auth.rb new file mode 100644 index 0000000..500cf64 --- /dev/null +++ b/lib/legion/crypt/vault_jwt_auth.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + # Vault JWT auth backend integration. + # + # Allows Legion workers to authenticate to Vault using JWT tokens + # via Vault's JWT/OIDC auth method. The worker presents a signed JWT + # and receives a Vault token with policies scoped to the worker's role. + # + # Vault config prerequisites: + # vault auth enable jwt + # vault write auth/jwt/config jwks_url="..." (or bound_issuer + jwt_validation_pubkeys) + # vault write auth/jwt/role/legion-worker bound_audiences="legion" ... + module VaultJwtAuth + DEFAULT_AUTH_PATH = 'auth/jwt/login' + DEFAULT_ROLE = 'legion-worker' + + class AuthError < StandardError; end + + extend Legion::Logging::Helper + + # Authenticate to Vault using a JWT token. + # Returns a Vault token string on success. + # + # @param jwt [String] Signed JWT token (issued by Legion or Entra ID) + # @param role [String] Vault JWT auth role name (default: 'legion-worker') + # @param auth_path [String] Vault auth mount path (default: 'auth/jwt/login') + # @return [Hash] { token:, lease_duration:, policies:, metadata: } + def self.login(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH) + raise AuthError, 'Vault is not connected' unless vault_connected? + + log.info "[crypt:vault_jwt] authenticating role=#{role} auth_path=#{auth_path}" + response = ::Vault.logical.write( + auth_path, + role: role, + jwt: jwt + ) + + raise AuthError, 'Vault JWT auth returned no auth data' unless response&.auth + + { + token: response.auth.client_token, + lease_duration: response.auth.lease_duration, + renewable: response.auth.renewable?, + policies: response.auth.policies, + metadata: response.auth.metadata + } + rescue ::Vault::HTTPClientError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_jwt_auth.login', role: role, auth_path: auth_path, + category: 'client_error') + raise AuthError, "Vault JWT auth failed: #{e.message}" + rescue ::Vault::HTTPServerError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_jwt_auth.login', role: role, auth_path: auth_path, + category: 'server_error') + raise AuthError, "Vault server error during JWT auth: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault_jwt_auth.login', role: role, auth_path: auth_path) + raise if e.is_a?(AuthError) + + raise AuthError, "Vault JWT auth failed: #{e.message}" + end + + # Authenticate and set the Vault client token for subsequent operations. + # This replaces the current Vault token with the JWT-authenticated one. + # + # @return [Hash] Same as login + def self.login!(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH) + result = login(jwt: jwt, role: role, auth_path: auth_path) + ::Vault.token = result[:token] + log.info "[crypt:vault_jwt] authenticated via JWT auth, policies=#{result[:policies].join(',')}" + result + end + + # Issue a Legion JWT and use it to authenticate to Vault in one step. + # Convenience method for workers that need Vault access. + # + # @param worker_id [String] Digital worker ID + # @param owner_msid [String] Worker's owner MSID + # @param role [String] Vault JWT auth role name + # @return [Hash] Same as login + def self.worker_login(worker_id:, owner_msid:, role: DEFAULT_ROLE) + log.info "[crypt:vault_jwt] worker login requested role=#{role} worker_id=#{worker_id}" + jwt = Legion::Crypt::JWT.issue( + { worker_id: worker_id, sub: owner_msid, scope: 'vault', aud: 'legion' }, + signing_key: Legion::Crypt.cluster_secret, + ttl: 300, + issuer: 'legion' + ) + + login(jwt: jwt, role: role) + end + + def self.vault_connected? + defined?(::Vault) && + defined?(Legion::Settings) && + Legion::Settings[:crypt][:vault][:connected] == true + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.vault_jwt_auth.vault_connected?') + false + end + + private_class_method :vault_connected? + end + end +end diff --git a/lib/legion/crypt/vault_kerberos_auth.rb b/lib/legion/crypt/vault_kerberos_auth.rb new file mode 100644 index 0000000..e0376fa --- /dev/null +++ b/lib/legion/crypt/vault_kerberos_auth.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Crypt + module VaultKerberosAuth + DEFAULT_AUTH_PATH = 'auth/kerberos/login' + + class AuthError < StandardError; end + + extend Legion::Logging::Helper + + def self.login(spnego_token:, auth_path: DEFAULT_AUTH_PATH) + raise AuthError, 'Vault is not connected' unless vault_connected? + + log.info "[crypt:vault_kerberos] login requested auth_path=#{auth_path}" + response = ::Vault.logical.write(auth_path, authorization: "Negotiate #{spnego_token}") + raise AuthError, 'Vault Kerberos auth returned no auth data' unless response&.auth + + { + token: response.auth.client_token, + lease_duration: response.auth.lease_duration, + renewable: response.auth.renewable?, + policies: response.auth.policies, + metadata: response.auth.metadata + } + rescue ::Vault::HTTPClientError => e + handle_exception(e, level: :warn, operation: 'crypt.vault_kerberos_auth.login', auth_path: auth_path) + raise AuthError, "Vault Kerberos auth failed: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.vault_kerberos_auth.login', auth_path: auth_path) + raise + end + + def self.login!(spnego_token:, auth_path: DEFAULT_AUTH_PATH) + result = login(spnego_token: spnego_token, auth_path: auth_path) + ::Vault.token = result[:token] + log.info "[crypt:vault_kerberos] authenticated via Kerberos auth, policies=#{result[:policies].join(',')}" + result + end + + def self.vault_connected? + defined?(::Vault) && defined?(Legion::Settings) && + Legion::Settings[:crypt][:vault][:connected] == true + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'crypt.vault_kerberos_auth.vault_connected') + false + end + + private_class_method :vault_connected? + end + end +end diff --git a/lib/legion/crypt/vault_renewer.rb b/lib/legion/crypt/vault_renewer.rb index f6ef30b..f3fe453 100644 --- a/lib/legion/crypt/vault_renewer.rb +++ b/lib/legion/crypt/vault_renewer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'legion/extensions/actors/every' module Legion diff --git a/lib/legion/crypt/version.rb b/lib/legion/crypt/version.rb index eaf2995..3d545ed 100644 --- a/lib/legion/crypt/version.rb +++ b/lib/legion/crypt/version.rb @@ -2,6 +2,6 @@ module Legion module Crypt - VERSION = '1.2.0' + VERSION = '1.5.13' end end diff --git a/scripts/pre-commit-rubocop.sh b/scripts/pre-commit-rubocop.sh new file mode 100755 index 0000000..3be2c1a --- /dev/null +++ b/scripts/pre-commit-rubocop.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Pre-commit hook: run RuboCop with autofix on staged Ruby files. +# Tries rubocop directly, then bundle exec. If the binary is truly +# unavailable (exit 127 / crash / Prism conflict), warns and defers +# to CI. If rubocop runs but reports offenses, fails the commit. +set -uo pipefail + +run_rubocop() { + output=$("$@" -A --force-exclusion "${FILES[@]}" 2>&1) + rc=$? + if [ $rc -eq 0 ] || [ $rc -eq 1 ]; then + # rubocop ran successfully: 0 = clean, 1 = offenses found + echo "$output" + return $rc + fi + # exit > 1 means rubocop crashed / couldn't load. Preserve the output so the + # local failure is visible even when CI remains the final enforcement point. + echo "$output" >&2 + return 2 +} + +FILES=("$@") + +if run_rubocop rubocop; then + exit 0 +elif [ $? -eq 1 ]; then + echo "RuboCop found offenses that could not be auto-corrected." + exit 1 +fi + +if run_rubocop bundle exec rubocop; then + exit 0 +elif [ $? -eq 1 ]; then + echo "RuboCop found offenses that could not be auto-corrected." + exit 1 +fi + +echo "⚠ RuboCop not available locally (Prism conflict?) — CI will enforce." +echo " Run 'ruby -c' to at least verify syntax." +ruby -c "$@" 2>&1 || exit 1 +exit 0 diff --git a/sourcehawk.yml b/sourcehawk.yml deleted file mode 100644 index a228e9b..0000000 --- a/sourcehawk.yml +++ /dev/null @@ -1,4 +0,0 @@ - -config-locations: - - https://raw.githubusercontent.com/optum/.github/main/sourcehawk.yml - diff --git a/spec/legion/cipher_spec.rb b/spec/legion/cipher_spec.rb index 04562c1..7a2579b 100644 --- a/spec/legion/cipher_spec.rb +++ b/spec/legion/cipher_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/crypt' @@ -17,16 +19,61 @@ expect(@crypt.decrypt(message[:enciphered_message], message[:iv])).to eq 'foobar' end + it 'rejects tampered authenticated ciphertext' do + message = @crypt.encrypt('foobar') + prefix, ciphertext, auth_tag = message[:enciphered_message].split(':', 3) + decoded_auth_tag = Base64.strict_decode64(auth_tag).bytes + decoded_auth_tag[-1] ^= 0x01 + tampered_auth_tag = Base64.strict_encode64(decoded_auth_tag.pack('C*')) + + expect do + @crypt.decrypt("#{prefix}:#{ciphertext}:#{tampered_auth_tag}", message[:iv]) + end.to raise_error(OpenSSL::Cipher::CipherError) + end + + it 'raises an actionable error when authenticated ciphertext is missing fields' do + message = @crypt.encrypt('foobar') + prefix, ciphertext = message[:enciphered_message].split(':', 3) + + expect do + @crypt.decrypt("#{prefix}:#{ciphertext}", message[:iv]) + end.to raise_error(ArgumentError, /invalid authenticated ciphertext: missing auth_tag .*auth_tag_present=false/) + end + + it 'raises an actionable error when authenticated ciphertext is missing an iv' do + message = @crypt.encrypt('foobar') + + expect do + @crypt.decrypt(message[:enciphered_message], nil) + end.to raise_error(ArgumentError, /invalid authenticated ciphertext: missing iv .*iv_present=false/) + end + + it 'can decrypt legacy cbc ciphertext' do + cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher.encrypt + cipher.key = @crypt.cs + iv = cipher.random_iv + encrypted_message = Base64.encode64(cipher.update('legacy secret') + cipher.final) + + expect(@crypt.decrypt(encrypted_message, Base64.encode64(iv))).to eq 'legacy secret' + end + it 'can encrypt from keypair' do expect(@crypt.private_key).to be_a OpenSSL::PKey::RSA expect(@crypt.public_key).to be_a String expect(Base64.encode64(@crypt.private_key.public_key.to_s)).to be_a String - expect(@crypt.encrypt_from_keypair(message: 'test')).to be_a String - expect(@crypt.encrypt_from_keypair(message: 'test', pub_key: @crypt.public_key)).to be_a String + expect(@crypt.encrypt_from_keypair(message: 'test')).to start_with('oaep:') + expect(@crypt.encrypt_from_keypair(message: 'test', pub_key: @crypt.public_key)).to start_with('oaep:') end it 'can decrypt from keypair' do encrypt = @crypt.encrypt_from_keypair(message: 'test long message') expect(@crypt.decrypt_from_keypair(message: encrypt)).to eq 'test long message' end + + it 'can decrypt legacy pkcs1 keypair ciphertext' do + encrypted_message = Base64.encode64(@crypt.private_key.public_key.public_encrypt('legacy keypair message')) + + expect(@crypt.decrypt_from_keypair(message: encrypted_message)).to eq 'legacy keypair message' + end end diff --git a/spec/legion/cluster_secret_spec.rb b/spec/legion/cluster_secret_spec.rb index d8c3197..f5ef2f7 100644 --- a/spec/legion/cluster_secret_spec.rb +++ b/spec/legion/cluster_secret_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/crypt/cluster_secret' @@ -7,11 +9,16 @@ @cs = Class.new @cs.extend Legion::Crypt::ClusterSecret - @vault_mock = Module.new do - def self.get(_) - { cluster_secret: SecureRandom.hex(32) } - end - end + @original_cluster_secret = Legion::Settings[:crypt][:cluster_secret] + @original_cs_encrypt_ready = Legion::Settings[:crypt][:cs_encrypt_ready] + @original_vault_settings = Legion::Settings[:crypt][:vault].dup + end + + after do + Legion::Settings[:crypt][:cluster_secret] = @original_cluster_secret + Legion::Settings[:crypt][:cs_encrypt_ready] = @original_cs_encrypt_ready + Legion::Settings[:crypt][:vault].replace(@original_vault_settings) + @cs.remove_instance_variable(:@cs) if @cs.instance_variable_defined?(:@cs) end it '.find_cluster_secret' do @@ -27,11 +34,11 @@ def self.get(_) # end it '.force_cluster_secret' do - expect(@cs.force_cluster_secret).to eq true + expect(@cs.force_cluster_secret).to eq false end it '.settings_push_vault' do - expect(@cs.settings_push_vault).to eq true + expect(@cs.settings_push_vault).to eq false end it '.only_member?' do @@ -42,6 +49,101 @@ def self.get(_) expect(@cs.push_cs_to_vault).to eq false end + describe '#push_cs_to_vault rescue paths' do + before do + allow(Legion::Settings[:crypt][:vault]).to receive(:[]).and_call_original + allow(Legion::Settings[:crypt][:vault]).to receive(:[]).with(:connected).and_return(true) + allow(Legion::Settings[:crypt]).to receive(:[]).and_call_original + allow(Legion::Settings[:crypt]).to receive(:[]).with(:cluster_secret).and_return('aabbccdd') + allow(Legion::Crypt).to receive(:write).and_raise(StandardError, 'permission denied') + end + + it 'returns false when Vault write raises' do + expect(@cs.push_cs_to_vault).to eq false + end + + it 'does not propagate the exception' do + expect { @cs.push_cs_to_vault }.not_to raise_error + end + + it 'logs via handle_exception' do + expect(@cs).to receive(:handle_exception).with(instance_of(StandardError), hash_including(level: :warn)) + @cs.push_cs_to_vault + end + end + + describe '#set_cluster_secret' do + let(:valid_secret) { SecureRandom.hex(32) } + + before do + Legion::Settings[:crypt][:vault][:connected] = true + Legion::Settings[:crypt][:vault][:push_cluster_secret] = true + end + + it 'pushes the newly assigned secret instead of the previous settings value' do + previous_secret = SecureRandom.hex(32) + Legion::Settings[:crypt][:cluster_secret] = previous_secret + allow(Legion::Crypt).to receive(:write) + + @cs.set_cluster_secret(valid_secret, true) + + expect(Legion::Crypt).to have_received(:write).with('crypt', cluster_secret: valid_secret) + expect(Legion::Settings[:crypt][:cluster_secret]).to eq valid_secret + end + + context 'when push_cs_to_vault rescues internally' do + before do + allow(@cs).to receive(:push_cs_to_vault).and_return(false) + end + + it 'stores cluster_secret in Settings' do + @cs.set_cluster_secret(valid_secret, true) + expect(Legion::Settings[:crypt][:cluster_secret]).to eq valid_secret + end + + it 'sets cs_encrypt_ready to true' do + @cs.set_cluster_secret(valid_secret, true) + expect(Legion::Settings[:crypt][:cs_encrypt_ready]).to eq true + end + end + end + + describe 'Vault round trip' do + let(:vault_store) { {} } + let(:initial_secret) { SecureRandom.hex(32) } + let(:rotated_secret) { SecureRandom.hex(32) } + + before do + Legion::Settings[:crypt][:vault][:connected] = true + Legion::Settings[:crypt][:vault][:read_cluster_secret] = true + Legion::Settings[:crypt][:vault][:push_cluster_secret] = true + + allow(Legion::Crypt).to receive(:write) do |path, **data| + vault_store[path] = data + end + allow(Legion::Crypt).to receive(:exist?) do |path| + vault_store.key?(path) + end + allow(Legion::Crypt).to receive(:get) do |path| + vault_store[path] + end + end + + it 'round trips the cluster secret through Vault and refreshes the derived digest' do + @cs.set_cluster_secret(initial_secret, true) + initial_digest = @cs.cs + + Legion::Settings[:crypt][:cluster_secret] = nil + expect(@cs.from_vault).to eq initial_secret + expect(vault_store['crypt']).to eq(cluster_secret: initial_secret) + + @cs.set_cluster_secret(rotated_secret, false) + + expect(@cs.cs).to eq(Digest::SHA256.digest(rotated_secret)) + expect(@cs.cs).not_to eq(initial_digest) + end + end + it '.cluster_secret_timeout' do expect(@cs.cluster_secret_timeout).to eq 5 end @@ -68,4 +170,37 @@ def self.get(_) it 'can do magic things with vault(fake)' do expect(@cs.from_vault).to be_nil end + + describe '#from_transport rescue paths' do + before do + # Simulate transport connected but require itself raising an error + allow(Legion::Settings[:transport]).to receive(:[]).and_call_original + allow(Legion::Settings[:transport]).to receive(:[]).with(:connected).and_return(true) + allow(@cs).to receive(:require).and_raise(StandardError, 'transport error') + end + + it 'returns nil when an exception is raised' do + expect(@cs.from_transport).to be_nil + end + + it 'logs via handle_exception' do + expect(@cs).to receive(:handle_exception).with(instance_of(StandardError), hash_including(level: :error)) + @cs.from_transport + end + end + + describe '#cs rescue paths' do + before do + allow(@cs).to receive(:find_cluster_secret).and_raise(StandardError, 'digest error') + end + + it 'returns nil when find_cluster_secret raises' do + expect(@cs.cs).to be_nil + end + + it 'logs via handle_exception' do + expect(@cs).to receive(:handle_exception).with(instance_of(StandardError), hash_including(level: :error)) + @cs.cs + end + end end diff --git a/spec/legion/crypt/attestation_spec.rb b/spec/legion/crypt/attestation_spec.rb new file mode 100644 index 0000000..4baf41b --- /dev/null +++ b/spec/legion/crypt/attestation_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/ed25519' +require 'legion/crypt/attestation' + +RSpec.describe Legion::Crypt::Attestation do + let(:keypair) { Legion::Crypt::Ed25519.generate_keypair } + + describe 'create and verify' do + it 'roundtrips attestation' do + att = described_class.create( + agent_id: 'agent-1', capabilities: %w[read write], + state: 'active', private_key: keypair[:private_key] + ) + + valid = described_class.verify( + claim_hash: att[:claim], signature_hex: att[:signature], + public_key: keypair[:public_key] + ) + expect(valid).to be true + end + + it 'fails with tampered claim' do + att = described_class.create( + agent_id: 'agent-1', capabilities: %w[read], + state: 'active', private_key: keypair[:private_key] + ) + + tampered = att[:claim].merge(agent_id: 'agent-evil') + valid = described_class.verify( + claim_hash: tampered, signature_hex: att[:signature], + public_key: keypair[:public_key] + ) + expect(valid).to be false + end + end + + describe '.fresh?' do + it 'returns true for recent claim' do + claim = { timestamp: Time.now.utc.iso8601 } + expect(described_class.fresh?(claim)).to be true + end + + it 'returns false for old claim' do + claim = { timestamp: (Time.now.utc - 600).iso8601 } + expect(described_class.fresh?(claim, max_age_seconds: 300)).to be false + end + end +end diff --git a/spec/legion/crypt/cert_rotation_spec.rb b/spec/legion/crypt/cert_rotation_spec.rb new file mode 100644 index 0000000..d7eebea --- /dev/null +++ b/spec/legion/crypt/cert_rotation_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/mtls' +require 'legion/crypt/cert_rotation' + +RSpec.describe Legion::Crypt::CertRotation do + let(:cert_data) do + { + cert: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + key: "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----", + ca_chain: [], + serial: '01:02', + expiry: Time.now + 86_400 + } + end + + before do + allow(Legion::Crypt::Mtls).to receive(:enabled?).and_return(true) + allow(Legion::Crypt::Mtls).to receive(:issue_cert).and_return(cert_data) + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:client).and_return({ name: 'test-node' }) + end + + describe '#initialize' do + it 'creates instance with default check_interval of 43200' do + rotation = described_class.new + expect(rotation.check_interval).to eq 43_200 + end + + it 'accepts a custom check_interval' do + rotation = described_class.new(check_interval: 60) + expect(rotation.check_interval).to eq 60 + end + + it 'does not start a thread on initialize' do + rotation = described_class.new + expect(rotation.running?).to be false + end + end + + describe '#current_cert' do + it 'returns nil before start' do + rotation = described_class.new + expect(rotation.current_cert).to be_nil + end + end + + describe '#rotate!' do + it 'calls Mtls.issue_cert with node name from settings' do + rotation = described_class.new + allow(Legion::Settings).to receive(:[]).with(:client).and_return({ name: 'my-node' }) + expect(Legion::Crypt::Mtls).to receive(:issue_cert).with(common_name: 'my-node').and_return(cert_data) + rotation.rotate! + end + + it 'stores the new cert as current_cert' do + rotation = described_class.new + rotation.rotate! + expect(rotation.current_cert).to eq cert_data + end + + it 'stores the issued_at time' do + rotation = described_class.new + before = Time.now + rotation.rotate! + expect(rotation.issued_at).to be >= before + end + + it 'emits cert.rotated event when Legion::Events is defined' do + stub_const('Legion::Events', double('Events')) + expect(Legion::Events).to receive(:emit).with('cert.rotated', hash_including(:serial)) + rotation = described_class.new + rotation.rotate! + end + + it 'does not raise when Legion::Events is not defined' do + hide_const('Legion::Events') if defined?(Legion::Events) + rotation = described_class.new + expect { rotation.rotate! }.not_to raise_error + end + end + + describe '#needs_renewal?' do + it 'returns false when current_cert is nil' do + rotation = described_class.new + expect(rotation.needs_renewal?).to be false + end + + it 'returns false when cert has more than 50% TTL remaining' do + rotation = described_class.new + rotation.instance_variable_set(:@current_cert, cert_data) + rotation.instance_variable_set(:@issued_at, Time.now) + # expiry is 24h from now, issued_at is now => 100% remaining + expect(rotation.needs_renewal?).to be false + end + + it 'returns true when less than 50% TTL remains' do + rotation = described_class.new + # issued_at 13 hours ago, expiry 11 hours from now => 24h total, 11/24 ~ 46% remaining + issued = Time.now - (13 * 3600) + expiry = Time.now + (11 * 3600) + data = cert_data.merge(expiry: expiry) + rotation.instance_variable_set(:@current_cert, data) + rotation.instance_variable_set(:@issued_at, issued) + expect(rotation.needs_renewal?).to be true + end + end + + describe '#start and #stop' do + it 'starts a background thread' do + rotation = described_class.new(check_interval: 3600) + rotation.start + expect(rotation.running?).to be true + rotation.stop + end + + it 'stops the thread' do + rotation = described_class.new(check_interval: 3600) + rotation.start + rotation.stop + expect(rotation.running?).to be false + end + + it 'does not start when mtls is disabled' do + allow(Legion::Crypt::Mtls).to receive(:enabled?).and_return(false) + rotation = described_class.new + rotation.start + expect(rotation.running?).to be false + end + + it 'is idempotent — double start does not spawn a second thread' do + rotation = described_class.new(check_interval: 3600) + rotation.start + t1 = rotation.instance_variable_get(:@thread) + rotation.start + t2 = rotation.instance_variable_get(:@thread) + expect(t1).to eq t2 + rotation.stop + end + + it 'keeps the thread reference when stop times out' do + rotation = described_class.new(check_interval: 3600) + stuck_thread = instance_double(Thread, alive?: true) + allow(stuck_thread).to receive(:wakeup) + allow(stuck_thread).to receive(:join) + rotation.instance_variable_set(:@thread, stuck_thread) + rotation.instance_variable_set(:@running, true) + + rotation.stop + + expect(rotation.instance_variable_get(:@thread)).to eq(stuck_thread) + end + end +end diff --git a/spec/legion/crypt/credential_scoping_spec.rb b/spec/legion/crypt/credential_scoping_spec.rb new file mode 100644 index 0000000..5f445c4 --- /dev/null +++ b/spec/legion/crypt/credential_scoping_spec.rb @@ -0,0 +1,472 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Crypt do + let(:vault_response) do + double('Vault::Secret', + data: { username: 'bootstrap_user', password: 'bootstrap_pass' }, + lease_id: 'rabbitmq/creds/legionio-bootstrap/abc123', + lease_duration: 300, + renewable?: false) + end + + let(:vault_response_string_keys) do + double('Vault::Secret', + data: { 'username' => 'bootstrap_user_str', 'password' => 'bootstrap_pass_str' }, + lease_id: 'rabbitmq/creds/legionio-bootstrap/str123', + lease_duration: 300, + renewable?: false) + end + + let(:identity_response) do + double('Vault::Secret', + data: { username: 'identity_user', password: 'identity_pass' }, + lease_id: 'rabbitmq/creds/legionio-infra/def456', + lease_duration: 604_800, + renewable?: true) + end + + let(:identity_response_string_keys) do + double('Vault::Secret', + data: { 'username' => 'identity_user_str', 'password' => 'identity_pass_str' }, + lease_id: 'rabbitmq/creds/legionio-infra/str456', + lease_duration: 604_800, + renewable?: true) + end + + let(:logical_double) { double('Vault::Logical') } + let(:sys_double) { instance_double(Vault::Sys) } + + before do + # Stub public Vault delegators on LeaseManager (vault_logical, vault_sys) + allow(Legion::Crypt::LeaseManager.instance).to receive(:vault_logical).and_return(logical_double) + allow(Legion::Crypt::LeaseManager.instance).to receive(:vault_sys).and_return(sys_double) + allow(logical_double).to receive(:read).and_return(vault_response) + allow(sys_double).to receive(:revoke) + # Reset instance-level state between examples + Legion::Crypt.instance_variable_set(:@bootstrap_lease_id, nil) + Legion::Crypt.instance_variable_set(:@bootstrap_lease_expires, nil) + Legion::Crypt::LeaseManager.instance.reset! + end + + describe 'RMQ_ROLE_MAP' do + it 'maps :agent to legionio-infra' do + expect(described_class::RMQ_ROLE_MAP[:agent]).to eq('legionio-infra') + end + + it 'maps :worker to legionio-worker' do + expect(described_class::RMQ_ROLE_MAP[:worker]).to eq('legionio-worker') + end + + it 'maps :infra to legionio-infra' do + expect(described_class::RMQ_ROLE_MAP[:infra]).to eq('legionio-infra') + end + + it 'is frozen' do + expect(described_class::RMQ_ROLE_MAP).to be_frozen + end + end + + describe '.dynamic_rmq_creds?' do + it 'returns true when the setting is true' do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = true + expect(described_class.dynamic_rmq_creds?).to be(true) + ensure + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + end + + it 'returns false when the setting is false' do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + expect(described_class.dynamic_rmq_creds?).to be(false) + end + + it 'returns false when the setting is absent' do + Legion::Settings[:crypt][:vault].delete(:dynamic_rmq_creds) + expect(described_class.dynamic_rmq_creds?).to be(false) + ensure + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + end + end + + describe '.fetch_bootstrap_rmq_creds' do + before do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = true + allow(described_class).to receive(:vault_connected?).and_return(true) + Legion::Settings.loader.settings[:transport] ||= {} + Legion::Settings.loader.settings[:transport][:connection] ||= {} + end + + after do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + end + + it 'reads from the bootstrap path' do + expect(logical_double).to receive(:read).with('rabbitmq/creds/legionio-bootstrap').and_return(vault_response) + described_class.fetch_bootstrap_rmq_creds + end + + it 'stores the bootstrap lease_id' do + described_class.fetch_bootstrap_rmq_creds + expect(described_class.instance_variable_get(:@bootstrap_lease_id)) + .to eq('rabbitmq/creds/legionio-bootstrap/abc123') + end + + it 'stores a bootstrap_lease_expires time capped at 300 seconds' do + before_call = Time.now + described_class.fetch_bootstrap_rmq_creds + expires = described_class.instance_variable_get(:@bootstrap_lease_expires) + expect(expires).to be_a(Time) + expect(expires).to be >= before_call + expect(expires).to be <= (before_call + 300 + 1) + end + + it 'writes username to the transport connection settings' do + described_class.fetch_bootstrap_rmq_creds + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:user]).to eq('bootstrap_user') + end + + it 'writes password to the transport connection settings' do + described_class.fetch_bootstrap_rmq_creds + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:password]).to eq('bootstrap_pass') + end + + context 'when Vault returns string-keyed data' do + before { allow(logical_double).to receive(:read).and_return(vault_response_string_keys) } + + it 'writes username to transport connection settings from string key' do + described_class.fetch_bootstrap_rmq_creds + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:user]).to eq('bootstrap_user_str') + end + + it 'writes password to transport connection settings from string key' do + described_class.fetch_bootstrap_rmq_creds + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:password]).to eq('bootstrap_pass_str') + end + + it 'stores the bootstrap lease_id from string-keyed response' do + described_class.fetch_bootstrap_rmq_creds + expect(described_class.instance_variable_get(:@bootstrap_lease_id)) + .to eq('rabbitmq/creds/legionio-bootstrap/str123') + end + end + + context 'when vault is not connected' do + before { allow(described_class).to receive(:vault_connected?).and_return(false) } + + it 'returns nil without reading from Vault' do + expect(logical_double).not_to receive(:read) + expect(described_class.fetch_bootstrap_rmq_creds).to be_nil + end + end + + context 'when dynamic_rmq_creds? is false' do + before { Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false } + + it 'returns nil without reading from Vault' do + expect(logical_double).not_to receive(:read) + expect(described_class.fetch_bootstrap_rmq_creds).to be_nil + end + end + + context 'when Vault returns nil' do + before { allow(logical_double).to receive(:read).and_return(nil) } + + it 'returns nil without raising' do + expect { described_class.fetch_bootstrap_rmq_creds }.not_to raise_error + expect(described_class.instance_variable_get(:@bootstrap_lease_id)).to be_nil + end + end + + context 'when Vault returns a response with no data' do + let(:no_data_response) do + double('Vault::Secret', data: nil, lease_id: 'lease/abc', lease_duration: 300, renewable?: false) + end + + before { allow(logical_double).to receive(:read).and_return(no_data_response) } + + it 'returns nil without raising' do + expect { described_class.fetch_bootstrap_rmq_creds }.not_to raise_error + end + end + + context 'when Vault raises an error' do + before { allow(logical_double).to receive(:read).and_raise(StandardError, 'vault unavailable') } + + it 'does not raise and logs a warning' do + expect { described_class.fetch_bootstrap_rmq_creds }.not_to raise_error + end + + it 'does not store a lease_id on error' do + described_class.fetch_bootstrap_rmq_creds + expect(described_class.instance_variable_get(:@bootstrap_lease_id)).to be_nil + end + end + end + + describe '.swap_to_identity_creds' do + let(:transport_connection_double) do + double('Legion::Transport::Connection', session_open?: true) + end + + before do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = true + allow(described_class).to receive(:vault_connected?).and_return(true) + allow(logical_double).to receive(:read).with('rabbitmq/creds/legionio-infra').and_return(identity_response) + allow(logical_double).to receive(:read).with('rabbitmq/creds/legionio-worker').and_return(identity_response) + allow(logical_double).to receive(:read).with('rabbitmq/creds/legionio-custom').and_return(identity_response) + Legion::Settings.loader.settings[:transport] ||= {} + Legion::Settings.loader.settings[:transport][:connection] ||= {} + end + + after do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + end + + it 'returns early when vault is not connected' do + allow(described_class).to receive(:vault_connected?).and_return(false) + expect(logical_double).not_to receive(:read) + described_class.swap_to_identity_creds(mode: :agent) + end + + it 'returns early when dynamic_rmq_creds? is false' do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + expect(logical_double).not_to receive(:read) + described_class.swap_to_identity_creds(mode: :agent) + end + + it 'returns early for :lite mode' do + expect(logical_double).not_to receive(:read) + described_class.swap_to_identity_creds(mode: :lite) + end + + context 'role selection from RMQ_ROLE_MAP' do + before do + stub_const('Legion::Transport::Connection', transport_connection_double) + allow(transport_connection_double).to receive(:force_reconnect) + allow(Legion::Crypt::LeaseManager.instance).to receive(:register_dynamic_lease) + end + + it 'uses legionio-infra for :agent mode' do + expect(logical_double).to receive(:read).with('rabbitmq/creds/legionio-infra').and_return(identity_response) + described_class.swap_to_identity_creds(mode: :agent) + end + + it 'uses legionio-infra for :infra mode' do + expect(logical_double).to receive(:read).with('rabbitmq/creds/legionio-infra').and_return(identity_response) + described_class.swap_to_identity_creds(mode: :infra) + end + + it 'uses legionio-worker for :worker mode' do + expect(logical_double).to receive(:read).with('rabbitmq/creds/legionio-worker').and_return(identity_response) + described_class.swap_to_identity_creds(mode: :worker) + end + + it 'uses a fallback role name for unknown modes' do + expect(logical_double).to receive(:read).with('rabbitmq/creds/legionio-custom').and_return(identity_response) + described_class.swap_to_identity_creds(mode: :custom) + end + end + + context 'when Vault returns identity creds' do + before do + stub_const('Legion::Transport::Connection', transport_connection_double) + allow(transport_connection_double).to receive(:force_reconnect) + allow(Legion::Crypt::LeaseManager.instance).to receive(:register_dynamic_lease) + end + + it 'registers the lease with LeaseManager' do + expect(Legion::Crypt::LeaseManager.instance).to receive(:register_dynamic_lease).with( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: identity_response, + settings_refs: [ + { path: %i[transport connection user], key: :username }, + { path: %i[transport connection password], key: :password } + ] + ) + described_class.swap_to_identity_creds(mode: :agent) + end + + it 'writes identity username to transport connection settings' do + described_class.swap_to_identity_creds(mode: :agent) + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:user]).to eq('identity_user') + end + + it 'writes identity password to transport connection settings' do + described_class.swap_to_identity_creds(mode: :agent) + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:password]).to eq('identity_pass') + end + + it 'calls force_reconnect on Transport::Connection' do + expect(transport_connection_double).to receive(:force_reconnect) + described_class.swap_to_identity_creds(mode: :agent) + end + + it 'revokes the bootstrap lease after successful reconnect' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/xyz789') + expect(sys_double).to receive(:revoke).with('boot/lease/xyz789') + described_class.swap_to_identity_creds(mode: :agent) + end + end + + context 'when Vault returns string-keyed identity data' do + before do + stub_const('Legion::Transport::Connection', transport_connection_double) + allow(transport_connection_double).to receive(:force_reconnect) + allow(Legion::Crypt::LeaseManager.instance).to receive(:register_dynamic_lease) + allow(logical_double).to receive(:read).with('rabbitmq/creds/legionio-infra') + .and_return(identity_response_string_keys) + end + + it 'writes identity username to transport connection settings from string key' do + described_class.swap_to_identity_creds(mode: :agent) + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:user]).to eq('identity_user_str') + end + + it 'writes identity password to transport connection settings from string key' do + described_class.swap_to_identity_creds(mode: :agent) + conn = Legion::Settings.loader.settings.dig(:transport, :connection) + expect(conn[:password]).to eq('identity_pass_str') + end + end + + context 'when Vault returns no data for identity creds' do + before { allow(logical_double).to receive(:read).with('rabbitmq/creds/legionio-infra').and_return(nil) } + + it 'raises an error' do + expect { described_class.swap_to_identity_creds(mode: :agent) }.to raise_error(RuntimeError, /Failed to fetch/) + end + end + + context 'when Transport::Connection is not defined' do + before { allow(Legion::Crypt::LeaseManager.instance).to receive(:register_dynamic_lease) } + + it 'does not raise when Transport module is absent' do + hide_const('Legion::Transport::Connection') + expect { described_class.swap_to_identity_creds(mode: :agent) }.not_to raise_error + end + end + + context 'when transport reconnect fails (session not open)' do + let(:closed_connection) { double('Legion::Transport::Connection', session_open?: false) } + + before do + stub_const('Legion::Transport::Connection', closed_connection) + allow(closed_connection).to receive(:force_reconnect) + allow(Legion::Crypt::LeaseManager.instance).to receive(:register_dynamic_lease) + end + + it 'raises an error and does not revoke the bootstrap lease' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/xyz789') + expect(sys_double).not_to receive(:revoke) + expect { described_class.swap_to_identity_creds(mode: :agent) }.to raise_error(RuntimeError, /reconnect failed/) + end + end + end + + describe '.revoke_bootstrap_lease' do + it 'is a no-op when no bootstrap lease is stored' do + described_class.instance_variable_set(:@bootstrap_lease_id, nil) + expect(sys_double).not_to receive(:revoke) + expect { described_class.revoke_bootstrap_lease }.not_to raise_error + end + + it 'revokes the stored bootstrap lease' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + expect(sys_double).to receive(:revoke).with('boot/lease/abc123') + described_class.revoke_bootstrap_lease + end + + it 'clears @bootstrap_lease_id after revocation' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + described_class.revoke_bootstrap_lease + expect(described_class.instance_variable_get(:@bootstrap_lease_id)).to be_nil + end + + it 'clears @bootstrap_lease_expires after revocation' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + described_class.instance_variable_set(:@bootstrap_lease_expires, Time.now + 100) + described_class.revoke_bootstrap_lease + expect(described_class.instance_variable_get(:@bootstrap_lease_expires)).to be_nil + end + + it 'does not raise when Vault revocation fails' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + allow(sys_double).to receive(:revoke).and_raise(StandardError, 'vault down') + expect { described_class.revoke_bootstrap_lease }.not_to raise_error + end + + it 'clears @bootstrap_lease_id even when revocation fails' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + allow(sys_double).to receive(:revoke).and_raise(StandardError, 'vault down') + described_class.revoke_bootstrap_lease + expect(described_class.instance_variable_get(:@bootstrap_lease_id)).to be_nil + end + + it 'clears @bootstrap_lease_expires even when revocation fails' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + described_class.instance_variable_set(:@bootstrap_lease_expires, Time.now + 100) + allow(sys_double).to receive(:revoke).and_raise(StandardError, 'vault down') + described_class.revoke_bootstrap_lease + expect(described_class.instance_variable_get(:@bootstrap_lease_expires)).to be_nil + end + + it 'is idempotent — second call is a no-op' do + described_class.instance_variable_set(:@bootstrap_lease_id, 'boot/lease/abc123') + described_class.revoke_bootstrap_lease + expect(sys_double).not_to receive(:revoke) + described_class.revoke_bootstrap_lease + end + end + + describe 'start_lease_manager with dynamic_rmq_creds guard' do + before do + allow(Legion::Crypt::LeaseManager.instance).to receive(:start) + allow(Legion::Crypt::LeaseManager.instance).to receive(:start_renewal_thread) + allow(Legion::Crypt::LeaseManager.instance).to receive(:shutdown) + end + + context 'when dynamic_rmq_creds is true and no static leases' do + before do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = true + Legion::Settings[:crypt][:vault][:connected] = true + Legion::Settings[:crypt][:vault][:leases] = {} + end + + after do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + Legion::Settings[:crypt][:vault][:connected] = false + end + + it 'starts the renewal thread even without static leases' do + Legion::Crypt.send(:start_lease_manager) + expect(Legion::Crypt::LeaseManager.instance).to have_received(:start_renewal_thread) + end + end + + context 'when dynamic_rmq_creds is false and no static leases' do + before do + Legion::Settings[:crypt][:vault][:dynamic_rmq_creds] = false + Legion::Settings[:crypt][:vault][:connected] = true + Legion::Settings[:crypt][:vault][:leases] = {} + end + + after do + Legion::Settings[:crypt][:vault][:connected] = false + end + + it 'does not start the renewal thread without static leases' do + Legion::Crypt.send(:start_lease_manager) + expect(Legion::Crypt::LeaseManager.instance).not_to have_received(:start_renewal_thread) + end + end + end +end diff --git a/spec/legion/crypt/ed25519_spec.rb b/spec/legion/crypt/ed25519_spec.rb new file mode 100644 index 0000000..ba461a5 --- /dev/null +++ b/spec/legion/crypt/ed25519_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/ed25519' + +RSpec.describe Legion::Crypt::Ed25519 do + describe '.generate_keypair' do + it 'returns private and public keys' do + kp = described_class.generate_keypair + expect(kp[:private_key]).to be_a(String) + expect(kp[:public_key]).to be_a(String) + expect(kp[:public_key_hex]).to match(/\A[a-f0-9]{64}\z/) + end + end + + describe '.sign and .verify' do + let(:keypair) { described_class.generate_keypair } + + it 'roundtrips sign/verify' do + sig = described_class.sign('hello', keypair[:private_key]) + expect(described_class.verify('hello', sig, keypair[:public_key])).to be true + end + + it 'fails verify with wrong key' do + other = described_class.generate_keypair + sig = described_class.sign('hello', keypair[:private_key]) + expect(described_class.verify('hello', sig, other[:public_key])).to be false + end + + it 'fails verify with tampered message' do + sig = described_class.sign('hello', keypair[:private_key]) + expect(described_class.verify('tampered', sig, keypair[:public_key])).to be false + end + end + + describe '.store_keypair and .load_private_key' do + let(:keypair) { described_class.generate_keypair } + + it 'stores keypairs via Legion::Crypt.write' do + allow(Legion::Crypt).to receive(:write) + + described_class.store_keypair(agent_id: 'agent-1', keypair: keypair) + + expect(Legion::Crypt).to have_received(:write).with( + 'keys/agent-1', + private_key: keypair[:private_key].unpack1('H*'), + public_key: keypair[:public_key_hex] + ) + end + + it 'loads private keys via Legion::Crypt.get' do + allow(Legion::Crypt).to receive(:get).with('keys/agent-1') + .and_return(private_key: keypair[:private_key].unpack1('H*')) + + result = described_class.load_private_key(agent_id: 'agent-1') + + expect(result).to eq(keypair[:private_key]) + end + end +end diff --git a/spec/legion/crypt/erasure_spec.rb b/spec/legion/crypt/erasure_spec.rb new file mode 100644 index 0000000..9b5a8fe --- /dev/null +++ b/spec/legion/crypt/erasure_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/erasure' + +RSpec.describe Legion::Crypt::Erasure do + describe '.erase_tenant' do + it 'returns success when vault delete succeeds' do + allow(Legion::Crypt).to receive(:delete) + + result = described_class.erase_tenant(tenant_id: 'tenant-123') + expect(result[:erased]).to be true + expect(result[:tenant_id]).to eq('tenant-123') + end + + it 'returns failure on error' do + allow(Legion::Crypt).to receive(:delete).and_raise(StandardError.new('vault unreachable')) + + result = described_class.erase_tenant(tenant_id: 'tenant-123') + expect(result[:erased]).to be false + expect(result[:error]).to include('vault unreachable') + end + end + + describe '.verify_erasure' do + it 'returns erased=true when the key is absent' do + allow(Legion::Crypt).to receive(:read).and_return(nil) + + result = described_class.verify_erasure(tenant_id: 'tenant-123') + + expect(result).to include(erased: true, tenant_id: 'tenant-123') + end + + it 'returns erased=false when the key still exists' do + allow(Legion::Crypt).to receive(:read).and_return(private_key: 'present') + + result = described_class.verify_erasure(tenant_id: 'tenant-123') + + expect(result).to include(erased: false, tenant_id: 'tenant-123') + end + + it 'fails closed when Vault access raises' do + allow(Legion::Crypt).to receive(:read).and_raise(StandardError, 'vault unreachable') + + result = described_class.verify_erasure(tenant_id: 'tenant-123') + + expect(result).to include(erased: false, tenant_id: 'tenant-123') + expect(result[:error]).to include('vault unreachable') + end + end +end diff --git a/spec/legion/crypt/helper_spec.rb b/spec/legion/crypt/helper_spec.rb new file mode 100644 index 0000000..e2be854 --- /dev/null +++ b/spec/legion/crypt/helper_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Crypt::Helper do + let(:helper_class) do + Class.new do + include Legion::Crypt::Helper + + def lex_filename + 'microsoft_teams' + end + end + end + + let(:bare_class) do + stub_const('Legion::Extensions::MyExtension::Runners::Foo', Class.new do + include Legion::Crypt::Helper + end) + end + + subject { helper_class.new } + + describe '#vault_namespace' do + it 'derives from lex_filename' do + expect(subject.vault_namespace).to eq('microsoft_teams') + end + + it 'derives from class name when lex_filename is not defined' do + obj = bare_class.new + expect(obj.vault_namespace).to eq('my_extension') + end + end + + describe '#vault_get' do + it 'delegates to Legion::Crypt.get with namespace' do + expect(Legion::Crypt).to receive(:get).with('microsoft_teams').and_return({ token: 'abc' }) + expect(subject.vault_get).to eq({ token: 'abc' }) + end + + it 'appends suffix to namespace' do + expect(Legion::Crypt).to receive(:get).with('microsoft_teams/auth').and_return({ token: 'abc' }) + expect(subject.vault_get('auth')).to eq({ token: 'abc' }) + end + end + + describe '#vault_write' do + it 'delegates to Legion::Crypt.write with namespace' do + expect(Legion::Crypt).to receive(:write).with('microsoft_teams/auth', token: 'abc') + subject.vault_write('auth', token: 'abc') + end + end + + describe '#vault_exist?' do + it 'delegates to Legion::Crypt.exist? with namespace' do + expect(Legion::Crypt).to receive(:exist?).with('microsoft_teams').and_return(true) + expect(subject.vault_exist?).to be true + end + + it 'appends suffix to namespace' do + expect(Legion::Crypt).to receive(:exist?).with('microsoft_teams/auth').and_return(false) + expect(subject.vault_exist?('auth')).to be false + end + end +end diff --git a/spec/legion/crypt/mtls_spec.rb b/spec/legion/crypt/mtls_spec.rb new file mode 100644 index 0000000..905bb6e --- /dev/null +++ b/spec/legion/crypt/mtls_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/mtls' + +RSpec.describe Legion::Crypt::Mtls do + let(:vault_response) do + double( + 'VaultResponse', + data: { + certificate: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + private_key: "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----", + ca_chain: ["-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"], + serial_number: '01:02:03:04', + expiration: (Time.now + 86_400).to_i + } + ) + end + + before do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { mtls: { enabled: false, vault_pki_path: 'pki/issue/legion-internal', cert_ttl: '24h' } } + ) + end + + describe '.enabled?' do + it 'returns false when security.mtls.enabled is false' do + expect(described_class.enabled?).to be false + end + + it 'returns true when security.mtls.enabled is true' do + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { mtls: { enabled: true, vault_pki_path: 'pki/issue/legion-internal', cert_ttl: '24h' } } + ) + expect(described_class.enabled?).to be true + end + + it 'returns false when security settings are missing' do + allow(Legion::Settings).to receive(:[]).with(:security).and_return(nil) + expect(described_class.enabled?).to be false + end + end + + describe '.pki_path' do + it 'reads from settings' do + expect(described_class.pki_path).to eq 'pki/issue/legion-internal' + end + + it 'defaults to pki/issue/legion-internal when setting is nil' do + allow(Legion::Settings).to receive(:[]).with(:security).and_return({ mtls: {} }) + expect(described_class.pki_path).to eq 'pki/issue/legion-internal' + end + + it 'returns default when security is nil' do + allow(Legion::Settings).to receive(:[]).with(:security).and_return(nil) + expect(described_class.pki_path).to eq 'pki/issue/legion-internal' + end + end + + describe '.local_ip' do + it 'returns a string' do + expect(described_class.local_ip).to be_a(String) + end + + it 'returns a non-empty address' do + expect(described_class.local_ip).not_to be_empty + end + end + + describe '.issue_cert' do + before do + vault_logical = double('VaultLogical') + stub_const('Vault', Module.new) + allow(Vault).to receive(:logical).and_return(vault_logical) + allow(vault_logical).to receive(:write).and_return(vault_response) + end + + it 'calls Vault.logical.write with pki path and common_name' do + expect(Vault.logical).to receive(:write).with( + 'pki/issue/legion-internal', + hash_including(common_name: 'node.legion.internal', ttl: '24h') + ).and_return(vault_response) + described_class.issue_cert(common_name: 'node.legion.internal') + end + + it 'returns a hash with cert, key, ca_chain, serial, expiry' do + result = described_class.issue_cert(common_name: 'node.legion.internal') + expect(result).to include(:cert, :key, :ca_chain, :serial, :expiry) + end + + it 'returns cert as a string' do + result = described_class.issue_cert(common_name: 'node.legion.internal') + expect(result[:cert]).to include('BEGIN CERTIFICATE') + end + + it 'returns key as a string' do + result = described_class.issue_cert(common_name: 'node.legion.internal') + expect(result[:key]).to include('BEGIN RSA PRIVATE KEY') + end + + it 'returns expiry as a Time' do + result = described_class.issue_cert(common_name: 'node.legion.internal') + expect(result[:expiry]).to be_a(Time) + end + + it 'accepts a custom ttl override' do + expect(Vault.logical).to receive(:write).with( + anything, + hash_including(ttl: '4h') + ).and_return(vault_response) + described_class.issue_cert(common_name: 'node.legion.internal', ttl: '4h') + end + + it 'includes ip_sans with local IP' do + ip = described_class.local_ip + expect(Vault.logical).to receive(:write).with( + anything, + hash_including(ip_sans: ip) + ).and_return(vault_response) + described_class.issue_cert(common_name: 'node.legion.internal') + end + + it 'raises when Vault returns nil' do + allow(Vault.logical).to receive(:write).and_return(nil) + expect { described_class.issue_cert(common_name: 'x') }.to raise_error(RuntimeError, /Vault PKI returned nil/) + end + end +end diff --git a/spec/legion/crypt/partition_keys_spec.rb b/spec/legion/crypt/partition_keys_spec.rb new file mode 100644 index 0000000..fae9b0f --- /dev/null +++ b/spec/legion/crypt/partition_keys_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/partition_keys' + +RSpec.describe Legion::Crypt::PartitionKeys do + let(:master_key) { OpenSSL::Random.random_bytes(32) } + + describe '.derive_key' do + it 'returns 32-byte key' do + key = described_class.derive_key(master_key: master_key, tenant_id: 'tenant-1') + expect(key.bytesize).to eq(32) + end + + it 'is deterministic for same inputs' do + k1 = described_class.derive_key(master_key: master_key, tenant_id: 'tenant-1') + k2 = described_class.derive_key(master_key: master_key, tenant_id: 'tenant-1') + expect(k1).to eq(k2) + end + + it 'differs by tenant_id' do + k1 = described_class.derive_key(master_key: master_key, tenant_id: 'tenant-1') + k2 = described_class.derive_key(master_key: master_key, tenant_id: 'tenant-2') + expect(k1).not_to eq(k2) + end + end + + describe 'encrypt/decrypt roundtrip' do + it 'recovers plaintext' do + encrypted = described_class.encrypt_for_tenant(plaintext: 'secret data', tenant_id: 'tenant-1', master_key: master_key) + plaintext = described_class.decrypt_for_tenant( + ciphertext: encrypted[:ciphertext], + init_vector: encrypted[:iv], + auth_tag: encrypted[:auth_tag], + tenant_id: 'tenant-1', + master_key: master_key + ) + expect(plaintext).to eq('secret data') + end + + it 'fails with wrong tenant_id' do + encrypted = described_class.encrypt_for_tenant(plaintext: 'secret', tenant_id: 'tenant-1', master_key: master_key) + expect do + described_class.decrypt_for_tenant( + ciphertext: encrypted[:ciphertext], init_vector: encrypted[:iv], auth_tag: encrypted[:auth_tag], + tenant_id: 'tenant-2', master_key: master_key + ) + end.to raise_error(OpenSSL::Cipher::CipherError) + end + end +end diff --git a/spec/legion/crypt/spiffe_identity_helpers_spec.rb b/spec/legion/crypt/spiffe_identity_helpers_spec.rb new file mode 100644 index 0000000..6acb92c --- /dev/null +++ b/spec/legion/crypt/spiffe_identity_helpers_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'openssl' +require 'legion/crypt/spiffe' +require 'legion/crypt/spiffe/workload_api_client' +require 'legion/crypt/spiffe/identity_helpers' + +RSpec.describe Legion::Crypt::Spiffe::IdentityHelpers do + # Mix helpers into an anonymous object for testing. + let(:helper) { Object.new.extend(described_class) } + + # Build a real self-signed EC cert + key for signing/verification tests. + let(:ec_key) { OpenSSL::PKey::EC.generate('prime256v1') } + let(:cert) do + c = OpenSSL::X509::Certificate.new + c.version = 2 + c.serial = OpenSSL::BN.rand(64) + c.not_before = Time.now - 1 + c.not_after = Time.now + 3600 + spiffe_uri = 'spiffe://test.local/workload/helper-test' + # Subject CN must be a plain string; the SPIFFE ID goes in the SAN URI extension only. + c.subject = OpenSSL::X509::Name.parse('/CN=helper-test-svid') + c.issuer = c.subject + c.public_key = ec_key + ext_factory = OpenSSL::X509::ExtensionFactory.new(c, c) + c.add_extension(ext_factory.create_extension('subjectAltName', "URI:#{spiffe_uri}", false)) + c.add_extension(ext_factory.create_extension('basicConstraints', 'CA:FALSE', true)) + c.sign(ec_key, OpenSSL::Digest.new('SHA256')) + c + end + + let(:spiffe_id) { Legion::Crypt::Spiffe.parse_id('spiffe://test.local/workload/helper-test') } + let(:live_svid) do + Legion::Crypt::Spiffe::X509Svid.new( + spiffe_id, + cert.to_pem, + ec_key.private_to_pem, + nil, + cert.not_after + ) + end + let(:expired_svid) do + Legion::Crypt::Spiffe::X509Svid.new( + spiffe_id, + cert.to_pem, + ec_key.private_to_pem, + nil, + Time.now - 1 + ) + end + + describe '#sign_with_svid' do + it 'returns a Base64-encoded string' do + sig = helper.sign_with_svid('hello world', svid: live_svid) + expect(sig).to be_a(String) + expect { Base64.strict_decode64(sig) }.not_to raise_error + end + + it 'raises SvidError when svid is nil' do + expect { helper.sign_with_svid('data', svid: nil) } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /nil/) + end + + it 'raises SvidError when svid is expired' do + expect { helper.sign_with_svid('data', svid: expired_svid) } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /expired/) + end + end + + describe '#verify_svid_signature' do + let(:data) { 'the data to sign' } + let(:signature_b64) { helper.sign_with_svid(data, svid: live_svid) } + + it 'returns true for a valid signature' do + expect(helper.verify_svid_signature(data, signature_b64: signature_b64, svid: live_svid)).to be true + end + + it 'returns false for a tampered payload' do + expect(helper.verify_svid_signature('tampered', signature_b64: signature_b64, svid: live_svid)).to be false + end + + it 'returns false for a corrupted signature' do + bad_sig = Base64.strict_encode64('not-a-real-sig') + expect(helper.verify_svid_signature(data, signature_b64: bad_sig, svid: live_svid)).to be false + end + + it 'raises SvidError when svid is nil' do + expect { helper.verify_svid_signature(data, signature_b64: 'x', svid: nil) } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /nil/) + end + + it 'raises SvidError when svid is expired' do + expect { helper.verify_svid_signature(data, signature_b64: 'x', svid: expired_svid) } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /expired/) + end + end + + describe '#extract_spiffe_id_from_cert' do + it 'extracts the SPIFFE ID from the SAN URI extension' do + id = helper.extract_spiffe_id_from_cert(cert.to_pem) + expect(id).not_to be_nil + expect(id.trust_domain).to eq('test.local') + expect(id.path).to eq('/workload/helper-test') + end + + it 'returns nil for a cert without a SPIFFE SAN' do + plain_key = OpenSSL::PKey::EC.generate('prime256v1') + plain_cert = OpenSSL::X509::Certificate.new + plain_cert.subject = OpenSSL::X509::Name.parse('/CN=plain') + plain_cert.issuer = plain_cert.subject + plain_cert.not_before = Time.now - 1 + plain_cert.not_after = Time.now + 3600 + plain_cert.public_key = plain_key + plain_cert.sign(plain_key, OpenSSL::Digest.new('SHA256')) + + expect(helper.extract_spiffe_id_from_cert(plain_cert.to_pem)).to be_nil + end + + it 'returns nil for invalid PEM input' do + expect(helper.extract_spiffe_id_from_cert('not-a-cert')).to be_nil + end + end + + describe '#trusted_cert?' do + it 'raises SvidError when svid is nil' do + expect { helper.trusted_cert?(cert.to_pem, svid: nil) } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /nil/) + end + + it 'returns false when svid has no bundle' do + svid = Legion::Crypt::Spiffe::X509Svid.new(spiffe_id, cert.to_pem, ec_key.private_to_pem, nil, Time.now + 3600) + expect(helper.trusted_cert?(cert.to_pem, svid: svid)).to be false + end + + it 'returns true when the leaf cert is signed by the bundle CA' do + # Use the self-signed cert as both leaf and bundle (self-signed trusts itself). + svid = Legion::Crypt::Spiffe::X509Svid.new(spiffe_id, cert.to_pem, ec_key.private_to_pem, cert.to_pem, Time.now + 3600) + expect(helper.trusted_cert?(cert.to_pem, svid: svid)).to be true + end + end + + describe '#svid_identity' do + it 'returns an empty hash for nil' do + expect(helper.svid_identity(nil)).to eq({}) + end + + it 'returns type :x509 for an X509Svid' do + identity = helper.svid_identity(live_svid) + expect(identity[:type]).to eq(:x509) + expect(identity[:spiffe_id]).to eq('spiffe://test.local/workload/helper-test') + expect(identity[:trust_domain]).to eq('test.local') + expect(identity[:workload_path]).to eq('/workload/helper-test') + expect(identity[:expired]).to be false + expect(identity[:ttl_seconds]).to be_a(Integer) + end + + it 'returns type :jwt for a JwtSvid' do + jwt_svid = Legion::Crypt::Spiffe::JwtSvid.new(spiffe_id, 'tok', 'myservice', Time.now + 3600) + identity = helper.svid_identity(jwt_svid) + expect(identity[:type]).to eq(:jwt) + expect(identity[:audience]).to eq('myservice') + end + end +end diff --git a/spec/legion/crypt/spiffe_spec.rb b/spec/legion/crypt/spiffe_spec.rb new file mode 100644 index 0000000..a10c500 --- /dev/null +++ b/spec/legion/crypt/spiffe_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/spiffe' + +RSpec.describe Legion::Crypt::Spiffe do + describe '.parse_id' do + it 'parses a valid SPIFFE ID with a workload path' do + id = described_class.parse_id('spiffe://example.org/ns/default/sa/myapp') + expect(id.trust_domain).to eq('example.org') + expect(id.path).to eq('/ns/default/sa/myapp') + end + + it 'parses a SPIFFE ID with only a trust domain (empty path)' do + id = described_class.parse_id('spiffe://example.org') + expect(id.trust_domain).to eq('example.org') + expect(id.path).to eq('/') + end + + it 'returns a SpiffeId struct' do + id = described_class.parse_id('spiffe://legion.internal/workload/foo') + expect(id).to be_a(Legion::Crypt::Spiffe::SpiffeId) + end + + it 'round-trips to string correctly' do + raw = 'spiffe://legion.internal/workload/bar' + expect(described_class.parse_id(raw).to_s).to eq(raw) + end + + it 'raises InvalidSpiffeIdError for a nil input' do + expect { described_class.parse_id(nil) }.to raise_error(Legion::Crypt::Spiffe::InvalidSpiffeIdError, /non-empty string/) + end + + it 'raises InvalidSpiffeIdError for an empty string' do + expect { described_class.parse_id('') }.to raise_error(Legion::Crypt::Spiffe::InvalidSpiffeIdError, /non-empty string/) + end + + it 'raises InvalidSpiffeIdError when scheme is not spiffe' do + expect { described_class.parse_id('https://example.org/foo') } + .to raise_error(Legion::Crypt::Spiffe::InvalidSpiffeIdError, /scheme/) + end + + it 'raises InvalidSpiffeIdError when trust domain is missing' do + expect { described_class.parse_id('spiffe:///workload/foo') } + .to raise_error(Legion::Crypt::Spiffe::InvalidSpiffeIdError, /trust domain/) + end + + it 'raises InvalidSpiffeIdError when a query string is present' do + expect { described_class.parse_id('spiffe://example.org/foo?bar=1') } + .to raise_error(Legion::Crypt::Spiffe::InvalidSpiffeIdError, /query/) + end + + it 'raises InvalidSpiffeIdError when a fragment is present' do + expect { described_class.parse_id('spiffe://example.org/foo#section') } + .to raise_error(Legion::Crypt::Spiffe::InvalidSpiffeIdError, /fragment/) + end + end + + describe '.valid_id?' do + it 'returns true for a well-formed SPIFFE ID' do + expect(described_class.valid_id?('spiffe://example.org/workload')).to be true + end + + it 'returns false for an invalid SPIFFE ID' do + expect(described_class.valid_id?('http://example.org/workload')).to be false + end + + it 'returns false for nil' do + expect(described_class.valid_id?(nil)).to be false + end + end + + describe '.enabled?' do + context 'when Legion::Settings is not defined' do + before { hide_const('Legion::Settings') } + + it 'returns false' do + expect(described_class.enabled?).to be false + end + end + + context 'when security settings are nil' do + before do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).with(:security).and_return(nil) + end + + it 'returns false' do + expect(described_class.enabled?).to be false + end + end + + context 'when spiffe.enabled is false' do + before do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { enabled: false } } + ) + end + + it 'returns false' do + expect(described_class.enabled?).to be false + end + end + + context 'when spiffe.enabled is true' do + before do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { enabled: true, socket_path: '/tmp/spire.sock', trust_domain: 'test.local' } } + ) + end + + it 'returns true' do + expect(described_class.enabled?).to be true + end + end + end + + describe '.socket_path' do + it 'returns the default when settings are absent' do + hide_const('Legion::Settings') + expect(described_class.socket_path).to eq('/tmp/spire-agent/public/api.sock') + end + + it 'reads from settings when present' do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { enabled: true, socket_path: '/var/run/spire.sock' } } + ) + expect(described_class.socket_path).to eq('/var/run/spire.sock') + end + end + + describe '.trust_domain' do + it 'returns the default when settings are absent' do + hide_const('Legion::Settings') + expect(described_class.trust_domain).to eq('legion.internal') + end + + it 'reads from settings when present' do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { trust_domain: 'corp.example.com' } } + ) + expect(described_class.trust_domain).to eq('corp.example.com') + end + end + + describe '.allow_x509_fallback?' do + it 'returns false by default when settings are absent' do + hide_const('Legion::Settings') + expect(described_class.allow_x509_fallback?).to be false + end + + it 'reads the fallback flag from settings when present' do + stub_const('Legion::Settings', Module.new) + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { allow_x509_fallback: true } } + ) + expect(described_class.allow_x509_fallback?).to be true + end + end + + describe 'SpiffeId' do + let(:id) { described_class.parse_id('spiffe://example.org/ns/prod/sa/worker') } + + it 'exposes trust_domain' do + expect(id.trust_domain).to eq('example.org') + end + + it 'exposes path' do + expect(id.path).to eq('/ns/prod/sa/worker') + end + + it 'compares equal to an identical SpiffeId' do + other = described_class.parse_id('spiffe://example.org/ns/prod/sa/worker') + expect(id).to eq(other) + end + + it 'does not equal a SpiffeId with a different path' do + other = described_class.parse_id('spiffe://example.org/ns/prod/sa/other') + expect(id).not_to eq(other) + end + end + + describe 'X509Svid' do + let(:future) { Time.now + 3600 } + let(:past) { Time.now - 1 } + + it 'reports valid? true when cert, key, and expiry are set' do + svid = Legion::Crypt::Spiffe::X509Svid.new('id', 'cert', 'key', nil, future) + expect(svid.valid?).to be true + end + + it 'reports expired? false when expiry is in the future' do + svid = Legion::Crypt::Spiffe::X509Svid.new('id', 'cert', 'key', nil, future) + expect(svid.expired?).to be false + end + + it 'reports expired? true when expiry is in the past' do + svid = Legion::Crypt::Spiffe::X509Svid.new('id', 'cert', 'key', nil, past) + expect(svid.expired?).to be true + end + + it 'reports valid? false when expired' do + svid = Legion::Crypt::Spiffe::X509Svid.new('id', 'cert', 'key', nil, past) + expect(svid.valid?).to be false + end + + it 'returns a positive ttl for a future expiry' do + svid = Legion::Crypt::Spiffe::X509Svid.new('id', 'cert', 'key', nil, future) + expect(svid.ttl).to be_positive + end + end + + describe 'JwtSvid' do + let(:future) { Time.now + 3600 } + let(:past) { Time.now - 1 } + + it 'reports valid? true when token is set and not expired' do + svid = Legion::Crypt::Spiffe::JwtSvid.new('id', 'tok', 'aud', future) + expect(svid.valid?).to be true + end + + it 'reports expired? true when expiry is in the past' do + svid = Legion::Crypt::Spiffe::JwtSvid.new('id', 'tok', 'aud', past) + expect(svid.expired?).to be true + end + end +end diff --git a/spec/legion/crypt/spiffe_svid_rotation_spec.rb b/spec/legion/crypt/spiffe_svid_rotation_spec.rb new file mode 100644 index 0000000..9e60fd1 --- /dev/null +++ b/spec/legion/crypt/spiffe_svid_rotation_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/spiffe' +require 'legion/crypt/spiffe/workload_api_client' +require 'legion/crypt/spiffe/svid_rotation' + +RSpec.describe Legion::Crypt::Spiffe::SvidRotation do + let(:mock_svid) do + Legion::Crypt::Spiffe::X509Svid.new( + Legion::Crypt::Spiffe.parse_id('spiffe://test.local/workload/test'), + '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----', + '-----BEGIN EC PRIVATE KEY-----\nMIIB...\n-----END EC PRIVATE KEY-----', + nil, + Time.now + 3600 + ) + end + + let(:mock_client) { instance_double(Legion::Crypt::Spiffe::WorkloadApiClient, fetch_x509_svid: mock_svid) } + + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { enabled: true, socket_path: '/tmp/fake.sock', trust_domain: 'test.local' } } + ) + end + + subject(:rotation) { described_class.new(check_interval: 60, client: mock_client) } + + describe '#running?' do + it 'returns false before start is called' do + expect(rotation.running?).to be false + end + end + + describe '#rotate!' do + it 'fetches and stores the SVID' do + rotation.rotate! + expect(rotation.current_svid).to eq(mock_svid) + end + + it 'returns the new SVID' do + result = rotation.rotate! + expect(result).to eq(mock_svid) + end + + it 'raises when the client raises' do + allow(mock_client).to receive(:fetch_x509_svid).and_raise(Legion::Crypt::Spiffe::SvidError, 'fetch failed') + expect { rotation.rotate! }.to raise_error(Legion::Crypt::Spiffe::SvidError, /fetch failed/) + end + end + + describe '#needs_renewal?' do + it 'returns true when no SVID has been fetched yet' do + expect(rotation.needs_renewal?).to be true + end + + it 'returns false when the SVID has plenty of time left' do + rotation.rotate! + # SVID expires in 3600s; at 50% window, renewal fires below 1800s remaining. + # We just rotated so ~3600s remain — no renewal needed. + expect(rotation.needs_renewal?).to be false + end + + it 'returns true when the SVID has expired' do + expired_svid = Legion::Crypt::Spiffe::X509Svid.new( + Legion::Crypt::Spiffe.parse_id('spiffe://test.local/workload/test'), + 'cert', 'key', nil, Time.now - 1 + ) + allow(mock_client).to receive(:fetch_x509_svid).and_return(expired_svid) + rotation.rotate! + expect(rotation.needs_renewal?).to be true + end + + it 'returns true when past the renewal window fraction' do + # SVID expires in 10s, issued 15s ago (110% of TTL has elapsed → expired). + # Use a nearly-expired case: expires in 1s, issued 19s ago → 95% elapsed > 50% window. + almost_expired_svid = Legion::Crypt::Spiffe::X509Svid.new( + Legion::Crypt::Spiffe.parse_id('spiffe://test.local/workload/test'), + 'cert', 'key', nil, Time.now + 1 + ) + allow(mock_client).to receive(:fetch_x509_svid).and_return(almost_expired_svid) + rotation.rotate! + # Manually backdate @issued_at so fraction calculation shows > 50% elapsed. + rotation.instance_variable_set(:@issued_at, Time.now - 19) + expect(rotation.needs_renewal?).to be true + end + end + + describe '#start and #stop' do + it 'does not start when SPIFFE is disabled' do + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { spiffe: { enabled: false } } + ) + rotation.start + expect(rotation.running?).to be false + end + + it 'starts and stops the rotation thread' do + rotation.start + expect(rotation.running?).to be true + rotation.stop + expect(rotation.running?).to be false + end + + it 'is idempotent: calling start twice does not create two threads' do + rotation.start + thread1 = rotation.instance_variable_get(:@thread) + rotation.start + thread2 = rotation.instance_variable_get(:@thread) + expect(thread1).to eq(thread2) + ensure + rotation.stop + end + + it 'keeps the thread reference when stop times out' do + stuck_thread = instance_double(Thread, alive?: true) + allow(stuck_thread).to receive(:wakeup) + allow(stuck_thread).to receive(:join) + rotation.instance_variable_set(:@thread, stuck_thread) + rotation.instance_variable_set(:@running, true) + + rotation.stop + + expect(rotation.instance_variable_get(:@thread)).to eq(stuck_thread) + end + end +end diff --git a/spec/legion/crypt/spiffe_workload_api_client_spec.rb b/spec/legion/crypt/spiffe_workload_api_client_spec.rb new file mode 100644 index 0000000..1692481 --- /dev/null +++ b/spec/legion/crypt/spiffe_workload_api_client_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/spiffe' +require 'legion/crypt/spiffe/workload_api_client' + +RSpec.describe Legion::Crypt::Spiffe::WorkloadApiClient do + let(:client) { described_class.new(socket_path: '/tmp/fake.sock', trust_domain: 'test.local', allow_x509_fallback: false) } + + describe '#available?' do + it 'returns false when the socket file does not exist' do + allow(File).to receive(:exist?).with('/tmp/fake.sock').and_return(false) + expect(client.available?).to be false + end + + it 'returns false when the socket exists but connection fails' do + allow(File).to receive(:exist?).with('/tmp/fake.sock').and_return(true) + allow(UNIXSocket).to receive(:new).and_raise(Errno::ECONNREFUSED) + expect(client.available?).to be false + end + + it 'returns true when the socket is reachable' do + fake_sock = instance_double(UNIXSocket) + allow(File).to receive(:exist?).with('/tmp/fake.sock').and_return(true) + allow(UNIXSocket).to receive(:new).with('/tmp/fake.sock').and_return(fake_sock) + allow(fake_sock).to receive(:close) + expect(client.available?).to be true + end + end + + describe '#close_workload_api_socket' do + it 'logs socket close failures' do + fake_sock = instance_double(UNIXSocket) + close_error = StandardError.new('close failed') + allow(fake_sock).to receive(:close).and_raise(close_error) + + expect(client).to receive(:handle_exception).with( + close_error, + level: :debug, + operation: 'crypt.spiffe.workload_api_client.close_socket', + method_path: '/spire.api.agent.X509SVID/FetchX509SVID', + socket_path: '/tmp/fake.sock' + ) + + expect(client.send(:close_workload_api_socket, fake_sock, '/spire.api.agent.X509SVID/FetchX509SVID')).to be_nil + end + end + + describe '#fetch_x509_svid' do + context 'when the Workload API is unavailable (no socket)' do + before do + allow(File).to receive(:exist?).and_return(false) + end + + it 'fails closed by default' do + expect { client.fetch_x509_svid } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /Failed to fetch X.509 SVID/) + end + end + + context 'when the Workload API raises a connection error' do + before do + allow(File).to receive(:exist?).with('/tmp/fake.sock').and_return(true) + allow(UNIXSocket).to receive(:new).and_raise(Errno::ECONNREFUSED, 'connection refused') + end + + it 'raises when fallback is disabled' do + expect { client.fetch_x509_svid } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /Failed to fetch X.509 SVID/) + end + end + + context 'when explicit fallback is enabled' do + let(:client) { described_class.new(socket_path: '/tmp/fake.sock', trust_domain: 'test.local', allow_x509_fallback: true) } + + before do + allow(File).to receive(:exist?).and_return(false) + end + + it 'returns a self-signed fallback SVID' do + svid = client.fetch_x509_svid + expect(svid).to be_a(Legion::Crypt::Spiffe::X509Svid) + expect(svid.source).to eq(:fallback) + end + + it 'returns a valid (non-expired) fallback SVID' do + svid = client.fetch_x509_svid + expect(svid.valid?).to be true + end + + it 'encodes the trust domain into the fallback SPIFFE ID' do + svid = client.fetch_x509_svid + expect(svid.spiffe_id.trust_domain).to eq('test.local') + end + + it 'populates cert_pem on the fallback SVID' do + svid = client.fetch_x509_svid + expect(svid.cert_pem).to include('BEGIN CERTIFICATE') + end + + it 'populates key_pem on the fallback SVID' do + svid = client.fetch_x509_svid + expect(svid.key_pem).to include('BEGIN') + end + + it 'sets a future expiry on the fallback SVID' do + svid = client.fetch_x509_svid + expect(svid.expiry).to be > Time.now + end + end + end + + describe '#fetch_jwt_svid' do + context 'when the socket does not exist' do + before do + allow(File).to receive(:exist?).and_return(false) + end + + it 'raises SvidError' do + expect { client.fetch_jwt_svid(audience: 'myservice') } + .to raise_error(Legion::Crypt::Spiffe::SvidError, /myservice/) + end + end + end + + describe 'self-signed fallback' do + it 'generates a unique serial number each call' do + allow(client).to receive(:instance_variable_get).with(:@allow_x509_fallback).and_return(true) + allow(File).to receive(:exist?).and_return(false) + svid1 = described_class.new(socket_path: '/tmp/fake.sock', trust_domain: 'test.local', allow_x509_fallback: true).fetch_x509_svid + svid2 = described_class.new(socket_path: '/tmp/fake.sock', trust_domain: 'test.local', allow_x509_fallback: true).fetch_x509_svid + cert1 = OpenSSL::X509::Certificate.new(svid1.cert_pem) + cert2 = OpenSSL::X509::Certificate.new(svid2.cert_pem) + expect(cert1.serial).not_to eq(cert2.serial) + end + + it 'embeds the SPIFFE URI SAN in the fallback cert' do + fallback_client = described_class.new(socket_path: '/tmp/fake.sock', trust_domain: 'test.local', allow_x509_fallback: true) + allow(File).to receive(:exist?).and_return(false) + svid = fallback_client.fetch_x509_svid + cert = OpenSSL::X509::Certificate.new(svid.cert_pem) + san = cert.extensions.find { |e| e.oid == 'subjectAltName' } + expect(san).not_to be_nil + expect(san.value).to include('spiffe://test.local') + end + end + + describe 'HTTP/2 handshake' do + it 'uses a valid 9-byte HTTP/2 SETTINGS frame' do + expect(described_class::HTTP2_SETTINGS_FRAME.bytesize).to eq(9) + expect(described_class::HTTP2_SETTINGS_FRAME.bytes).to eq([0, 0, 0, 4, 0, 0, 0, 0, 0]) + end + + it 'writes the HTTP/2 preface and settings frame when connecting' do + fake_sock = instance_double(UNIXSocket, flush: nil) + allow(File).to receive(:exist?).with('/tmp/fake.sock').and_return(true) + allow(UNIXSocket).to receive(:new).with('/tmp/fake.sock').and_return(fake_sock) + expect(fake_sock).to receive(:write).with(described_class::HTTP2_PREFACE).ordered + expect(fake_sock).to receive(:write).with(described_class::HTTP2_SETTINGS_FRAME).ordered + + client.send(:connect_socket) + end + end + + describe 'minimal protobuf helpers (private, tested via send)' do + describe '#decode_varint' do + it 'decodes a single-byte varint' do + bytes = "\x05".b + value, consumed = client.send(:decode_varint, bytes, 0) + expect(value).to eq(5) + expect(consumed).to eq(1) + end + + it 'decodes a two-byte varint (value 300 = 0xAC 0x02)' do + bytes = "\xAC\x02".b + value, = client.send(:decode_varint, bytes, 0) + expect(value).to eq(300) + end + + it 'returns 0 consumed when pos is past end of bytes' do + bytes = ''.b + value, consumed = client.send(:decode_varint, bytes, 0) + expect(value).to eq(0) + expect(consumed).to eq(0) + end + end + + describe '#extract_proto_field' do + it 'returns nil when field is not present' do + result = client.send(:extract_proto_field, ''.b, field_number: 1) + expect(result).to be_nil + end + + it 'extracts a length-delimited field (wire type 2)' do + # Build a minimal protobuf: field 1, wire type 2, length 5, value "hello" + payload = "\x0A\x05hello".b + result = client.send(:extract_proto_field, payload, field_number: 1) + expect(result).to eq('hello'.b) + end + + it 'skips varint fields (wire type 0) before the target field' do + # Field 1 varint=42, field 2 length-delimited="world" + payload = "\x08\x2A\x12\x05world".b + result = client.send(:extract_proto_field, payload, field_number: 2) + expect(result).to eq('world'.b) + end + end + + describe '#extract_jwt_expiry' do + it 'extracts exp from a real-looking JWT payload segment' do + exp = (Time.now + 3600).to_i + claims = { 'exp' => exp }.to_json + b64 = Base64.urlsafe_encode64(claims, padding: false) + token = "header.#{b64}.sig" + result = client.send(:extract_jwt_expiry, token) + expect(result.to_i).to be_within(2).of(exp) + end + + it 'returns a future time when JWT has no exp claim' do + claims = { 'sub' => 'test' }.to_json + b64 = Base64.urlsafe_encode64(claims, padding: false) + token = "header.#{b64}.sig" + result = client.send(:extract_jwt_expiry, token) + expect(result).to be > Time.now + end + + it 'returns a future time for a malformed token' do + result = client.send(:extract_jwt_expiry, 'not.a.jwt') + expect(result).to be > Time.now + end + end + end +end diff --git a/spec/legion/crypt/tls_spec.rb b/spec/legion/crypt/tls_spec.rb new file mode 100644 index 0000000..7cc38fe --- /dev/null +++ b/spec/legion/crypt/tls_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/tls' + +RSpec.describe Legion::Crypt::TLS do + describe '.resolve' do + it 'returns all defaults with empty config' do + result = described_class.resolve({}) + expect(result[:enabled]).to be false + expect(result[:verify]).to eq :peer + expect(result[:ca]).to be_nil + expect(result[:cert]).to be_nil + expect(result[:key]).to be_nil + expect(result[:auto_detected]).to be false + end + + it 'returns enabled when explicitly set' do + result = described_class.resolve({ enabled: true }) + expect(result[:enabled]).to be true + end + + it 'normalizes string verify to symbol' do + result = described_class.resolve({ verify: 'mutual', cert: '/tmp/c.crt', key: '/tmp/k.key' }) + expect(result[:verify]).to eq :mutual + end + + it 'normalizes string keys to symbols' do + result = described_class.resolve({ 'enabled' => true, 'verify' => 'none' }) + expect(result[:enabled]).to be true + expect(result[:verify]).to eq :none + end + + it 'passes through file paths unchanged' do + result = described_class.resolve({ + ca: '/etc/ssl/ca.crt', + cert: '/etc/ssl/client.crt', + key: '/etc/ssl/client.key' + }) + expect(result[:ca]).to eq '/etc/ssl/ca.crt' + expect(result[:cert]).to eq '/etc/ssl/client.crt' + expect(result[:key]).to eq '/etc/ssl/client.key' + end + end + + describe 'port auto-detection' do + it 'auto-enables for AMQPS port 5671' do + result = described_class.resolve({}, port: 5671) + expect(result[:enabled]).to be true + expect(result[:auto_detected]).to be true + end + + it 'auto-enables for Redis TLS port 6380' do + result = described_class.resolve({}, port: 6380) + expect(result[:enabled]).to be true + expect(result[:auto_detected]).to be true + end + + it 'auto-enables for Memcached TLS port 11207' do + result = described_class.resolve({}, port: 11_207) + expect(result[:enabled]).to be true + expect(result[:auto_detected]).to be true + end + + it 'does not auto-enable for unknown port' do + result = described_class.resolve({}, port: 5432) + expect(result[:enabled]).to be false + expect(result[:auto_detected]).to be false + end + + it 'respects explicit enabled: false even on TLS port' do + result = described_class.resolve({ enabled: false }, port: 5671) + expect(result[:enabled]).to be false + expect(result[:auto_detected]).to be false + end + end + + describe 'mutual TLS validation' do + it 'downgrades mutual to peer when cert is missing' do + result = described_class.resolve({ verify: 'mutual', key: '/tmp/k.key' }) + expect(result[:verify]).to eq :peer + end + + it 'downgrades mutual to peer when key is missing' do + result = described_class.resolve({ verify: 'mutual', cert: '/tmp/c.crt' }) + expect(result[:verify]).to eq :peer + end + + it 'keeps mutual when both cert and key are present' do + result = described_class.resolve({ + verify: 'mutual', + cert: '/tmp/c.crt', + key: '/tmp/k.key' + }) + expect(result[:verify]).to eq :mutual + end + end + + describe '.migrate_legacy' do + it 'maps legacy transport keys to standard shape' do + legacy = { + use_tls: true, + verify_peer: true, + ca_certs: '/etc/ca.crt', + tls_cert: '/etc/client.crt', + tls_key: '/etc/client.key' + } + result = described_class.migrate_legacy(legacy) + expect(result[:enabled]).to be true + expect(result[:verify]).to eq 'peer' + expect(result[:ca]).to eq '/etc/ca.crt' + expect(result[:cert]).to eq '/etc/client.crt' + expect(result[:key]).to eq '/etc/client.key' + end + + it 'passes through standard config unchanged' do + standard = { enabled: true, verify: 'peer' } + result = described_class.migrate_legacy(standard) + expect(result).to eq standard + end + + it 'maps verify_peer false to none' do + legacy = { use_tls: true, verify_peer: false } + result = described_class.migrate_legacy(legacy) + expect(result[:verify]).to eq 'none' + end + end + + describe 'vault URI resolution' do + it 'resolves vault:// URIs when resolver is available' do + resolver = class_double('Legion::Settings::Resolver') + stub_const('Legion::Settings::Resolver', resolver) + allow(resolver).to receive(:resolve_value).with('vault://pki/issue/legion#certificate').and_return('/tmp/resolved.crt') + allow(resolver).to receive(:resolve_value).with('/tmp/ca.crt').and_return('/tmp/ca.crt') + allow(resolver).to receive(:resolve_value).with('/tmp/k.key').and_return('/tmp/k.key') + + result = described_class.resolve({ + ca: '/tmp/ca.crt', + cert: 'vault://pki/issue/legion#certificate', + key: '/tmp/k.key' + }) + expect(result[:cert]).to eq '/tmp/resolved.crt' + end + + it 'passes through when resolver is not available' do + result = described_class.resolve({ ca: 'vault://pki/ca' }) + expect(result[:ca]).to eq 'vault://pki/ca' + end + end + + describe 'TLS_PORTS' do + it 'contains AMQPS, Redis TLS, and Memcached TLS' do + expect(described_class::TLS_PORTS).to include(5671, 6380, 11_207) + end + end + + describe 'default settings' do + it 'provides tls defaults in crypt settings' do + defaults = Legion::Crypt::Settings.default + expect(defaults[:tls]).to be_a(Hash) + expect(defaults[:tls][:enabled]).to be false + expect(defaults[:tls][:verify]).to eq 'peer' + end + end +end diff --git a/spec/legion/crypt_jwt_integration_spec.rb b/spec/legion/crypt_jwt_integration_spec.rb new file mode 100644 index 0000000..cdabb76 --- /dev/null +++ b/spec/legion/crypt_jwt_integration_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt' + +RSpec.describe 'Legion::Crypt JWT convenience methods' do + before do + Legion::Settings[:crypt][:cluster_secret] = SecureRandom.hex(32) + Legion::Crypt.start + end + + describe '.issue_token' do + it 'issues an HS256 token using cluster secret' do + token = Legion::Crypt.issue_token({ node_id: 'test' }) + expect(token).to be_a(String) + expect(token.split('.').length).to eq(3) + end + + it 'issues an RS256 token using private key' do + token = Legion::Crypt.issue_token({ node_id: 'test' }, algorithm: 'RS256') + expect(token).to be_a(String) + + decoded = Legion::Crypt::JWT.decode(token) + expect(decoded[:node_id]).to eq('test') + end + + it 'uses default ttl from settings' do + token = Legion::Crypt.issue_token({ node_id: 'test' }) + decoded = Legion::Crypt::JWT.decode(token) + expect(decoded[:exp] - decoded[:iat]).to eq(3600) + end + + it 'allows overriding ttl' do + token = Legion::Crypt.issue_token({ node_id: 'test' }, ttl: 120) + decoded = Legion::Crypt::JWT.decode(token) + expect(decoded[:exp] - decoded[:iat]).to eq(120) + end + end + + describe '.verify_token' do + it 'verifies an HS256 token' do + token = Legion::Crypt.issue_token({ node_id: 'verify-test' }) + result = Legion::Crypt.verify_token(token) + expect(result[:node_id]).to eq('verify-test') + end + + it 'verifies an RS256 token' do + token = Legion::Crypt.issue_token({ node_id: 'rs256-test' }, algorithm: 'RS256') + result = Legion::Crypt.verify_token(token, algorithm: 'RS256') + expect(result[:node_id]).to eq('rs256-test') + end + + it 'raises for expired tokens' do + token = Legion::Crypt.issue_token({ node_id: 'expired' }, ttl: -1) + expect do + Legion::Crypt.verify_token(token) + end.to raise_error(Legion::Crypt::JWT::ExpiredTokenError) + end + + it 'round-trips correctly' do + payload = { node_id: 'round-trip', extensions: %w[lex-redis lex-http] } + token = Legion::Crypt.issue_token(payload) + result = Legion::Crypt.verify_token(token) + expect(result[:node_id]).to eq('round-trip') + expect(result[:extensions]).to eq(%w[lex-redis lex-http]) + end + end + + describe '.jwt_settings' do + it 'returns jwt settings hash' do + jwt = Legion::Crypt.jwt_settings + expect(jwt).to be_a(Hash) + expect(jwt[:default_algorithm]).to eq('HS256') + expect(jwt[:default_ttl]).to eq(3600) + expect(jwt[:issuer]).to eq('legion') + end + end +end diff --git a/spec/legion/crypt_spec.rb b/spec/legion/crypt_spec.rb index 781e404..3890b0a 100644 --- a/spec/legion/crypt_spec.rb +++ b/spec/legion/crypt_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/crypt' @@ -16,4 +18,248 @@ it 'can stop' do expect { Legion::Crypt.shutdown }.not_to raise_exception end + + describe '.verify_external_token' do + it 'delegates to JWT.verify_with_jwks' do + expect(Legion::Crypt::JWT).to receive(:verify_with_jwks) + .with('token', jwks_url: 'https://example.com/keys', issuers: ['iss'], audience: 'aud') + .and_return({ sub: 'test' }) + + result = Legion::Crypt.verify_external_token( + 'token', jwks_url: 'https://example.com/keys', issuers: ['iss'], audience: 'aud' + ) + expect(result[:sub]).to eq('test') + end + end + + describe '.kerberos_principal' do + it 'delegates to KerberosAuth.kerberos_principal' do + allow(Legion::Crypt::KerberosAuth).to receive(:kerberos_principal).and_return('miverso2@EXAMPLE.COM') + expect(Legion::Crypt.kerberos_principal).to eq('miverso2@EXAMPLE.COM') + end + + it 'returns nil when no Kerberos auth has occurred' do + allow(Legion::Crypt::KerberosAuth).to receive(:kerberos_principal).and_return(nil) + expect(Legion::Crypt.kerberos_principal).to be_nil + end + end + + describe 'multi-cluster module methods' do + it 'responds to :cluster' do + expect(Legion::Crypt).to respond_to(:cluster) + end + + it 'responds to :clusters' do + expect(Legion::Crypt).to respond_to(:clusters) + end + + it 'responds to :vault_client' do + expect(Legion::Crypt).to respond_to(:vault_client) + end + + it 'responds to :ldap_login_all' do + expect(Legion::Crypt).to respond_to(:ldap_login_all) + end + + it ':clusters returns a hash' do + expect(Legion::Crypt.clusters).to be_a(Hash) + end + end + + describe '.vault_connected?' do + it 'returns true when the top-level vault flag is set' do + allow(Legion::Crypt).to receive(:settings).and_return({ vault: { connected: true } }) + + expect(Legion::Crypt.vault_connected?).to be(true) + end + + it 'returns true when any clustered Vault client is connected' do + allow(Legion::Crypt).to receive(:settings).and_return({ vault: { connected: false } }) + allow(Legion::Crypt).to receive(:connected_clusters).and_return({ primary: { connected: true, token: 'abc' } }) + + expect(Legion::Crypt.vault_connected?).to be(true) + end + + it 'returns false when neither the top-level flag nor clusters are connected' do + allow(Legion::Crypt).to receive(:settings).and_return({ vault: { connected: false } }) + allow(Legion::Crypt).to receive(:connected_clusters).and_return({}) + + expect(Legion::Crypt.vault_connected?).to be(false) + end + end + + describe '.delete' do + context 'when Vault is available' do + let(:logical) { double('logical') } + + before do + allow(Vault).to receive(:logical).and_return(logical) + allow(logical).to receive(:delete).and_return(true) + end + + it 'deletes the Vault path' do + result = Legion::Crypt.delete('secret/data/legion/workers/w-1/entra') + expect(logical).to have_received(:delete).with('secret/data/legion/workers/w-1/entra') + expect(result).to include(success: true) + end + end + + context 'when Vault is not available' do + before do + allow(Vault).to receive(:logical).and_raise(StandardError, 'not connected') + end + + it 'returns failure without raising' do + result = Legion::Crypt.delete('secret/data/legion/workers/w-1/entra') + expect(result[:success]).to be false + end + end + end + + describe 'LeaseManager integration' do + before do + allow(Legion::Crypt::LeaseManager.instance).to receive(:start) + allow(Legion::Crypt::LeaseManager.instance).to receive(:start_renewal_thread) + allow(Legion::Crypt::LeaseManager.instance).to receive(:shutdown) + end + + it 'starts LeaseManager when vault is connected and leases are defined' do + Legion::Settings[:crypt][:vault][:connected] = true + Legion::Settings[:crypt][:vault][:leases] = { 'test' => { 'path' => 'secret/test' } } + Legion::Crypt.start + expect(Legion::Crypt::LeaseManager.instance).to have_received(:start) + ensure + Legion::Settings[:crypt][:vault][:connected] = false + Legion::Settings[:crypt][:vault][:leases] = {} + end + + it 'does not start LeaseManager when no leases are defined' do + Legion::Settings[:crypt][:vault][:leases] = {} + Legion::Crypt.start + expect(Legion::Crypt::LeaseManager.instance).not_to have_received(:start) + end + + it 'does not start LeaseManager when vault is not connected' do + Legion::Settings[:crypt][:vault][:connected] = false + Legion::Settings[:crypt][:vault][:leases] = { 'test' => { 'path' => 'secret/test' } } + Legion::Crypt.start + expect(Legion::Crypt::LeaseManager.instance).not_to have_received(:start) + ensure + Legion::Settings[:crypt][:vault][:leases] = {} + end + + it 'shuts down LeaseManager during shutdown' do + Legion::Crypt.shutdown + expect(Legion::Crypt::LeaseManager.instance).to have_received(:shutdown) + end + + it 'prefers the connected cluster client over the top-level vault flag' do + leases = { 'test' => { 'path' => 'secret/test' } } + secondary_client = instance_double(Vault::Client) + settings_override = Legion::Settings[:crypt].merge( + vault: Legion::Settings[:crypt][:vault].merge(leases: leases, connected: true) + ) + allow(Legion::Crypt::LeaseManager.instance).to receive(:fetched_count).and_return(1) + allow(Legion::Crypt).to receive(:settings).and_return(settings_override) + allow(Legion::Crypt).to receive(:connected_clusters).and_return({ secondary: { token: 'tok-2', connected: true } }) + allow(Legion::Crypt).to receive(:selected_connected_cluster_name).and_return(:secondary) + allow(Legion::Crypt).to receive(:vault_client).with(:secondary).and_return(secondary_client) + + Legion::Crypt.send(:start_lease_manager) + + expect(Legion::Crypt::LeaseManager.instance).to have_received(:start).with(leases, vault_client: secondary_client) + end + end + + describe '.start with kerberos clusters' do + let(:mock_renewer) do + instance_double(Legion::Crypt::TokenRenewer, start: nil, stop: nil, running?: true) + end + + before do + allow(Legion::Crypt::LeaseManager.instance).to receive(:start) + allow(Legion::Crypt::LeaseManager.instance).to receive(:start_renewal_thread) + allow(Legion::Crypt::LeaseManager.instance).to receive(:shutdown) + allow(Legion::Crypt).to receive(:connect_all_clusters) + end + + after do + Legion::Crypt.shutdown + Legion::Settings[:crypt][:vault][:clusters] = {} + end + + it 'starts a TokenRenewer for connected kerberos clusters' do + Legion::Settings[:crypt][:vault][:clusters] = { + primary: { + protocol: 'https', address: 'vault.example.com', port: 8200, + auth_method: 'kerberos', connected: true, token: 'hvs.krb-token', + lease_duration: 3600, renewable: true, + kerberos: { service_principal: 'HTTP/vault.example.com', auth_path: 'auth/kerberos/login' } + } + } + mock_client = instance_double(Vault::Client) + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(Legion::Crypt::TokenRenewer).to receive(:new).and_return(mock_renewer) + + Legion::Crypt.start + expect(Legion::Crypt::TokenRenewer).to have_received(:new) + expect(mock_renewer).to have_received(:start) + end + + it 'skips non-kerberos clusters' do + Legion::Settings[:crypt][:vault][:clusters] = { + token_based: { + protocol: 'https', address: 'vault.example.com', port: 8200, + token: 'hvs.static', connected: true + } + } + mock_client = instance_double(Vault::Client) + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(mock_client).to receive(:sys).and_return(double('sys', health_status: double(initialized?: true))) + + Legion::Crypt.start + expect(Legion::Crypt::TokenRenewer).not_to receive(:new) + end + + it 'stops all token renewers on shutdown' do + Legion::Settings[:crypt][:vault][:clusters] = { + primary: { + protocol: 'https', address: 'vault.example.com', port: 8200, + auth_method: 'kerberos', connected: true, token: 'hvs.krb-token', + lease_duration: 3600, renewable: true, + kerberos: { service_principal: 'HTTP/vault.example.com', auth_path: 'auth/kerberos/login' } + } + } + mock_client = instance_double(Vault::Client) + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(Legion::Crypt::TokenRenewer).to receive(:new).and_return(mock_renewer) + + Legion::Crypt.start + Legion::Crypt.shutdown + expect(mock_renewer).to have_received(:stop) + end + + it 'ignores repeated start calls once the lifecycle is already running' do + Legion::Settings[:crypt][:vault][:clusters] = { + primary: { + protocol: 'https', address: 'vault.example.com', port: 8200, + auth_method: 'kerberos', connected: true, token: 'hvs.krb-token', + lease_duration: 3600, renewable: true, + kerberos: { service_principal: 'HTTP/vault.example.com', auth_path: 'auth/kerberos/login' } + } + } + mock_client = instance_double(Vault::Client) + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(Legion::Crypt::TokenRenewer).to receive(:new).and_return(mock_renewer) + + Legion::Crypt.start + Legion::Crypt.start + + expect(Legion::Crypt::TokenRenewer).to have_received(:new).once + end + end end diff --git a/spec/legion/jwks_client_spec.rb b/spec/legion/jwks_client_spec.rb new file mode 100644 index 0000000..7a73c88 --- /dev/null +++ b/spec/legion/jwks_client_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/jwks_client' + +RSpec.describe Legion::Crypt::JwksClient do + let(:jwks_url) { 'https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys' } + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:jwk) { JWT::JWK.new(rsa_key, kid: 'test-kid-1') } + let(:jwks_response) do + { 'keys' => [jwk.export.transform_keys(&:to_s)] }.to_json + end + + before { described_class.clear_cache } + + describe '.fetch_keys' do + it 'fetches and parses JWKS from a URL' do + allow(described_class).to receive(:http_get).with(jwks_url).and_return(jwks_response) + + keys = described_class.fetch_keys(jwks_url) + expect(keys).to have_key('test-kid-1') + expect(keys['test-kid-1']).to be_a(OpenSSL::PKey::RSA) + end + + it 'raises on HTTP failure' do + allow(described_class).to receive(:http_get) + .and_raise(Legion::Crypt::JWT::Error, 'failed to fetch JWKS: HTTP 500') + + expect { described_class.fetch_keys(jwks_url) } + .to raise_error(Legion::Crypt::JWT::Error, /HTTP 500/) + end + + it 'raises on invalid JSON' do + allow(described_class).to receive(:http_get).and_return('not json') + + expect { described_class.fetch_keys(jwks_url) } + .to raise_error(Legion::Crypt::JWT::Error, /invalid JWKS/) + end + + it 'raises on missing keys array' do + allow(described_class).to receive(:http_get).and_return('{}') + + expect { described_class.fetch_keys(jwks_url) } + .to raise_error(Legion::Crypt::JWT::Error, /missing keys/) + end + + it 'skips malformed keys without raising' do + bad_response = { 'keys' => [{ 'kid' => 'bad', 'kty' => 'invalid' }, jwk.export.transform_keys(&:to_s)] }.to_json + allow(described_class).to receive(:http_get).and_return(bad_response) + + keys = described_class.fetch_keys(jwks_url) + expect(keys).to have_key('test-kid-1') + expect(keys).not_to have_key('bad') + end + end + + describe '.find_key' do + context 'with cached keys' do + before do + allow(described_class).to receive(:http_get).and_return(jwks_response) + described_class.fetch_keys(jwks_url) + end + + it 'returns the key for a known kid' do + key = described_class.find_key(jwks_url, 'test-kid-1') + expect(key).to be_a(OpenSSL::PKey::RSA) + end + + it 'raises for an unknown kid after refreshing cached keys' do + expect(described_class).to receive(:fetch_keys).with(jwks_url).and_call_original + + expect { described_class.find_key(jwks_url, 'unknown-kid') } + .to raise_error(Legion::Crypt::JWT::InvalidTokenError, /signing key not found/) + end + end + + context 'with expired cache' do + before do + allow(described_class).to receive(:http_get).and_return(jwks_response) + described_class.fetch_keys(jwks_url) + + # Simulate expiry + allow(described_class).to receive(:expired?).and_return(true) + end + + it 're-fetches keys on expiry' do + expect(described_class).to receive(:fetch_keys).with(jwks_url).and_call_original + described_class.find_key(jwks_url, 'test-kid-1') + end + end + + context 'with no cache' do + it 'fetches keys on first call' do + allow(described_class).to receive(:http_get).and_return(jwks_response) + + key = described_class.find_key(jwks_url, 'test-kid-1') + expect(key).to be_a(OpenSSL::PKey::RSA) + end + end + end + + describe '.clear_cache' do + it 'empties the cache' do + allow(described_class).to receive(:http_get).and_return(jwks_response) + described_class.fetch_keys(jwks_url) + + described_class.clear_cache + + # After clear, find_key must re-fetch + expect(described_class).to receive(:fetch_keys).with(jwks_url).and_call_original + described_class.find_key(jwks_url, 'test-kid-1') + end + end + + describe '.http_get' do + it 'rejects non-HTTPS JWKS URLs' do + expect do + described_class.send(:http_get, 'http://example.com/keys') + end.to raise_error(Legion::Crypt::JWT::Error, /HTTPS is required/) + end + end +end diff --git a/spec/legion/jwt_spec.rb b/spec/legion/jwt_spec.rb new file mode 100644 index 0000000..5b73d96 --- /dev/null +++ b/spec/legion/jwt_spec.rb @@ -0,0 +1,488 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/jwt' + +RSpec.describe Legion::Crypt::JWT do + let(:signing_key) { SecureRandom.hex(32) } + let(:rsa_private_key) { OpenSSL::PKey::RSA.new(2048) } + let(:rsa_public_key) { rsa_private_key.public_key } + let(:payload) { { node_id: 'test-node-001', extensions: %w[lex-redis lex-http] } } + + describe '.issue' do + it 'returns a JWT string' do + token = described_class.issue(payload, signing_key: signing_key) + expect(token).to be_a(String) + expect(token.split('.').length).to eq(3) + end + + it 'includes standard claims' do + token = described_class.issue(payload, signing_key: signing_key, ttl: 3600) + decoded = described_class.decode(token) + + expect(decoded[:iss]).to eq('legion') + expect(decoded[:iat]).to be_a(Integer) + expect(decoded[:exp]).to eq(decoded[:iat] + 3600) + expect(decoded[:jti]).to be_a(String) + end + + it 'includes custom payload' do + token = described_class.issue(payload, signing_key: signing_key) + decoded = described_class.decode(token) + + expect(decoded[:node_id]).to eq('test-node-001') + expect(decoded[:extensions]).to eq(%w[lex-redis lex-http]) + end + + it 'uses custom issuer' do + token = described_class.issue(payload, signing_key: signing_key, issuer: 'legion-test') + decoded = described_class.decode(token) + expect(decoded[:iss]).to eq('legion-test') + end + + it 'uses custom ttl' do + token = described_class.issue(payload, signing_key: signing_key, ttl: 60) + decoded = described_class.decode(token) + expect(decoded[:exp] - decoded[:iat]).to eq(60) + end + + it 'issues HS256 tokens by default' do + token = described_class.issue(payload, signing_key: signing_key) + _payload, header = JWT.decode(token, signing_key, true, algorithm: 'HS256') + expect(header['alg']).to eq('HS256') + end + + it 'issues RS256 tokens with RSA key' do + token = described_class.issue(payload, signing_key: rsa_private_key, algorithm: 'RS256') + _payload, header = JWT.decode(token, rsa_public_key, true, algorithm: 'RS256') + expect(header['alg']).to eq('RS256') + end + + it 'raises on unsupported algorithm' do + expect do + described_class.issue(payload, signing_key: signing_key, algorithm: 'none') + end.to raise_error(ArgumentError, /unsupported algorithm/) + end + + it 'generates unique jti for each token' do + token1 = described_class.issue(payload, signing_key: signing_key) + token2 = described_class.issue(payload, signing_key: signing_key) + decoded1 = described_class.decode(token1) + decoded2 = described_class.decode(token2) + expect(decoded1[:jti]).not_to eq(decoded2[:jti]) + end + + it 'does not allow payload to override reserved claims' do + token = described_class.issue( + payload.merge('iss' => 'forged', exp: 1, iat: 2, 'jti' => 'fixed-id'), + signing_key: signing_key, + issuer: 'legion-secure', + ttl: 120 + ) + decoded = described_class.decode(token) + + expect(decoded[:iss]).to eq('legion-secure') + expect(decoded[:jti]).not_to eq('fixed-id') + expect(decoded[:exp]).to eq(decoded[:iat] + 120) + end + end + + describe '.issue_identity_token' do + let(:identity_hash) do + { + id: 'identity-123', + canonical_name: 'agent@example.com', + kind: :service, + mode: :automated, + groups: (1..55).map { |i| "group-#{i}" } + } + end + let(:identity_resolved) { true } + + before do + stub_const( + 'Legion::Identity::Process', + Module.new do + class << self + attr_accessor :identity_hash, :resolved + end + + def self.resolved? + resolved + end + end + ) + + Legion::Identity::Process.identity_hash = identity_hash + Legion::Identity::Process.resolved = identity_resolved + end + + it 'issues a token with immutable identity claims and preserves extra claims' do + token = described_class.issue_identity_token( + signing_key: signing_key, + extra_claims: { + sub: 'override-me', + groups: %w[override], + role: 'worker' + }, + ttl: 120 + ) + + decoded = described_class.decode(token) + + expect(decoded[:sub]).to eq('agent@example.com') + expect(decoded[:principal_id]).to eq('identity-123') + expect(decoded[:canonical_name]).to eq('agent@example.com') + expect(decoded[:kind]).to eq('service') + expect(decoded[:mode]).to eq('automated') + expect(decoded[:groups]).to eq(identity_hash[:groups].first(50)) + expect(decoded[:role]).to eq('worker') + end + + it 'passes issuer kwarg through to JWT.issue' do + token = described_class.issue_identity_token(signing_key: signing_key, issuer: 'my-cluster') + decoded = described_class.decode(token) + expect(decoded[:iss]).to eq('my-cluster') + end + + it 'defaults issuer to legion' do + token = described_class.issue_identity_token(signing_key: signing_key) + decoded = described_class.decode(token) + expect(decoded[:iss]).to eq('legion') + end + + it 'prevents extra_claims from overriding identity fields via string keys' do + token = described_class.issue_identity_token( + signing_key: signing_key, + extra_claims: { 'sub' => 'evil', 'canonical_name' => 'forged' } + ) + decoded = described_class.decode(token) + expect(decoded[:sub]).to eq('agent@example.com') + expect(decoded[:canonical_name]).to eq('agent@example.com') + end + + it 'merges non-conflicting extra_claims into token' do + token = described_class.issue_identity_token( + signing_key: signing_key, + extra_claims: { tenant_id: 'acme', role: 'agent' } + ) + decoded = described_class.decode(token) + expect(decoded[:tenant_id]).to eq('acme') + expect(decoded[:role]).to eq('agent') + end + + it 'caps groups at 50 entries' do + token = described_class.issue_identity_token(signing_key: signing_key) + decoded = described_class.decode(token) + expect(decoded[:groups].length).to eq(50) + end + + it 'handles nil groups gracefully' do + Legion::Identity::Process.identity_hash = identity_hash.merge(groups: nil) + token = described_class.issue_identity_token(signing_key: signing_key) + decoded = described_class.decode(token) + expect(decoded[:groups]).to eq([]) + end + + it 'raises ArgumentError when Identity::Process is not defined' do + hide_const('Legion::Identity::Process') + expect do + described_class.issue_identity_token(signing_key: signing_key) + end.to raise_error(ArgumentError, /Identity::Process not resolved/) + end + + context 'when identity process is not resolved' do + let(:identity_resolved) { false } + + it 'raises an error' do + expect do + described_class.issue_identity_token(signing_key: signing_key) + end.to raise_error(ArgumentError, /Identity::Process not resolved/) + end + end + end + + describe '.verify' do + it 'verifies a valid HS256 token' do + token = described_class.issue(payload, signing_key: signing_key) + result = described_class.verify(token, verification_key: signing_key) + + expect(result[:node_id]).to eq('test-node-001') + expect(result[:iss]).to eq('legion') + end + + it 'verifies a valid RS256 token' do + token = described_class.issue(payload, signing_key: rsa_private_key, algorithm: 'RS256') + result = described_class.verify(token, verification_key: rsa_public_key, algorithm: 'RS256') + + expect(result[:node_id]).to eq('test-node-001') + end + + it 'raises ExpiredTokenError for expired tokens' do + token = described_class.issue(payload, signing_key: signing_key, ttl: -1) + + expect do + described_class.verify(token, verification_key: signing_key) + end.to raise_error(Legion::Crypt::JWT::ExpiredTokenError, /expired/) + end + + it 'raises InvalidTokenError for wrong key' do + token = described_class.issue(payload, signing_key: signing_key) + wrong_key = SecureRandom.hex(32) + + expect do + described_class.verify(token, verification_key: wrong_key) + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError, /verification failed/) + end + + it 'raises InvalidTokenError for tampered token' do + token = described_class.issue(payload, signing_key: signing_key) + parts = token.split('.') + parts[1] = Base64.urlsafe_encode64('{"node_id":"hacked"}', padding: false) + tampered = parts.join('.') + + expect do + described_class.verify(tampered, verification_key: signing_key) + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError) + end + + it 'raises DecodeError for malformed token' do + expect do + described_class.verify('not.a.jwt', verification_key: signing_key) + end.to raise_error(Legion::Crypt::JWT::DecodeError) + end + + it 'skips expiration check when disabled' do + token = described_class.issue(payload, signing_key: signing_key, ttl: -1) + + result = described_class.verify(token, verification_key: signing_key, verify_expiration: false) + expect(result[:node_id]).to eq('test-node-001') + end + + it 'skips issuer check when disabled' do + token = described_class.issue(payload, signing_key: signing_key, issuer: 'other') + + result = described_class.verify(token, verification_key: signing_key, verify_issuer: false) + expect(result[:node_id]).to eq('test-node-001') + end + + it 'raises on algorithm mismatch' do + token = described_class.issue(payload, signing_key: signing_key, algorithm: 'HS256') + + expect do + described_class.verify(token, verification_key: rsa_public_key, algorithm: 'RS256') + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError) + end + end + + describe '.decode' do + it 'decodes without verification' do + token = described_class.issue(payload, signing_key: signing_key) + result = described_class.decode(token) + + expect(result[:node_id]).to eq('test-node-001') + expect(result[:iss]).to eq('legion') + end + + it 'decodes expired tokens without error' do + token = described_class.issue(payload, signing_key: signing_key, ttl: -1) + result = described_class.decode(token) + expect(result[:node_id]).to eq('test-node-001') + end + + it 'returns symbolized keys' do + token = described_class.issue({ 'string_key' => 'value' }, signing_key: signing_key) + result = described_class.decode(token) + expect(result).to have_key(:string_key) + end + + it 'raises DecodeError for garbage input' do + expect do + described_class.decode('completely-invalid') + end.to raise_error(Legion::Crypt::JWT::DecodeError) + end + end + + describe '.verify_with_jwks' do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:kid) { 'test-kid-1' } + let(:jwks_url) { 'https://login.microsoftonline.com/test/discovery/v2.0/keys' } + + let(:token) do + payload = { sub: 'worker-1', iss: 'https://login.microsoftonline.com/test/v2.0', + aud: 'app-client-id', iat: Time.now.to_i, exp: Time.now.to_i + 3600 } + header = { kid: kid, alg: 'RS256' } + JWT.encode(payload, rsa_key, 'RS256', header) + end + + before do + allow(Legion::Crypt::JwksClient).to receive(:find_key) + .with(jwks_url, kid).and_return(rsa_key.public_key) + end + + it 'verifies a valid token' do + result = described_class.verify_with_jwks( + token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'app-client-id' + ) + expect(result[:sub]).to eq('worker-1') + end + + it 'requires issuers to be provided' do + expect do + described_class.verify_with_jwks(token, jwks_url: jwks_url, audience: 'app-client-id') + end.to raise_error(ArgumentError, /issuers is required/) + end + + it 'requires audience to be provided' do + expect do + described_class.verify_with_jwks( + token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'] + ) + end.to raise_error(ArgumentError, /audience is required/) + end + + it 'validates issuer when issuers provided' do + result = described_class.verify_with_jwks( + token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'app-client-id' + ) + expect(result[:sub]).to eq('worker-1') + end + + it 'rejects wrong issuer' do + expect do + described_class.verify_with_jwks( + token, + jwks_url: jwks_url, + issuers: ['https://other.issuer.com'], + audience: 'app-client-id' + ) + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError, /issuer not allowed/) + end + + it 'validates audience when provided' do + result = described_class.verify_with_jwks( + token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'app-client-id' + ) + expect(result[:sub]).to eq('worker-1') + end + + it 'rejects wrong audience' do + expect do + described_class.verify_with_jwks( + token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'wrong-audience' + ) + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError, /audience mismatch/) + end + + it 'rejects expired token' do + expired_payload = { sub: 'worker-1', iat: Time.now.to_i - 7200, exp: Time.now.to_i - 3600 } + expired_token = JWT.encode(expired_payload, rsa_key, 'RS256', { kid: kid, alg: 'RS256' }) + + expect do + described_class.verify_with_jwks( + expired_token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'app-client-id' + ) + end.to raise_error(Legion::Crypt::JWT::ExpiredTokenError) + end + + it 'rejects token with missing kid' do + no_kid_token = JWT.encode({ sub: 'test' }, rsa_key, 'RS256') + + expect do + described_class.verify_with_jwks( + no_kid_token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'app-client-id' + ) + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError, /missing kid/) + end + + it 'rejects token signed with wrong key' do + other_key = OpenSSL::PKey::RSA.generate(2048) + bad_token = JWT.encode({ sub: 'test', exp: Time.now.to_i + 3600 }, other_key, 'RS256', + { kid: kid, alg: 'RS256' }) + + expect do + described_class.verify_with_jwks( + bad_token, + jwks_url: jwks_url, + issuers: ['https://login.microsoftonline.com/test/v2.0'], + audience: 'app-client-id' + ) + end.to raise_error(Legion::Crypt::JWT::InvalidTokenError, /signature verification failed/) + end + end + + describe '.decode_header' do + let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) } + + it 'extracts header fields from a JWT' do + token = JWT.encode({ sub: 'test' }, rsa_key, 'RS256', { kid: 'k1', alg: 'RS256' }) + header = described_class.send(:decode_header, token) + expect(header['kid']).to eq('k1') + expect(header['alg']).to eq('RS256') + end + + it 'raises on invalid token format' do + expect { described_class.send(:decode_header, 'not.a.valid.token.format') } + .to raise_error(Legion::Crypt::JWT::DecodeError) + end + end + + describe 'error hierarchy' do + it 'all errors inherit from Legion::Crypt::JWT::Error' do + expect(Legion::Crypt::JWT::ExpiredTokenError.ancestors).to include(Legion::Crypt::JWT::Error) + expect(Legion::Crypt::JWT::InvalidTokenError.ancestors).to include(Legion::Crypt::JWT::Error) + expect(Legion::Crypt::JWT::DecodeError.ancestors).to include(Legion::Crypt::JWT::Error) + end + + it 'Legion::Crypt::JWT::Error inherits from StandardError' do + expect(Legion::Crypt::JWT::Error.ancestors).to include(StandardError) + end + end + + describe 'SUPPORTED_ALGORITHMS' do + it 'includes HS256 and RS256' do + expect(described_class::SUPPORTED_ALGORITHMS).to contain_exactly('HS256', 'RS256') + end + end + + describe 'round-trip' do + it 'HS256 issue -> verify preserves all claims' do + original = { node_id: 'round-trip', count: 42, nested: { key: 'value' } } + token = described_class.issue(original, signing_key: signing_key, ttl: 300) + result = described_class.verify(token, verification_key: signing_key) + + expect(result[:node_id]).to eq('round-trip') + expect(result[:count]).to eq(42) + expect(result[:nested]).to eq({ 'key' => 'value' }) + end + + it 'RS256 issue -> verify preserves all claims' do + original = { node_id: 'rs256-trip', role: 'worker' } + token = described_class.issue(original, signing_key: rsa_private_key, algorithm: 'RS256') + result = described_class.verify(token, verification_key: rsa_public_key, algorithm: 'RS256') + + expect(result[:node_id]).to eq('rs256-trip') + expect(result[:role]).to eq('worker') + end + end +end diff --git a/spec/legion/kerberos_auth_spec.rb b/spec/legion/kerberos_auth_spec.rb new file mode 100644 index 0000000..3b0236c --- /dev/null +++ b/spec/legion/kerberos_auth_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/kerberos_auth' + +RSpec.describe Legion::Crypt::KerberosAuth do + let(:vault_client) { instance_double(Vault::Client) } + let(:vault_token) { 'hvs.kerberos-token' } + let(:auth_hash) do + { + client_token: vault_token, + lease_duration: 3600, + renewable: true, + policies: %w[default legion-worker], + metadata: { username: 'miverso2' } + } + end + let(:response_hash) { { auth: auth_hash } } + + before do + stub_const('Vault::HTTPClientError', Class.new(StandardError)) + stub_const('Legion::Extensions::Kerberos::Helpers::Spnego', + Module.new do + def obtain_spnego_token(service_principal:) # rubocop:disable Lint/UnusedMethodArgument + { success: true, token: 'fake-spnego-b64' } + end + end) + described_class.instance_variable_set(:@spnego_available, nil) + allow(vault_client).to receive(:put).and_return(response_hash) + end + + after do + described_class.instance_variable_set(:@spnego_available, nil) + end + + describe '.login' do + it 'obtains a SPNEGO token and exchanges it for a Vault token' do + result = described_class.login( + vault_client: vault_client, + service_principal: 'HTTP/vault.example.com' + ) + expect(result[:token]).to eq(vault_token) + expect(result[:lease_duration]).to eq(3600) + expect(result[:renewable]).to be true + expect(result[:policies]).to include('legion-worker') + end + + it 'sends the SPNEGO token as an Authorization header' do + expect(vault_client).to receive(:put).with( + '/v1/auth/kerberos/login', + '{}', + 'Authorization' => 'Negotiate fake-spnego-b64' + ).and_return(response_hash) + + described_class.login( + vault_client: vault_client, + service_principal: 'HTTP/vault.example.com' + ) + end + + it 'uses a custom auth_path when provided' do + expect(vault_client).to receive(:put).with( + '/v1/auth/custom/login', + '{}', + 'Authorization' => 'Negotiate fake-spnego-b64' + ).and_return(response_hash) + + described_class.login( + vault_client: vault_client, + service_principal: 'HTTP/vault.example.com', + auth_path: 'auth/custom/login' + ) + end + + it 'does not clear or modify the vault client namespace' do + expect(vault_client).not_to receive(:namespace=) + + described_class.login( + vault_client: vault_client, + service_principal: 'HTTP/vault.example.com' + ) + end + + context 'when lex-kerberos is not installed' do + before do + hide_const('Legion::Extensions::Kerberos::Helpers::Spnego') + described_class.instance_variable_set(:@spnego_available, nil) + end + + it 'raises GemMissingError' do + expect do + described_class.login(vault_client: vault_client, service_principal: 'HTTP/vault.example.com') + end.to raise_error(Legion::Crypt::KerberosAuth::GemMissingError, /lex-kerberos/) + end + end + + context 'when GSSAPI fails' do + before do + stub_const('Legion::Extensions::Kerberos::Helpers::Spnego', + Module.new do + def obtain_spnego_token(service_principal:) # rubocop:disable Lint/UnusedMethodArgument + { success: false, error: 'No credentials cache found' } + end + end) + described_class.instance_variable_set(:@spnego_available, nil) + end + + it 'raises AuthError with the GSSAPI message' do + expect do + described_class.login(vault_client: vault_client, service_principal: 'HTTP/vault.example.com') + end.to raise_error(Legion::Crypt::KerberosAuth::AuthError, /No credentials cache found/) + end + end + + context 'when Vault returns no auth data' do + before do + allow(vault_client).to receive(:put).and_return({ auth: nil }) + end + + it 'raises AuthError' do + expect do + described_class.login(vault_client: vault_client, service_principal: 'HTTP/vault.example.com') + end.to raise_error(Legion::Crypt::KerberosAuth::AuthError, /no auth data/) + end + end + + context 'when Vault returns an HTTP error' do + before do + allow(vault_client).to receive(:put).and_raise( + Vault::HTTPClientError.new('permission denied') + ) + end + + it 'raises AuthError without touching the namespace' do + expect(vault_client).not_to receive(:namespace=) + + expect do + described_class.login(vault_client: vault_client, service_principal: 'HTTP/vault.example.com') + end.to raise_error(Legion::Crypt::KerberosAuth::AuthError, /permission denied/) + end + end + end + + describe '.spnego_available?' do + before { described_class.instance_variable_set(:@spnego_available, nil) } + + it 'returns true when lex-kerberos Spnego module is defined' do + expect(described_class.spnego_available?).to be true + end + end + + describe '.reset!' do + it 'clears the cached spnego_available state' do + described_class.spnego_available? + described_class.reset! + expect(described_class.instance_variable_get(:@spnego_available)).to be_nil + end + end + + describe '.kerberos_principal' do + before { described_class.instance_variable_set(:@kerberos_principal, nil) } + + it 'is nil before authentication' do + expect(described_class.kerberos_principal).to be_nil + end + + it 'stores the principal after successful login' do + described_class.login(vault_client: vault_client, service_principal: 'HTTP/vault.example.com') + expect(described_class.kerberos_principal).to eq('miverso2') + end + + it 'resets on reset!' do + described_class.instance_variable_set(:@kerberos_principal, 'someone') + described_class.reset! + expect(described_class.kerberos_principal).to be_nil + end + + it 'clears a stale principal at the start of login before attempting auth' do + described_class.instance_variable_set(:@kerberos_principal, 'stale@EXAMPLE.COM') + allow(vault_client).to receive(:put).and_raise(Vault::HTTPClientError.new('forbidden')) + expect do + described_class.login(vault_client: vault_client, service_principal: 'HTTP/vault.example.com') + end.to raise_error(Legion::Crypt::KerberosAuth::AuthError) + expect(described_class.kerberos_principal).to be_nil + end + end +end diff --git a/spec/legion/ldap_auth_spec.rb b/spec/legion/ldap_auth_spec.rb new file mode 100644 index 0000000..b59d304 --- /dev/null +++ b/spec/legion/ldap_auth_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/vault_cluster' +require 'legion/crypt/ldap_auth' + +RSpec.describe Legion::Crypt::LdapAuth do + let(:cluster_ldap_one) do + { protocol: 'https', address: 'vault-1.example.com', port: 8200, + token: nil, connected: false, auth_method: 'ldap' } + end + let(:cluster_ldap_two) do + { protocol: 'https', address: 'vault-2.example.com', port: 8200, + token: nil, connected: false, auth_method: 'ldap' } + end + let(:cluster_token_only) do + { protocol: 'https', address: 'vault-3.example.com', port: 8200, + token: 'static-token', connected: true, auth_method: 'token' } + end + + let(:test_clusters) { { one: cluster_ldap_one, two: cluster_ldap_two, three: cluster_token_only } } + + let(:test_object) do + obj = Object.new + obj.extend(Legion::Crypt::VaultCluster) + obj.extend(described_class) + vault_settings_hash = { default: :one, clusters: test_clusters } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + let(:mock_vault_client) { instance_double(Vault::Client) } + let(:mock_logical) { instance_double(Vault::Logical) } + let(:mock_secret) { double('secret') } + let(:mock_auth) { double('auth') } + + before do + allow(Vault::Client).to receive(:new).and_return(mock_vault_client) + allow(mock_vault_client).to receive(:namespace=) + allow(mock_vault_client).to receive(:token=) + allow(mock_vault_client).to receive(:logical).and_return(mock_logical) + allow(mock_logical).to receive(:write).and_return(mock_secret) + allow(mock_secret).to receive(:auth).and_return(mock_auth) + allow(mock_auth).to receive(:client_token).and_return('new-vault-token') + allow(mock_auth).to receive(:lease_duration).and_return(3600) + allow(mock_auth).to receive(:renewable?).and_return(true) + allow(mock_auth).to receive(:policies).and_return(['default']) + end + + describe '#ldap_login' do + it 'writes to the correct LDAP login path' do + expect(mock_logical).to receive(:write) + .with('auth/ldap/login/jdoe', password: 'secret') + test_object.ldap_login(cluster_name: :one, username: 'jdoe', password: 'secret') + end + + it 'stores the returned token in the cluster config' do + test_object.ldap_login(cluster_name: :one, username: 'jdoe', password: 'secret') + expect(test_clusters[:one][:token]).to eq('new-vault-token') + end + + it 'marks the cluster as connected' do + test_object.ldap_login(cluster_name: :one, username: 'jdoe', password: 'secret') + expect(test_clusters[:one][:connected]).to be(true) + end + + it 'updates the cached vault client token' do + test_object.ldap_login(cluster_name: :one, username: 'jdoe', password: 'secret') + expect(mock_vault_client).to have_received(:token=).with('new-vault-token') + end + + it 'returns a result hash with token, lease_duration, renewable, and policies' do + result = test_object.ldap_login(cluster_name: :one, username: 'jdoe', password: 'secret') + expect(result).to include( + token: 'new-vault-token', + lease_duration: 3600, + renewable: true, + policies: ['default'] + ) + end + + it 'accepts cluster_name as a string and converts to symbol' do + result = test_object.ldap_login(cluster_name: 'one', username: 'jdoe', password: 'secret') + expect(result[:token]).to eq('new-vault-token') + end + + it 'does not set the top-level vault connected flag for multi-cluster LDAP auth' do + vault_hash = { connected: false } + crypt_hash = { vault: vault_hash } + allow(Legion::Settings).to receive(:[]).with(:crypt).and_return(crypt_hash) + + test_object.ldap_login(cluster_name: :one, username: 'jdoe', password: 'secret') + expect(vault_hash[:connected]).to be(false) + end + end + + describe '#ldap_login_all' do + it 'authenticates to all clusters with auth_method=ldap' do + results = test_object.ldap_login_all(username: 'jdoe', password: 'secret') + expect(results.keys).to contain_exactly(:one, :two) + end + + it 'skips clusters without auth_method=ldap' do + results = test_object.ldap_login_all(username: 'jdoe', password: 'secret') + expect(results).not_to have_key(:three) + end + + it 'captures errors per cluster without stopping iteration' do + call_count = 0 + allow(mock_logical).to receive(:write) do + call_count += 1 + raise StandardError, 'connection refused' if call_count == 1 + + mock_secret + end + + results = test_object.ldap_login_all(username: 'jdoe', password: 'secret') + expect(results[:one]).to include(error: 'connection refused') + expect(results[:two]).to include(token: 'new-vault-token') + end + + it 'returns an empty hash when no clusters use ldap auth' do + obj = Object.new + obj.extend(Legion::Crypt::VaultCluster) + obj.extend(described_class) + vault_settings_hash = { default: :three, clusters: { three: cluster_token_only } } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + + expect(obj.ldap_login_all(username: 'jdoe', password: 'secret')).to eq({}) + end + end +end diff --git a/spec/legion/lease_manager_spec.rb b/spec/legion/lease_manager_spec.rb new file mode 100644 index 0000000..2849fa1 --- /dev/null +++ b/spec/legion/lease_manager_spec.rb @@ -0,0 +1,817 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/lease_manager' + +RSpec.describe Legion::Crypt::LeaseManager do + subject(:manager) { described_class.instance } + + let(:vault_response) do + double('Vault::Secret', + data: { username: 'rabbit_user', password: 'rabbit_pass' }, + lease_id: 'rabbitmq/creds/legion-role/abc123', + lease_duration: 3600, + renewable?: true) + end + + let(:lease_definitions) do + { 'rabbitmq' => { 'path' => 'rabbitmq/creds/legion-role' } } + end + + before(:each) do + manager.reset! + allow(Vault).to receive_message_chain(:logical, :read).and_return(vault_response) + end + + describe '.instance' do + it 'returns the same object on repeated calls' do + expect(described_class.instance).to be(described_class.instance) + end + end + + describe '#start' do + it 'fetches each defined lease from Vault' do + expect(Vault.logical).to receive(:read).with('rabbitmq/creds/legion-role').and_return(vault_response) + manager.start(lease_definitions) + end + + context 'when vault_client: is provided' do + let(:mock_vault_client) { double('Vault::Client') } + let(:mock_logical) { double('Vault::Logical') } + + before do + allow(mock_vault_client).to receive(:logical).and_return(mock_logical) + allow(mock_logical).to receive(:read).and_return(vault_response) + end + + it 'uses the provided vault_client for reads' do + expect(mock_logical).to receive(:read).with('rabbitmq/creds/legion-role').and_return(vault_response) + expect(Vault).not_to receive(:logical) + manager.start(lease_definitions, vault_client: mock_vault_client) + end + + it 'stores the vault_client for use by sys operations' do + manager.start(lease_definitions, vault_client: mock_vault_client) + expect(manager.instance_variable_get(:@vault_client)).to eq(mock_vault_client) + end + end + + it 'caches the lease data' do + manager.start(lease_definitions) + expect(manager.lease_data('rabbitmq')).to eq({ username: 'rabbit_user', password: 'rabbit_pass' }) + end + + it 'tracks lease metadata with lease_id' do + manager.start(lease_definitions) + meta = manager.active_leases['rabbitmq'] + expect(meta[:lease_id]).to eq('rabbitmq/creds/legion-role/abc123') + end + + it 'tracks lease metadata with renewable flag' do + manager.start(lease_definitions) + meta = manager.active_leases['rabbitmq'] + expect(meta[:renewable]).to be(true) + end + + it 'tracks lease metadata with lease_duration' do + manager.start(lease_definitions) + meta = manager.active_leases['rabbitmq'] + expect(meta[:lease_duration]).to eq(3600) + end + + it 'tracks lease metadata with expires_at as a Time' do + before_start = Time.now + manager.start(lease_definitions) + meta = manager.active_leases['rabbitmq'] + expect(meta[:expires_at]).to be_a(Time) + expect(meta[:expires_at]).to be >= (before_start + 3600) + end + + it 'stores the definition path in active_leases for reissue' do + manager.start(lease_definitions) + meta = manager.active_leases['rabbitmq'] + expect(meta[:path]).to eq('rabbitmq/creds/legion-role') + end + + it 'stores the path from symbol-keyed definitions' do + sym_defs = { rabbitmq: { path: 'rabbitmq/creds/sym-role' } } + manager.start(sym_defs) + meta = manager.active_leases[:rabbitmq] + expect(meta[:path]).to eq('rabbitmq/creds/sym-role') + end + + it 'handles Vault read failure gracefully without raising' do + allow(Vault).to receive_message_chain(:logical, :read).and_raise(StandardError, 'vault unavailable') + expect { manager.start(lease_definitions) }.not_to raise_error + end + + it 'skips failed leases and keeps others empty' do + allow(Vault).to receive_message_chain(:logical, :read).and_raise(StandardError, 'vault unavailable') + manager.start(lease_definitions) + expect(manager.active_leases).to be_empty + end + + it 'is a no-op with empty definitions' do + expect(Vault.logical).not_to receive(:read) + manager.start({}) + expect(manager.active_leases).to be_empty + end + + context 'when a valid lease is already cached' do + before { manager.start(lease_definitions) } + + it 'does not create a second lease on repeated start' do + expect(Vault.logical).not_to receive(:read) + manager.start(lease_definitions) + end + + it 'preserves the original cached credentials' do + manager.start(lease_definitions) + expect(manager.fetch('rabbitmq', :username)).to eq('rabbit_user') + end + end + + context 'when a cached lease is expired' do + let(:expired_response) do + double('Vault::Secret', + data: { username: 'old_user', password: 'old_pass' }, + lease_id: 'rabbitmq/creds/legion-role/expired123', + lease_duration: 3600, + renewable?: true) + end + + let(:fresh_response) do + double('Vault::Secret', + data: { username: 'new_user', password: 'new_pass' }, + lease_id: 'rabbitmq/creds/legion-role/fresh456', + lease_duration: 3600, + renewable?: true) + end + + before do + allow(Vault).to receive_message_chain(:logical, :read).and_return(expired_response) + manager.start(lease_definitions) + manager.active_leases['rabbitmq'][:expires_at] = Time.now - 1 + allow(Vault).to receive_message_chain(:logical, :read).and_return(fresh_response) + allow(Vault).to receive_message_chain(:sys, :revoke) + end + + it 'revokes the expired lease before re-fetching' do + sys_double = instance_double(Vault::Sys) + allow(Vault).to receive(:sys).and_return(sys_double) + expect(sys_double).to receive(:revoke).with('rabbitmq/creds/legion-role/expired123') + manager.start(lease_definitions) + end + + it 'fetches new credentials when the cached lease has expired' do + expect(Vault.logical).to receive(:read).with('rabbitmq/creds/legion-role').and_return(fresh_response) + manager.start(lease_definitions) + end + + it 'caches the new credentials after re-fetch' do + manager.start(lease_definitions) + expect(manager.fetch('rabbitmq', :username)).to eq('new_user') + end + + it 'stores the new lease_id after re-fetch' do + manager.start(lease_definitions) + expect(manager.active_leases['rabbitmq'][:lease_id]).to eq('rabbitmq/creds/legion-role/fresh456') + end + end + end + + describe '#fetch' do + before { manager.start(lease_definitions) } + + it 'returns the value for a valid name and symbol key' do + expect(manager.fetch('rabbitmq', :username)).to eq('rabbit_user') + end + + it 'returns the value for a valid name and string key' do + expect(manager.fetch('rabbitmq', 'username')).to eq('rabbit_user') + end + + it 'returns nil for an unknown lease name' do + expect(manager.fetch('unknown_lease', :username)).to be_nil + end + + it 'returns nil for an unknown key' do + expect(manager.fetch('rabbitmq', :nonexistent_key)).to be_nil + end + end + + describe '#lease_data' do + it 'returns the full data hash for a known lease' do + manager.start(lease_definitions) + expect(manager.lease_data('rabbitmq')).to eq({ username: 'rabbit_user', password: 'rabbit_pass' }) + end + + it 'returns nil for an unknown lease' do + expect(manager.lease_data('nonexistent')).to be_nil + end + end + + describe '#register_ref' do + it 'stores a settings path reference without error' do + expect { manager.register_ref('rabbitmq', :username, 'transport.connection.username') }.not_to raise_error + end + end + + describe '#push_to_settings' do + let(:vault_response) do + double('Vault::Secret', + data: { username: 'new_user', password: 'new_pass' }, + lease_id: 'rabbitmq/creds/legion-role/def456', + lease_duration: 3600, + renewable?: true) + end + + before do + allow(Vault).to receive_message_chain(:logical, :read).and_return(vault_response) + manager.start({ 'rabbitmq' => { 'path' => 'rabbitmq/creds/legion-role' } }) + end + + it 'updates settings values at registered paths' do + connection_hash = { username: 'old_user', password: 'old_pass' } + transport_hash = { connection: connection_hash } + allow(Legion::Settings).to receive(:[]).with(:transport).and_return(transport_hash) + + manager.register_ref('rabbitmq', 'username', %i[transport connection username]) + manager.register_ref('rabbitmq', 'password', %i[transport connection password]) + manager.push_to_settings('rabbitmq') + + expect(connection_hash[:username]).to eq('new_user') + expect(connection_hash[:password]).to eq('new_pass') + end + + it 'does nothing when no refs are registered for the lease' do + expect { manager.push_to_settings('rabbitmq') }.not_to raise_error + end + + it 'does nothing for an unknown lease name' do + expect { manager.push_to_settings('unknown') }.not_to raise_error + end + end + + describe '#start_renewal_thread' do + let(:vault_response) do + double('Vault::Secret', + data: { username: 'user1', password: 'pass1' }, + lease_id: 'rabbitmq/creds/role/abc', + lease_duration: 10, + renewable?: true) + end + + before do + allow(Vault).to receive_message_chain(:logical, :read).and_return(vault_response) + end + + it 'starts a background thread' do + manager.start({ 'rabbitmq' => { 'path' => 'rabbitmq/creds/legion-role' } }) + manager.start_renewal_thread + expect(manager.renewal_thread_alive?).to eq(true) + manager.shutdown + end + + it 'is stopped by shutdown' do + manager.start({ 'rabbitmq' => { 'path' => 'rabbitmq/creds/legion-role' } }) + manager.start_renewal_thread + manager.shutdown + sleep(0.1) # give thread time to stop + expect(manager.renewal_thread_alive?).to eq(false) + end + + it 'is idempotent — second call is a no-op' do + manager.start({ 'rabbitmq' => { 'path' => 'rabbitmq/creds/legion-role' } }) + manager.start_renewal_thread + thread1 = manager.instance_variable_get(:@renewal_thread) + manager.start_renewal_thread + thread2 = manager.instance_variable_get(:@renewal_thread) + expect(thread1).to be(thread2) + manager.shutdown + end + + it 'keeps tracking the renewal thread if it does not stop within the timeout' do + stuck_thread = instance_double(Thread, alive?: true) + allow(stuck_thread).to receive(:wakeup) + allow(stuck_thread).to receive(:join) + manager.instance_variable_set(:@renewal_thread, stuck_thread) + manager.instance_variable_get(:@state_mutex).synchronize do + manager.instance_variable_set(:@running, true) + end + + manager.send(:stop_renewal_thread) + + expect(manager.instance_variable_get(:@renewal_thread)).to eq(stuck_thread) + end + end + + describe '#lease_valid?' do + it 'returns false when no lease exists for the name' do + expect(manager.send(:lease_valid?, 'rabbitmq')).to be(false) + end + + it 'returns true when the lease exists and has not expired' do + manager.start(lease_definitions) + expect(manager.send(:lease_valid?, 'rabbitmq')).to be(true) + end + + it 'returns false when the lease exists but expires_at is in the past' do + manager.start(lease_definitions) + manager.active_leases['rabbitmq'][:expires_at] = Time.now - 1 + expect(manager.send(:lease_valid?, 'rabbitmq')).to be(false) + end + + it 'returns false when expires_at is nil' do + manager.start(lease_definitions) + manager.active_leases['rabbitmq'][:expires_at] = nil + expect(manager.send(:lease_valid?, 'rabbitmq')).to be(false) + end + end + + describe '#approaching_expiry?' do + it 'returns true when past 50% of lease TTL' do + lease = { expires_at: Time.now + 10, lease_duration: 100 } + expect(manager.send(:approaching_expiry?, lease)).to eq(true) + end + + it 'returns false when before 50% of lease TTL' do + lease = { expires_at: Time.now + 80, lease_duration: 100 } + expect(manager.send(:approaching_expiry?, lease)).to eq(false) + end + + it 'returns true when expires_at is nil' do + lease = { expires_at: nil, lease_duration: 100 } + expect(manager.send(:approaching_expiry?, lease)).to eq(true) + end + end + + describe '#renew_lease' do + before { manager.start(lease_definitions) } + + it 'refreshes lease_duration and renewable from the renewal response' do + renew_response = double('Vault::Secret', + data: { username: 'renewed_user', password: 'renewed_pass' }, + lease_id: 'rabbitmq/creds/legion-role/abc123', + lease_duration: 1200, + renewable?: false) + sys_double = instance_double(Vault::Sys) + allow(Vault).to receive(:sys).and_return(sys_double) + allow(sys_double).to receive(:renew).and_return(renew_response) + + manager.send(:renew_lease, 'rabbitmq', manager.active_leases['rabbitmq']) + + expect(manager.active_leases['rabbitmq'][:lease_duration]).to eq(1200) + expect(manager.active_leases['rabbitmq'][:renewable]).to be(false) + expect(manager.fetch('rabbitmq', :username)).to eq('renewed_user') + end + end + + describe '#shutdown' do + before { manager.start(lease_definitions) } + + it 'revokes active leases via Vault' do + sys_double = instance_double(Vault::Sys) + allow(Vault).to receive(:sys).and_return(sys_double) + expect(sys_double).to receive(:revoke).with('rabbitmq/creds/legion-role/abc123') + manager.shutdown + end + + context 'when started with a vault_client' do + let(:mock_vault_client) { double('Vault::Client') } + let(:mock_logical) { double('Vault::Logical') } + let(:mock_sys) { double('Vault::Sys') } + + before do + manager.reset! + allow(mock_vault_client).to receive(:logical).and_return(mock_logical) + allow(mock_vault_client).to receive(:sys).and_return(mock_sys) + allow(mock_logical).to receive(:read).and_return(vault_response) + allow(mock_sys).to receive(:revoke) + manager.start(lease_definitions, vault_client: mock_vault_client) + end + + it 'uses the cluster vault_client to revoke leases' do + expect(mock_sys).to receive(:revoke).with('rabbitmq/creds/legion-role/abc123') + expect(Vault).not_to receive(:sys) + manager.shutdown + end + end + + it 'clears the cache after shutdown' do + allow(Vault).to receive_message_chain(:sys, :revoke) + manager.shutdown + expect(manager.active_leases).to be_empty + end + + it 'clears lease data after shutdown' do + allow(Vault).to receive_message_chain(:sys, :revoke) + manager.shutdown + expect(manager.lease_data('rabbitmq')).to be_nil + end + + it 'handles revocation failure gracefully without raising' do + allow(Vault).to receive_message_chain(:sys, :revoke).and_raise(StandardError, 'revoke failed') + expect { manager.shutdown }.not_to raise_error + end + + it 'skips leases with nil lease_id during shutdown' do + nil_lease_response = double('Vault::Secret', + data: { token: 'abc' }, + lease_id: nil, + lease_duration: 900, + renewable?: false) + manager.reset! + allow(Vault).to receive_message_chain(:logical, :read).and_return(nil_lease_response) + manager.start(lease_definitions) + expect(Vault).not_to receive(:sys) + manager.shutdown + end + + it 'skips leases with empty lease_id during shutdown' do + empty_lease_response = double('Vault::Secret', + data: { token: 'abc' }, + lease_id: '', + lease_duration: 900, + renewable?: false) + manager.reset! + allow(Vault).to receive_message_chain(:logical, :read).and_return(empty_lease_response) + manager.start(lease_definitions) + expect(Vault).not_to receive(:sys) + manager.shutdown + end + end + + describe '#register_dynamic_lease' do + let(:dynamic_response) do + double('Vault::Secret', + data: { username: 'dyn_user', password: 'dyn_pass' }, + lease_id: 'rabbitmq/creds/legionio-infra/dyn123', + lease_duration: 604_800, + renewable?: true) + end + + it 'populates the lease cache with credential data' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect(manager.lease_data(:rabbitmq)).to eq({ username: 'dyn_user', password: 'dyn_pass' }) + end + + it 'stores the lease_id in active_leases' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect(manager.active_leases[:rabbitmq][:lease_id]).to eq('rabbitmq/creds/legionio-infra/dyn123') + end + + it 'stores lease_duration in active_leases' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect(manager.active_leases[:rabbitmq][:lease_duration]).to eq(604_800) + end + + it 'stores fetched_at in active_leases as a Time' do + before_call = Time.now + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect(manager.active_leases[:rabbitmq][:fetched_at]).to be_a(Time) + expect(manager.active_leases[:rabbitmq][:fetched_at]).to be >= before_call + end + + it 'stores renewable via predicate method (true)' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect(manager.active_leases[:rabbitmq][:renewable]).to be(true) + end + + it 'stores the path for reissue' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect(manager.active_leases[:rabbitmq][:path]).to eq('rabbitmq/creds/legionio-infra') + end + + it 'registers settings refs provided' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [ + { path: %i[transport connection user], key: :username }, + { path: %i[transport connection password], key: :password } + ] + ) + refs = manager.instance_variable_get(:@refs) + expect(refs[:rabbitmq][:username]).to eq(%i[transport connection user]) + expect(refs[:rabbitmq][:password]).to eq(%i[transport connection password]) + end + + it 'is wrapped in state_mutex synchronize (idempotent on re-registration)' do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + expect do + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: dynamic_response, + settings_refs: [] + ) + end.not_to raise_error + end + end + + describe '#renew_lease with static leases' do + let(:fresh_response) do + double('Vault::Secret', + data: { username: 'fresh_user', password: 'fresh_pass' }, + lease_id: 'rabbitmq/creds/legion-role/fresh789', + lease_duration: 3600, + renewable?: true) + end + + before { manager.start(lease_definitions) } + + it 'falls back to reissue when sys.renew fails and path is available' do + sys_double = instance_double(Vault::Sys) + allow(Vault).to receive(:sys).and_return(sys_double) + allow(sys_double).to receive(:renew).and_raise(StandardError, 'permission denied') + allow(Vault).to receive_message_chain(:logical, :read).and_return(fresh_response) + + manager.send(:renew_lease, 'rabbitmq', manager.active_leases['rabbitmq']) + + expect(manager.fetch('rabbitmq', :username)).to eq('fresh_user') + end + + it 'updates credentials after successful reissue fallback' do + sys_double = instance_double(Vault::Sys) + allow(Vault).to receive(:sys).and_return(sys_double) + allow(sys_double).to receive(:renew).and_raise(StandardError, 'expired') + allow(Vault).to receive_message_chain(:logical, :read).and_return(fresh_response) + + manager.send(:renew_lease, 'rabbitmq', manager.active_leases['rabbitmq']) + + expect(manager.active_leases['rabbitmq'][:lease_id]).to eq('rabbitmq/creds/legion-role/fresh789') + end + end + + describe '#renew_approaching_leases with non-renewable pathless lease' do + let(:non_renewable_response) do + double('Vault::Secret', + data: { username: 'user', password: 'pass' }, + lease_id: 'some/lease/abc', + lease_duration: 100, + renewable?: false) + end + + it 'does not raise for a non-renewable lease without a path' do + allow(Vault).to receive_message_chain(:logical, :read).and_return(non_renewable_response) + manager.start({ 'legacy' => { 'path' => 'some/creds/role' } }) + # Remove the path to simulate the old bug + manager.active_leases['legacy'].delete(:path) + manager.active_leases['legacy'][:expires_at] = Time.now + 10 + + expect { manager.send(:renew_approaching_leases) }.not_to raise_error + end + end + + describe '#trigger_reconnect' do + context 'when name is :rabbitmq' do + it 'calls Transport::Connection.force_reconnect' do + transport_conn = double('Legion::Transport::Connection') + stub_const('Legion::Transport::Connection', transport_conn) + expect(transport_conn).to receive(:force_reconnect) + manager.send(:trigger_reconnect, :rabbitmq) + end + end + + context 'when name is :postgresql' do + context 'when reconnect_with_fresh_creds is available' do + it 'calls reconnect_with_fresh_creds' do + data_mod = Module.new + connection_mod = double('Legion::Data::Connection') + stub_const('Legion::Data', data_mod) + stub_const('Legion::Data::Connection', connection_mod) + allow(connection_mod).to receive(:respond_to?).with(:reconnect_with_fresh_creds).and_return(true) + expect(connection_mod).to receive(:reconnect_with_fresh_creds).and_return(true) + manager.send(:trigger_reconnect, :postgresql) + end + + it 'logs error when reconnect_with_fresh_creds returns false' do + data_mod = Module.new + connection_mod = double('Legion::Data::Connection') + stub_const('Legion::Data', data_mod) + stub_const('Legion::Data::Connection', connection_mod) + allow(connection_mod).to receive(:respond_to?).with(:reconnect_with_fresh_creds).and_return(true) + allow(connection_mod).to receive(:reconnect_with_fresh_creds).and_return(false) + expect { manager.send(:trigger_reconnect, :postgresql) }.not_to raise_error + end + end + + context 'when reconnect_with_fresh_creds is not available (legacy)' do + it 'falls back to disconnect + test_connection' do + data_mod = Module.new + sequel_db = double('Sequel::Database') + connection_mod = double('Legion::Data::Connection') + stub_const('Legion::Data', data_mod) + stub_const('Legion::Data::Connection', connection_mod) + allow(connection_mod).to receive(:respond_to?).with(:reconnect_with_fresh_creds).and_return(false) + allow(connection_mod).to receive(:respond_to?).with(:sequel).and_return(true) + allow(connection_mod).to receive(:sequel).and_return(sequel_db) + expect(sequel_db).to receive(:disconnect) + expect(sequel_db).to receive(:test_connection) + manager.send(:trigger_reconnect, :postgresql) + end + end + + it 'logs when Legion::Data::Connection is not defined' do + expect(manager.log).to receive(:debug).with("LeaseManager: no Legion::Data::Connection loaded for 'postgresql' reconnect") + + expect { manager.send(:trigger_reconnect, :postgresql) }.not_to raise_error + end + end + + context 'when name is :redis' do + it 'calls Cache.restart when available' do + cache_mod = double('Legion::Cache') + stub_const('Legion::Cache', cache_mod) + allow(cache_mod).to receive(:respond_to?).with(:restart).and_return(true) + expect(cache_mod).to receive(:restart) + manager.send(:trigger_reconnect, :redis) + end + end + + it 'handles reconnect errors gracefully' do + transport_conn = double('Legion::Transport::Connection') + stub_const('Legion::Transport::Connection', transport_conn) + allow(transport_conn).to receive(:force_reconnect).and_raise(StandardError, 'connection refused') + expect { manager.send(:trigger_reconnect, :rabbitmq) }.not_to raise_error + end + end + + describe '#reissue_lease' do + let(:original_response) do + double('Vault::Secret', + data: { username: 'orig_user', password: 'orig_pass' }, + lease_id: 'rabbitmq/creds/legionio-infra/orig111', + lease_duration: 604_800, + renewable?: true) + end + + let(:reissued_response) do + double('Vault::Secret', + data: { username: 'new_user', password: 'new_pass' }, + lease_id: 'rabbitmq/creds/legionio-infra/new222', + lease_duration: 604_800, + renewable?: true) + end + + before do + allow(Vault).to receive_message_chain(:logical, :read).and_return(reissued_response) + manager.register_dynamic_lease( + name: :rabbitmq, + path: 'rabbitmq/creds/legionio-infra', + response: original_response, + settings_refs: [] + ) + end + + it 'reads from the stored path' do + logical_double = double('Vault::Logical') + allow(Vault).to receive(:logical).and_return(logical_double) + expect(logical_double).to receive(:read).with('rabbitmq/creds/legionio-infra').and_return(reissued_response) + manager.reissue_lease(:rabbitmq) + end + + it 'updates the lease cache with new credentials' do + manager.reissue_lease(:rabbitmq) + expect(manager.fetch(:rabbitmq, :username)).to eq('new_user') + end + + it 'updates the lease_id in active_leases' do + manager.reissue_lease(:rabbitmq) + expect(manager.active_leases[:rabbitmq][:lease_id]).to eq('rabbitmq/creds/legionio-infra/new222') + end + + it 'updates the expires_at in active_leases' do + before_call = Time.now + manager.reissue_lease(:rabbitmq) + expect(manager.active_leases[:rabbitmq][:expires_at]).to be >= before_call + end + + it 'updates lease_duration in active_leases from the reissued response' do + manager.reissue_lease(:rabbitmq) + expect(manager.active_leases[:rabbitmq][:lease_duration]).to eq(604_800) + end + + it 'updates renewable in active_leases from the reissued response' do + manager.reissue_lease(:rabbitmq) + expect(manager.active_leases[:rabbitmq][:renewable]).to be(true) + end + + it 'does not raise when the active_leases entry is removed before the synchronize block runs' do + # Simulate the entry being removed (e.g. during shutdown) between the initial read and the merge + allow(Vault).to receive_message_chain(:logical, :read).and_return(reissued_response) do + manager.instance_variable_get(:@active_leases).delete(:rabbitmq) + reissued_response + end + expect { manager.reissue_lease(:rabbitmq) }.not_to raise_error + end + + it 'updates fetched_at in active_leases' do + before_call = Time.now + manager.reissue_lease(:rabbitmq) + expect(manager.active_leases[:rabbitmq][:fetched_at]).to be >= before_call + end + + it 'returns early for an unknown lease name' do + expect { manager.reissue_lease(:unknown_lease) }.not_to raise_error + end + + it 'returns early for a lease without a path' do + manager.active_leases[:rabbitmq].delete(:path) + expect(Vault.logical).not_to receive(:read) + manager.reissue_lease(:rabbitmq) + end + + it 'returns early when Vault returns nil' do + allow(Vault).to receive_message_chain(:logical, :read).and_return(nil) + expect { manager.reissue_lease(:rabbitmq) }.not_to raise_error + end + + it 'handles StandardError gracefully without raising' do + allow(Vault).to receive_message_chain(:logical, :read).and_raise(StandardError, 'network error') + expect { manager.reissue_lease(:rabbitmq) }.not_to raise_error + end + + context 'when name is :rabbitmq and Transport::Connection is defined' do + let(:transport_conn_double) { double('Legion::Transport::Connection') } + + before do + stub_const('Legion::Transport::Connection', transport_conn_double) + allow(transport_conn_double).to receive(:force_reconnect) + end + + it 'calls force_reconnect on Transport::Connection' do + expect(transport_conn_double).to receive(:force_reconnect) + manager.reissue_lease(:rabbitmq) + end + end + + context 'when name is not :rabbitmq' do + let(:kv_response) do + double('Vault::Secret', + data: { token: 'new_token' }, + lease_id: 'kv/some/path/token333', + lease_duration: 3600, + renewable?: true) + end + + before do + allow(Vault).to receive_message_chain(:logical, :read).and_return(kv_response) + manager.register_dynamic_lease( + name: :kv_token, + path: 'kv/some/path', + response: double('Vault::Secret', + data: { token: 'old_token' }, + lease_id: 'kv/some/path/old111', + lease_duration: 3600, + renewable?: true), + settings_refs: [] + ) + end + + it 'does not call force_reconnect for non-rabbitmq leases' do + transport_conn = double('Legion::Transport::Connection') + stub_const('Legion::Transport::Connection', transport_conn) + expect(transport_conn).not_to receive(:force_reconnect) + manager.reissue_lease(:kv_token) + end + end + end +end diff --git a/spec/legion/mock_vault_spec.rb b/spec/legion/mock_vault_spec.rb new file mode 100644 index 0000000..b63202d --- /dev/null +++ b/spec/legion/mock_vault_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/mock_vault' + +RSpec.describe Legion::Crypt::MockVault do + before { described_class.reset! } + + describe 'read/write/delete' do + it 'roundtrips data' do + described_class.write('secret/test', { key: 'value' }) + expect(described_class.read('secret/test')).to eq({ key: 'value' }) + end + + it 'returns nil for missing path' do + expect(described_class.read('nonexistent')).to be_nil + end + + it 'deletes path' do + described_class.write('secret/del', { a: 1 }) + described_class.delete('secret/del') + expect(described_class.read('secret/del')).to be_nil + end + + it 'returns independent copies' do + original = { key: 'value' } + described_class.write('secret/copy', original) + result = described_class.read('secret/copy') + result[:key] = 'modified' + expect(described_class.read('secret/copy')).to eq({ key: 'value' }) + end + end + + describe '.list' do + it 'returns paths matching prefix' do + described_class.write('secret/a/1', {}) + described_class.write('secret/a/2', {}) + described_class.write('secret/b/1', {}) + expect(described_class.list('secret/a/')).to contain_exactly('secret/a/1', 'secret/a/2') + end + end + + describe '.connected?' do + it 'returns true' do + expect(described_class.connected?).to be true + end + end +end diff --git a/spec/legion/settings_spec.rb b/spec/legion/settings_spec.rb index c0fec75..bf09913 100644 --- a/spec/legion/settings_spec.rb +++ b/spec/legion/settings_spec.rb @@ -1,13 +1,128 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/crypt/settings' RSpec.describe Legion::Crypt::Settings do - it 'has default settings' do - expect(Legion::Crypt::Settings.default[:vault]).to be_a Hash - expect(Legion::Crypt::Settings.default[:cs_encrypt_ready]).to eq false - expect(Legion::Crypt::Settings.default[:dynamic_keys]).to eq true - expect(Legion::Crypt::Settings.default[:vault][:protocol]).to eq 'http' - expect(Legion::Crypt::Settings.default[:vault][:address]).to eq 'localhost' - expect(Legion::Crypt::Settings.vault).to be_a Hash + describe '.default' do + subject(:defaults) { described_class.default } + + it 'returns a hash' do + expect(defaults).to be_a(Hash) + end + + it 'has vault settings as a hash' do + expect(defaults[:vault]).to be_a(Hash) + end + + it 'has cs_encrypt_ready set to false' do + expect(defaults[:cs_encrypt_ready]).to eq(false) + end + + it 'has dynamic_keys set to true' do + expect(defaults[:dynamic_keys]).to eq(true) + end + + it 'has cluster_secret as nil' do + expect(defaults[:cluster_secret]).to be_nil + end + + it 'has save_private_key' do + expect(defaults).to have_key(:save_private_key) + end + + it 'has read_private_key' do + expect(defaults).to have_key(:read_private_key) + end + end + + describe '.vault' do + subject(:vault) { described_class.vault } + + it 'returns a hash' do + expect(vault).to be_a(Hash) + end + + it 'defaults protocol to http' do + expect(vault[:protocol]).to eq('http') + end + + it 'defaults address to localhost' do + expect(vault[:address]).to eq('localhost') + end + + it 'defaults port to 8200' do + expect(vault[:port]).to eq(8200) + end + + it 'defaults connected to false' do + expect(vault[:connected]).to eq(false) + end + + it 'has renewer_time of 5' do + expect(vault[:renewer_time]).to eq(5) + end + + it 'has renewer enabled' do + expect(vault[:renewer]).to eq(true) + end + + it 'has push_cluster_secret disabled' do + expect(vault[:push_cluster_secret]).to eq(false) + end + + it 'has read_cluster_secret disabled' do + expect(vault[:read_cluster_secret]).to eq(false) + end + + it 'has kv_path' do + expect(vault[:kv_path]).to be_a(String) + end + + it 'reads VAULT_DEV_ROOT_TOKEN_ID from env' do + original = ENV.fetch('VAULT_DEV_ROOT_TOKEN_ID', nil) + ENV['VAULT_DEV_ROOT_TOKEN_ID'] = 'dev-test-token' + expect(described_class.vault[:token]).to eq('dev-test-token') + ENV['VAULT_DEV_ROOT_TOKEN_ID'] = original + end + + it 'falls back to VAULT_TOKEN_ID env' do + original_dev = ENV.delete('VAULT_DEV_ROOT_TOKEN_ID') + original = ENV.fetch('VAULT_TOKEN_ID', nil) + ENV['VAULT_TOKEN_ID'] = 'fallback-token' + expect(described_class.vault[:token]).to eq('fallback-token') + ENV['VAULT_TOKEN_ID'] = original + ENV['VAULT_DEV_ROOT_TOKEN_ID'] = original_dev if original_dev + end + + it 'reads LEGION_VAULT_KV_PATH from env' do + original = ENV.fetch('LEGION_VAULT_KV_PATH', nil) + ENV['LEGION_VAULT_KV_PATH'] = 'custom/path' + expect(described_class.vault[:kv_path]).to eq('custom/path') + ENV['LEGION_VAULT_KV_PATH'] = original + end + + it 'has leases as an empty hash' do + expect(vault[:leases]).to eq({}) + end + + it 'has default as nil' do + expect(vault[:default]).to be_nil + end + + it 'has clusters as an empty hash' do + expect(vault[:clusters]).to eq({}) + end + + it 'includes kerberos defaults' do + expect(vault[:kerberos]).to eq( + service_principal: nil, + auth_path: 'auth/kerberos/login' + ) + end + + it 'defaults vault_namespace to legionio' do + expect(vault[:vault_namespace]).to eq('legionio') + end end end diff --git a/spec/legion/token_renewer_spec.rb b/spec/legion/token_renewer_spec.rb new file mode 100644 index 0000000..6b173ac --- /dev/null +++ b/spec/legion/token_renewer_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/token_renewer' + +RSpec.describe Legion::Crypt::TokenRenewer do + let(:cluster_name) { :primary } + let(:config) do + { + token: 'hvs.initial-token', lease_duration: 100, renewable: true, + connected: true, auth_method: 'kerberos', address: 'vault.example.com', + kerberos: { service_principal: 'HTTP/vault.example.com', auth_path: 'auth/kerberos/login' } + } + end + let(:vault_client) { instance_double(Vault::Client) } + let(:auth_token) { double('AuthToken') } + let(:renew_result) do + double('RenewResult', auth: double('Auth', lease_duration: 200, client_token: 'hvs.renewed', renewable?: false)) + end + + let(:renewer) { described_class.new(cluster_name: cluster_name, config: config, vault_client: vault_client) } + + before do + allow(vault_client).to receive(:auth_token).and_return(auth_token) + end + + describe '#initialize' do + it 'stores the cluster name' do + expect(renewer.cluster_name).to eq(:primary) + end + + it 'is not running after initialization' do + expect(renewer.running?).to be false + end + end + + describe '#renew_token' do + it 'calls renew_self on the vault client and updates lease_duration' do + allow(auth_token).to receive(:renew_self).and_return(renew_result) + result = renewer.renew_token + expect(result).to be true + expect(config[:lease_duration]).to eq(200) + expect(config[:renewable]).to be(false) + end + + it 'returns false when renewal fails' do + allow(auth_token).to receive(:renew_self).and_raise(StandardError, 'token expired') + result = renewer.renew_token + expect(result).to be false + end + end + + describe '#reauth_kerberos' do + it 'obtains a fresh token via KerberosAuth.login' do + allow(Legion::Crypt::KerberosAuth).to receive(:login).and_return( + { token: 'hvs.reauth-token', lease_duration: 300, renewable: true, policies: [], metadata: {} } + ) + allow(vault_client).to receive(:token=) + result = renewer.reauth_kerberos + expect(result).to be true + expect(config[:token]).to eq('hvs.reauth-token') + expect(config[:lease_duration]).to eq(300) + expect(config[:connected]).to be true + end + + it 'returns false when re-auth fails' do + allow(Legion::Crypt::KerberosAuth).to receive(:login) + .and_raise(Legion::Crypt::KerberosAuth::AuthError, 'no TGT') + result = renewer.reauth_kerberos + expect(result).to be false + end + end + + describe '#sleep_duration' do + it 'returns 75% of the lease_duration' do + expect(renewer.sleep_duration).to eq(75) + end + + it 'uses the ratio for short-lived tokens so renewal happens before expiry' do + config[:lease_duration] = 10 + expect(renewer.sleep_duration).to eq(7) + end + end + + describe '#start and #stop' do + it 'starts and stops the renewal thread' do + allow(auth_token).to receive(:renew_self).and_return(renew_result) + allow(vault_client).to receive(:token).and_return('hvs.initial-token') + allow(auth_token).to receive(:revoke_self) + renewer.start + expect(renewer.running?).to be true + renewer.stop + expect(renewer.running?).to be false + end + + it 'does not start a second renewal thread when already running' do + allow(auth_token).to receive(:renew_self).and_return(renew_result) + allow(vault_client).to receive(:token).and_return('hvs.initial-token') + allow(auth_token).to receive(:revoke_self) + + renewer.start + first_thread = renewer.instance_variable_get(:@thread) + renewer.start + + expect(renewer.instance_variable_get(:@thread)).to eq(first_thread) + renewer.stop + end + end + + describe '#revoke_token (private)' do + context 'when auth_method is kerberos' do + it 'calls revoke_self on auth_token' do + allow(vault_client).to receive(:token).and_return('hvs.initial-token') + expect(auth_token).to receive(:revoke_self) + renewer.send(:revoke_token) + end + end + + context 'when auth_method is not kerberos' do + let(:config) do + { + token: 'hvs.env-token', lease_duration: 100, renewable: true, + connected: true, auth_method: 'token', address: 'vault.example.com' + } + end + + it 'does not revoke the token' do + allow(vault_client).to receive(:token).and_return('hvs.env-token') + expect(auth_token).not_to receive(:revoke_self) + renewer.send(:revoke_token) + end + end + + context 'when auth_method is nil' do + let(:config) do + { + token: 'hvs.env-token', lease_duration: 100, renewable: true, + connected: true, address: 'vault.example.com' + } + end + + it 'does not revoke the token' do + allow(vault_client).to receive(:token).and_return('hvs.env-token') + expect(auth_token).not_to receive(:revoke_self) + renewer.send(:revoke_token) + end + end + + context 'when vault_client has no token' do + it 'does not attempt revocation' do + allow(vault_client).to receive(:token).and_return(nil) + expect(auth_token).not_to receive(:revoke_self) + renewer.send(:revoke_token) + end + end + + context 'when revoke_self raises' do + it 'does not propagate the error' do + allow(vault_client).to receive(:token).and_return('hvs.initial-token') + allow(auth_token).to receive(:revoke_self).and_raise(StandardError, 'network error') + expect { renewer.send(:revoke_token) }.not_to raise_error + end + end + end + + describe '#next_backoff' do + it 'doubles up to the cap' do + expect(renewer.next_backoff).to eq(30) + expect(renewer.next_backoff).to eq(60) + expect(renewer.next_backoff).to eq(120) + expect(renewer.next_backoff).to eq(240) + expect(renewer.next_backoff).to eq(480) + expect(renewer.next_backoff).to eq(600) + expect(renewer.next_backoff).to eq(600) + end + end + + describe '#reset_backoff' do + it 'resets the backoff to initial value' do + renewer.next_backoff + renewer.next_backoff + renewer.reset_backoff + expect(renewer.next_backoff).to eq(30) + end + end + + describe '#stop_thread_and_revoke' do + it 'keeps the thread reference when the join times out' do + stuck_thread = instance_double(Thread, alive?: true) + allow(stuck_thread).to receive(:join) + renewer.instance_variable_set(:@thread, stuck_thread) + + renewer.send(:stop_thread_and_revoke) + + expect(renewer.instance_variable_get(:@thread)).to eq(stuck_thread) + end + end +end diff --git a/spec/legion/vault_cluster_spec.rb b/spec/legion/vault_cluster_spec.rb new file mode 100644 index 0000000..af9464b --- /dev/null +++ b/spec/legion/vault_cluster_spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/vault_cluster' +require 'legion/crypt/kerberos_auth' + +RSpec.describe Legion::Crypt::VaultCluster do + let(:cluster_alpha) do + { protocol: 'https', address: 'vault-alpha.example.com', port: 8200, token: 'token-alpha', connected: true } + end + let(:cluster_beta) do + { protocol: 'https', address: 'vault-beta.example.com', port: 8200, token: 'token-beta', connected: false } + end + let(:cluster_gamma) do + { protocol: 'http', address: 'vault-gamma.example.com', port: 8200, token: nil, connected: false } + end + + let(:test_clusters) { { alpha: cluster_alpha, beta: cluster_beta, gamma: cluster_gamma } } + + let(:test_object) do + obj = Object.new + obj.extend(described_class) + vault_settings_hash = { default: :alpha, clusters: test_clusters } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + describe '#default_cluster_name' do + it 'returns the configured default cluster name as a symbol' do + expect(test_object.default_cluster_name).to eq(:alpha) + end + + context 'when default is nil' do + let(:test_object) do + obj = Object.new + obj.extend(described_class) + vault_settings_hash = { default: nil, clusters: test_clusters } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + it 'returns the first cluster key' do + expect(test_object.default_cluster_name).to eq(:alpha) + end + end + end + + describe '#clusters' do + it 'returns all clusters' do + expect(test_object.clusters).to eq(test_clusters) + end + + context 'when clusters key is missing from vault_settings' do + let(:test_object) do + obj = Object.new + obj.extend(described_class) + obj.define_singleton_method(:vault_settings) { { default: nil } } + obj + end + + it 'returns an empty hash' do + expect(test_object.clusters).to eq({}) + end + end + end + + describe '#cluster' do + it 'returns the default cluster config when no name is given' do + expect(test_object.cluster).to eq(cluster_alpha) + end + + it 'returns the named cluster config' do + expect(test_object.cluster(:beta)).to eq(cluster_beta) + end + + it 'returns nil for an unknown cluster name' do + expect(test_object.cluster(:unknown)).to be_nil + end + end + + describe '#connected_clusters' do + it 'returns only clusters with a token AND connected=true' do + result = test_object.connected_clusters + expect(result.keys).to eq([:alpha]) + end + + it 'excludes clusters without a token' do + expect(test_object.connected_clusters).not_to have_key(:gamma) + end + + it 'excludes clusters that have a token but are not connected' do + expect(test_object.connected_clusters).not_to have_key(:beta) + end + end + + describe '#vault_client' do + let(:mock_client) { instance_double(Vault::Client) } + + before do + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + end + + it 'returns a Vault::Client for the default cluster' do + expect(Vault::Client).to receive(:new).with( + address: 'https://vault-alpha.example.com:8200', + token: 'token-alpha', + ssl_verify: true + ).and_return(mock_client) + expect(test_object.vault_client).to eq(mock_client) + end + + it 'returns a Vault::Client for a named cluster' do + expect(Vault::Client).to receive(:new).with( + address: 'https://vault-beta.example.com:8200', + token: 'token-beta', + ssl_verify: true + ).and_return(mock_client) + expect(test_object.vault_client(:beta)).to eq(mock_client) + end + + it 'memoizes the client per cluster name' do + expect(Vault::Client).to receive(:new).once.and_return(mock_client) + test_object.vault_client(:alpha) + test_object.vault_client(:alpha) + end + + it 'creates separate clients for different cluster names' do + mock_beta = instance_double(Vault::Client) + allow(mock_beta).to receive(:namespace=) + allow(Vault::Client).to receive(:new).and_return(mock_client, mock_beta) + + client_alpha = test_object.vault_client(:alpha) + client_beta = test_object.vault_client(:beta) + expect(client_alpha).not_to eq(client_beta) + end + + it 'returns nil when the cluster config is not a hash' do + expect(test_object.vault_client(:unknown)).to be_nil + end + + context 'when cluster config includes a namespace' do + let(:test_clusters_with_ns) do + { alpha: cluster_alpha.merge(namespace: 'admin') } + end + + let(:test_object) do + obj = Object.new + obj.extend(described_class) + vault_settings_hash = { default: :alpha, clusters: test_clusters_with_ns } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + it 'sets the namespace on the client' do + expect(mock_client).to receive(:namespace=).with('admin') + test_object.vault_client(:alpha) + end + end + + context 'when cluster config has no namespace but Settings has vault_namespace' do + let(:test_object) do + obj = Object.new + obj.extend(described_class) + vault_settings_hash = { default: :alpha, clusters: { alpha: cluster_alpha } } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + before do + stub_const('Legion::Settings', Module.new do + def self.[](key) + { vault: { vault_namespace: 'legionio' } } if key == :crypt + end + end) + end + + it 'falls back to vault_namespace from Settings' do + expect(mock_client).to receive(:namespace=).with('legionio') + test_object.vault_client(:alpha) + end + end + end + + describe '#connect_all_clusters' do + let(:mock_alpha_client) { instance_double(Vault::Client) } + let(:mock_sys) { instance_double(Vault::Sys) } + let(:mock_health) { double('health', initialized?: true) } + + before do + allow(Vault::Client).to receive(:new).and_return(mock_alpha_client) + allow(mock_alpha_client).to receive(:namespace=) + allow(mock_alpha_client).to receive(:sys).and_return(mock_sys) + allow(mock_sys).to receive(:health_status).and_return(mock_health) + end + + it 'skips clusters without a token' do + results = test_object.connect_all_clusters + expect(results).not_to have_key(:gamma) + end + + it 'sets connected=true for successfully connected clusters' do + results = test_object.connect_all_clusters + expect(results[:alpha]).to be(true) + end + + it 'sets connected=false on error' do + allow(mock_alpha_client).to receive(:sys).and_raise(StandardError, 'connection refused') + results = test_object.connect_all_clusters + expect(results[:alpha]).to be(false) + end + + context 'with auth_method: kerberos' do + let(:krb_cluster) do + { + protocol: 'https', address: 'vault.example.com', port: 8200, + auth_method: 'kerberos', connected: false, + kerberos: { service_principal: 'HTTP/vault.example.com', auth_path: 'auth/kerberos/login' } + } + end + + let(:test_clusters) { { krb: krb_cluster } } + + it 'authenticates via KerberosAuth and sets the token' do + allow(Legion::Crypt::KerberosAuth).to receive(:login).and_return( + { token: 'hvs.krb-token', lease_duration: 3600, renewable: true, policies: [], metadata: {} } + ) + allow(Vault::Client).to receive(:new).and_return(mock_alpha_client) + allow(mock_alpha_client).to receive(:namespace=) + allow(mock_alpha_client).to receive(:token=) + + results = test_object.connect_all_clusters + expect(results[:krb]).to be true + expect(krb_cluster[:token]).to eq('hvs.krb-token') + expect(krb_cluster[:connected]).to be true + end + + it 'sets the token on the cached vault_client after kerberos auth' do + allow(Legion::Crypt::KerberosAuth).to receive(:login).and_return( + { token: 'hvs.krb-token', lease_duration: 3600, renewable: true, policies: [], metadata: {} } + ) + allow(Vault::Client).to receive(:new).and_return(mock_alpha_client) + allow(mock_alpha_client).to receive(:namespace=) + expect(mock_alpha_client).to receive(:token=).with('hvs.krb-token') + + test_object.connect_all_clusters + end + + it 'handles KerberosAuth failure gracefully' do + allow(Legion::Crypt::KerberosAuth).to receive(:login) + .and_raise(Legion::Crypt::KerberosAuth::AuthError, 'no TGT') + allow(Vault::Client).to receive(:new).and_return(mock_alpha_client) + allow(mock_alpha_client).to receive(:namespace=) + + results = test_object.connect_all_clusters + expect(results[:krb]).to be false + expect(krb_cluster[:connected]).to be false + end + + it 'handles missing lex-kerberos gem gracefully' do + allow(Legion::Crypt::KerberosAuth).to receive(:login) + .and_raise(Legion::Crypt::KerberosAuth::GemMissingError, 'lex-kerberos required') + allow(Vault::Client).to receive(:new).and_return(mock_alpha_client) + allow(mock_alpha_client).to receive(:namespace=) + + results = test_object.connect_all_clusters + expect(results[:krb]).to be false + end + + context 'without service_principal configured' do + let(:krb_cluster) do + { + protocol: 'https', address: 'vault.example.com', port: 8200, + auth_method: 'kerberos', connected: false, + kerberos: { service_principal: nil, auth_path: 'auth/kerberos/login' } + } + end + + it 'skips the cluster' do + results = test_object.connect_all_clusters + expect(results[:krb]).to be false + end + end + end + + context 'with auth_method: ldap' do + let(:ldap_cluster) do + { protocol: 'https', address: 'vault.example.com', port: 8200, + auth_method: 'ldap', connected: false } + end + + let(:test_clusters) { { ldap: ldap_cluster } } + + it 'skips ldap clusters' do + results = test_object.connect_all_clusters + expect(results).not_to have_key(:ldap) + end + end + + context 'when a token-based cluster connects successfully' do + it 'sets Legion::Settings[:crypt][:vault][:connected] in multi-cluster mode' do + vault_hash = { connected: false } + crypt_hash = { vault: vault_hash } + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:crypt).and_return(crypt_hash) + + test_object.connect_all_clusters + expect(vault_hash[:connected]).to be(true) + end + end + end + + describe '#sync_vault_connected (via connect_all_clusters)' do + it 'updates the top-level vault connected flag in multi-cluster mode' do + vault_hash = { connected: false } + crypt_hash = { vault: vault_hash } + + mock_client = instance_double(Vault::Client) + mock_sys = instance_double(Vault::Sys) + mock_health = double('health', initialized?: true) + + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(mock_client).to receive(:sys).and_return(mock_sys) + allow(mock_sys).to receive(:health_status).and_return(mock_health) + allow(Legion::Settings).to receive(:[]).with(:crypt).and_return(crypt_hash) + + test_object.connect_all_clusters + expect(vault_hash[:connected]).to be(true) + end + + it 'does not raise when Legion::Settings is not defined' do + hide_const('Legion::Settings') + expect { test_object.connect_all_clusters }.not_to raise_error + end + end +end diff --git a/spec/legion/vault_entity_spec.rb b/spec/legion/vault_entity_spec.rb new file mode 100644 index 0000000..28db098 --- /dev/null +++ b/spec/legion/vault_entity_spec.rb @@ -0,0 +1,431 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/vault_entity' + +RSpec.describe Legion::Crypt::VaultEntity do + let(:vault_logical) { instance_double('Vault::Logical') } + let(:lease_manager) { instance_double('Legion::Crypt::LeaseManager') } + + let(:principal_id) { 'user@example.com' } + let(:canonical_name) { 'user-example-com' } + let(:entity_id) { 'ent-abc-123' } + let(:mount_accessor) { 'auth_jwt_abc123' } + let(:alias_name) { 'user@example.com' } + + let(:entity_response) do + double('Vault::Secret', data: { id: entity_id, name: "legion-#{canonical_name}" }) + end + + let(:entity_response_string_keys) do + double('Vault::Secret', data: { 'id' => entity_id, 'name' => "legion-#{canonical_name}" }) + end + + let(:alias_response) do + double('Vault::Secret', data: { id: 'alias-xyz-456' }) + end + + before do + stub_const('Vault::HTTPClientError', Class.new(StandardError)) + + allow(Legion::Crypt::LeaseManager).to receive(:instance).and_return(lease_manager) + allow(lease_manager).to receive(:vault_logical).and_return(vault_logical) + + allow(vault_logical).to receive(:read).and_return(nil) + allow(vault_logical).to receive(:write).and_return(nil) + end + + # --------------------------------------------------------------------------- + describe '.ensure_entity' do + context 'when no entity exists yet' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(nil) + + allow(vault_logical).to receive(:write) + .with('identity/entity', anything) + .and_return(entity_response) + end + + it 'creates an entity and returns its ID' do + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to eq(entity_id) + end + + it 'writes to identity/entity with the legion- prefix' do + expect(vault_logical).to receive(:write).with( + 'identity/entity', + hash_including(name: "legion-#{canonical_name}") + ).and_return(entity_response) + + described_class.ensure_entity(principal_id: principal_id, canonical_name: canonical_name) + end + + it 'includes standard managed_by metadata' do + expect(vault_logical).to receive(:write).with( + 'identity/entity', + hash_including( + metadata: hash_including( + managed_by: 'legion', + legion_principal_id: principal_id, + legion_canonical_name: canonical_name + ) + ) + ).and_return(entity_response) + + described_class.ensure_entity(principal_id: principal_id, canonical_name: canonical_name) + end + + it 'merges caller-supplied metadata with standard metadata' do + expect(vault_logical).to receive(:write).with( + 'identity/entity', + hash_including( + metadata: hash_including( + custom_key: 'custom_value', + legion_principal_id: principal_id, + managed_by: 'legion' + ) + ) + ).and_return(entity_response) + + described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name, + metadata: { custom_key: 'custom_value' } + ) + end + end + + context 'when entity already exists' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(entity_response) + end + + it 'returns the existing entity ID without creating a new one' do + expect(vault_logical).not_to receive(:write).with('identity/entity', anything) + + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to eq(entity_id) + end + end + + context 'when Vault raises HTTPClientError on write' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(nil) + + allow(vault_logical).to receive(:write) + .with('identity/entity', anything) + .and_raise(Vault::HTTPClientError, 'permission denied') + end + + it 'returns nil instead of raising' do + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to be_nil + end + end + + context 'when Vault raises an unexpected StandardError' do + before do + allow(vault_logical).to receive(:read).and_return(nil) + allow(vault_logical).to receive(:write) + .with('identity/entity', anything) + .and_raise(StandardError, 'network timeout') + end + + it 'returns nil instead of raising' do + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to be_nil + end + end + + context 'when write response returns string-keyed data' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(nil) + + allow(vault_logical).to receive(:write) + .with('identity/entity', anything) + .and_return(entity_response_string_keys) + end + + it 'returns the entity ID from string-keyed response' do + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to eq(entity_id) + end + end + + context 'when write response has no data' do + before do + allow(vault_logical).to receive(:read).and_return(nil) + allow(vault_logical).to receive(:write) + .with('identity/entity', anything) + .and_return(double('Vault::Secret', data: nil)) + end + + it 'returns nil' do + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to be_nil + end + end + + context 'when write returns nil response' do + before do + allow(vault_logical).to receive(:read).and_return(nil) + allow(vault_logical).to receive(:write) + .with('identity/entity', anything) + .and_return(nil) + end + + it 'returns nil' do + result = described_class.ensure_entity( + principal_id: principal_id, + canonical_name: canonical_name + ) + expect(result).to be_nil + end + end + end + + # --------------------------------------------------------------------------- + describe '.ensure_alias' do + context 'when alias does not exist' do + before do + allow(vault_logical).to receive(:write) + .with('identity/entity-alias', anything) + .and_return(alias_response) + end + + it 'writes to identity/entity-alias and returns the response' do + result = described_class.ensure_alias( + entity_id: entity_id, + mount_accessor: mount_accessor, + alias_name: alias_name + ) + expect(result).to eq(alias_response) + end + + it 'passes the correct params to Vault' do + expect(vault_logical).to receive(:write).with( + 'identity/entity-alias', + name: alias_name, + canonical_id: entity_id, + mount_accessor: mount_accessor + ).and_return(alias_response) + + described_class.ensure_alias( + entity_id: entity_id, + mount_accessor: mount_accessor, + alias_name: alias_name + ) + end + end + + context 'when alias already exists (idempotent)' do + before do + err = Vault::HTTPClientError.new('alias already exists for the combination of mount and alias name') + allow(vault_logical).to receive(:write) + .with('identity/entity-alias', anything) + .and_raise(err) + end + + it 'returns nil without raising' do + result = described_class.ensure_alias( + entity_id: entity_id, + mount_accessor: mount_accessor, + alias_name: alias_name + ) + expect(result).to be_nil + end + end + + context 'when Vault raises a different HTTPClientError' do + before do + err = Vault::HTTPClientError.new('permission denied') + allow(vault_logical).to receive(:write) + .with('identity/entity-alias', anything) + .and_raise(err) + end + + it 'returns nil without raising (non-fatal)' do + result = described_class.ensure_alias( + entity_id: entity_id, + mount_accessor: mount_accessor, + alias_name: alias_name + ) + expect(result).to be_nil + end + end + + context 'when Vault raises an unexpected StandardError' do + before do + allow(vault_logical).to receive(:write) + .with('identity/entity-alias', anything) + .and_raise(StandardError, 'connection reset') + end + + it 'returns nil without raising' do + result = described_class.ensure_alias( + entity_id: entity_id, + mount_accessor: mount_accessor, + alias_name: alias_name + ) + expect(result).to be_nil + end + end + end + + # --------------------------------------------------------------------------- + describe '.find_by_name' do + context 'when entity exists' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(entity_response) + end + + it 'returns the entity ID' do + result = described_class.find_by_name(canonical_name) + expect(result).to eq(entity_id) + end + + it 'reads the legion- prefixed path' do + expect(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(entity_response) + + described_class.find_by_name(canonical_name) + end + end + + context 'when entity does not exist (nil response)' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(nil) + end + + it 'returns nil' do + result = described_class.find_by_name(canonical_name) + expect(result).to be_nil + end + end + + context 'when Vault raises HTTPClientError (404)' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_raise(Vault::HTTPClientError, 'entity not found') + end + + it 'returns nil without raising' do + result = described_class.find_by_name(canonical_name) + expect(result).to be_nil + end + end + + context 'when Vault raises an unexpected StandardError' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_raise(StandardError, 'timeout') + end + + it 'returns nil without raising' do + result = described_class.find_by_name(canonical_name) + expect(result).to be_nil + end + end + + context 'when response data has no id field' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(double('Vault::Secret', data: { name: 'legion-foo' })) + end + + it 'returns nil' do + result = described_class.find_by_name(canonical_name) + expect(result).to be_nil + end + end + + context 'when response returns string-keyed data' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_return(entity_response_string_keys) + end + + it 'returns the entity ID from string-keyed response' do + result = described_class.find_by_name(canonical_name) + expect(result).to eq(entity_id) + end + end + + context 'when Vault raises a non-404 HTTPClientError' do + before do + allow(vault_logical).to receive(:read) + .with("identity/entity/name/legion-#{canonical_name}") + .and_raise(Vault::HTTPClientError, 'permission denied') + end + + it 'returns nil without raising' do + result = described_class.find_by_name(canonical_name) + expect(result).to be_nil + end + end + end + + # --------------------------------------------------------------------------- + describe 'vault_logical resolution' do + context 'when LeaseManager is defined' do + it 'delegates to LeaseManager.instance.vault_logical' do + expect(lease_manager).to receive(:vault_logical).and_return(vault_logical) + allow(vault_logical).to receive(:read).and_return(nil) + + described_class.find_by_name(canonical_name) + end + end + + context 'when LeaseManager is not defined' do + before do + hide_const('Legion::Crypt::LeaseManager') + + stub_const('Vault', Module.new) + stub_const('Vault::HTTPClientError', Class.new(StandardError)) + allow(Vault).to receive(:logical).and_return(vault_logical) + allow(vault_logical).to receive(:read).and_return(nil) + end + + it 'falls back to ::Vault.logical' do + expect(Vault).to receive(:logical).and_return(vault_logical) + + described_class.find_by_name(canonical_name) + end + end + end +end diff --git a/spec/legion/vault_jwt_auth_spec.rb b/spec/legion/vault_jwt_auth_spec.rb new file mode 100644 index 0000000..eac1135 --- /dev/null +++ b/spec/legion/vault_jwt_auth_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/vault_jwt_auth' + +RSpec.describe Legion::Crypt::VaultJwtAuth do + let(:sample_jwt) { 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ3b3JrZXIifQ.sig' } + let(:vault_token) { 'hvs.CAESIJ-sample-vault-token' } + let(:auth_response) do + auth = double( + 'VaultAuth', + client_token: vault_token, + lease_duration: 3600, + renewable?: true, + policies: %w[default legion-worker], + metadata: { 'worker_id' => 'abc-123' } + ) + double('VaultResponse', auth: auth) + end + + let(:vault_logical) { double('VaultLogical') } + + before do + @original_connected = Legion::Settings[:crypt][:vault][:connected] + Legion::Settings[:crypt][:vault][:connected] = true + + stub_const('Vault', Module.new) + stub_const('Vault::HTTPClientError', Class.new(StandardError)) + stub_const('Vault::HTTPServerError', Class.new(StandardError)) + allow(Vault).to receive(:logical).and_return(vault_logical) + allow(vault_logical).to receive(:write).and_return(auth_response) + end + + after do + Legion::Settings[:crypt][:vault][:connected] = @original_connected + end + + describe '.login' do + context 'when Vault is connected and auth succeeds' do + it 'returns a hash with token and metadata' do + result = described_class.login(jwt: sample_jwt) + + expect(result).to be_a(Hash) + expect(result[:token]).to eq(vault_token) + expect(result[:lease_duration]).to eq(3600) + expect(result[:renewable]).to eq(true) + expect(result[:policies]).to eq(%w[default legion-worker]) + expect(result[:metadata]).to eq({ 'worker_id' => 'abc-123' }) + end + + it 'calls Vault.logical.write with the correct auth_path, role, and jwt' do + expect(vault_logical).to receive(:write).with( + 'auth/jwt/login', + role: 'legion-worker', + jwt: sample_jwt + ).and_return(auth_response) + + described_class.login(jwt: sample_jwt) + end + + it 'uses DEFAULT_AUTH_PATH by default' do + expect(vault_logical).to receive(:write).with( + Legion::Crypt::VaultJwtAuth::DEFAULT_AUTH_PATH, + hash_including(jwt: sample_jwt) + ).and_return(auth_response) + + described_class.login(jwt: sample_jwt) + end + + it 'uses DEFAULT_ROLE by default' do + expect(vault_logical).to receive(:write).with( + anything, + hash_including(role: Legion::Crypt::VaultJwtAuth::DEFAULT_ROLE) + ).and_return(auth_response) + + described_class.login(jwt: sample_jwt) + end + + it 'accepts a custom role' do + expect(vault_logical).to receive(:write).with( + anything, + hash_including(role: 'custom-role') + ).and_return(auth_response) + + described_class.login(jwt: sample_jwt, role: 'custom-role') + end + + it 'accepts a custom auth_path' do + expect(vault_logical).to receive(:write).with( + 'auth/oidc/login', + anything + ).and_return(auth_response) + + described_class.login(jwt: sample_jwt, auth_path: 'auth/oidc/login') + end + end + + context 'when Vault is not connected' do + before do + Legion::Settings[:crypt][:vault][:connected] = false + end + + it 'raises AuthError' do + expect do + described_class.login(jwt: sample_jwt) + end.to raise_error(Legion::Crypt::VaultJwtAuth::AuthError, 'Vault is not connected') + end + end + + context 'when Vault returns a response with no auth data' do + it 'raises AuthError' do + allow(vault_logical).to receive(:write).and_return(double('VaultResponse', auth: nil)) + + expect do + described_class.login(jwt: sample_jwt) + end.to raise_error(Legion::Crypt::VaultJwtAuth::AuthError, 'Vault JWT auth returned no auth data') + end + end + + context 'when Vault returns nil response' do + it 'raises AuthError' do + allow(vault_logical).to receive(:write).and_return(nil) + + expect do + described_class.login(jwt: sample_jwt) + end.to raise_error(Legion::Crypt::VaultJwtAuth::AuthError, 'Vault JWT auth returned no auth data') + end + end + + context 'when Vault raises HTTPClientError (4xx)' do + it 'wraps it in AuthError' do + allow(vault_logical).to receive(:write).and_raise(Vault::HTTPClientError, 'permission denied') + + expect do + described_class.login(jwt: sample_jwt) + end.to raise_error(Legion::Crypt::VaultJwtAuth::AuthError, /Vault JWT auth failed: permission denied/) + end + end + + context 'when Vault raises HTTPServerError (5xx)' do + it 'wraps it in AuthError' do + allow(vault_logical).to receive(:write).and_raise(Vault::HTTPServerError, 'internal server error') + + expect do + described_class.login(jwt: sample_jwt) + end.to raise_error(Legion::Crypt::VaultJwtAuth::AuthError, /Vault server error during JWT auth: internal server error/) + end + end + end + + describe '.login!' do + before do + allow(Vault).to receive(:token=) + end + + it 'calls login and returns the same result' do + result = described_class.login!(jwt: sample_jwt) + + expect(result[:token]).to eq(vault_token) + expect(result[:policies]).to eq(%w[default legion-worker]) + end + + it 'sets the Vault client token' do + expect(Vault).to receive(:token=).with(vault_token) + + described_class.login!(jwt: sample_jwt) + end + + it 'calls log.info with the authenticated policies' do + allow(described_class.log).to receive(:info) + expect(described_class.log).to receive(:info).with(match(/authenticated via JWT auth.*default,legion-worker/)) + described_class.login!(jwt: sample_jwt) + end + + it 'propagates AuthError from login' do + Legion::Settings[:crypt][:vault][:connected] = false + + expect do + described_class.login!(jwt: sample_jwt) + end.to raise_error(Legion::Crypt::VaultJwtAuth::AuthError, 'Vault is not connected') + end + + it 'accepts custom role and auth_path' do + expect(vault_logical).to receive(:write).with( + 'auth/oidc/login', + hash_including(role: 'oidc-worker') + ).and_return(auth_response) + + described_class.login!(jwt: sample_jwt, role: 'oidc-worker', auth_path: 'auth/oidc/login') + end + end + + describe '.worker_login' do + let(:worker_id) { 'worker-abc-123' } + let(:owner_msid) { 'user@example.com' } + let(:cluster_key) { SecureRandom.hex(32) } + let(:worker_jwt) { 'worker.signed.jwt' } + + before do + allow(Legion::Crypt).to receive(:cluster_secret).and_return(cluster_key) + allow(Legion::Crypt::JWT).to receive(:issue).and_return(worker_jwt) + allow(vault_logical).to receive(:write).and_return(auth_response) + end + + it 'issues a JWT with worker payload' do + expect(Legion::Crypt::JWT).to receive(:issue).with( + { worker_id: worker_id, sub: owner_msid, scope: 'vault', aud: 'legion' }, + signing_key: cluster_key, + ttl: 300, + issuer: 'legion' + ).and_return(worker_jwt) + + described_class.worker_login(worker_id: worker_id, owner_msid: owner_msid) + end + + it 'passes the issued JWT to login' do + expect(vault_logical).to receive(:write).with( + 'auth/jwt/login', + hash_including(jwt: worker_jwt, role: 'legion-worker') + ).and_return(auth_response) + + described_class.worker_login(worker_id: worker_id, owner_msid: owner_msid) + end + + it 'returns the auth result hash' do + result = described_class.worker_login(worker_id: worker_id, owner_msid: owner_msid) + + expect(result[:token]).to eq(vault_token) + expect(result[:lease_duration]).to eq(3600) + end + + it 'accepts a custom role' do + expect(vault_logical).to receive(:write).with( + anything, + hash_including(role: 'special-worker') + ).and_return(auth_response) + + described_class.worker_login(worker_id: worker_id, owner_msid: owner_msid, role: 'special-worker') + end + end + + describe 'AuthError' do + it 'inherits from StandardError' do + expect(Legion::Crypt::VaultJwtAuth::AuthError.ancestors).to include(StandardError) + end + + it 'carries the error message' do + err = Legion::Crypt::VaultJwtAuth::AuthError.new('something went wrong') + expect(err.message).to eq('something went wrong') + end + end + + describe 'constants' do + it 'DEFAULT_AUTH_PATH is auth/jwt/login' do + expect(described_class::DEFAULT_AUTH_PATH).to eq('auth/jwt/login') + end + + it 'DEFAULT_ROLE is legion-worker' do + expect(described_class::DEFAULT_ROLE).to eq('legion-worker') + end + end +end diff --git a/spec/legion/vault_kerberos_auth_spec.rb b/spec/legion/vault_kerberos_auth_spec.rb new file mode 100644 index 0000000..b145db5 --- /dev/null +++ b/spec/legion/vault_kerberos_auth_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/vault_kerberos_auth' + +RSpec.describe Legion::Crypt::VaultKerberosAuth do + let(:spnego_token) { 'fake-spnego-base64' } + let(:vault_token) { 'hvs.test-token' } + let(:auth_double) do + double('VaultAuth', + client_token: vault_token, + lease_duration: 3600, + renewable?: true, + policies: %w[default legion-worker], + metadata: { 'username' => 'miverso2' }) + end + let(:response_double) { double('VaultResponse', auth: auth_double) } + let(:vault_logical) { double('VaultLogical') } + + before do + @original_connected = Legion::Settings[:crypt][:vault][:connected] + Legion::Settings[:crypt][:vault][:connected] = true + + stub_const('Vault', Module.new) + stub_const('Vault::HTTPClientError', Class.new(StandardError)) + allow(Vault).to receive(:logical).and_return(vault_logical) + allow(vault_logical).to receive(:write).and_return(response_double) + end + + after do + Legion::Settings[:crypt][:vault][:connected] = @original_connected + end + + describe '.login' do + context 'when Vault is connected' do + it 'exchanges a SPNEGO token for a Vault token' do + result = described_class.login(spnego_token: spnego_token) + expect(result[:token]).to eq(vault_token) + expect(result[:policies]).to include('legion-worker') + expect(result[:lease_duration]).to eq(3600) + expect(result[:renewable]).to be true + expect(result[:metadata]).to eq({ 'username' => 'miverso2' }) + end + + it 'calls Vault.logical.write with Negotiate authorization header' do + expect(vault_logical).to receive(:write).with( + 'auth/kerberos/login', + authorization: "Negotiate #{spnego_token}" + ).and_return(response_double) + + described_class.login(spnego_token: spnego_token) + end + + it 'uses DEFAULT_AUTH_PATH by default' do + expect(vault_logical).to receive(:write).with( + Legion::Crypt::VaultKerberosAuth::DEFAULT_AUTH_PATH, + anything + ).and_return(response_double) + + described_class.login(spnego_token: spnego_token) + end + end + + context 'when Vault is not connected' do + before do + Legion::Settings[:crypt][:vault][:connected] = false + end + + it 'raises AuthError' do + expect { described_class.login(spnego_token: spnego_token) } + .to raise_error(Legion::Crypt::VaultKerberosAuth::AuthError, 'Vault is not connected') + end + end + + context 'when Vault returns no auth data' do + it 'raises AuthError' do + allow(vault_logical).to receive(:write).and_return(double('VaultResponse', auth: nil)) + + expect { described_class.login(spnego_token: spnego_token) } + .to raise_error(Legion::Crypt::VaultKerberosAuth::AuthError, 'Vault Kerberos auth returned no auth data') + end + end + + context 'when Vault returns nil response' do + it 'raises AuthError' do + allow(vault_logical).to receive(:write).and_return(nil) + + expect { described_class.login(spnego_token: spnego_token) } + .to raise_error(Legion::Crypt::VaultKerberosAuth::AuthError, 'Vault Kerberos auth returned no auth data') + end + end + + context 'when Vault raises HTTPClientError (4xx)' do + it 'wraps it in AuthError' do + allow(vault_logical).to receive(:write).and_raise(Vault::HTTPClientError, 'permission denied') + + expect { described_class.login(spnego_token: spnego_token) } + .to raise_error(Legion::Crypt::VaultKerberosAuth::AuthError, /Vault Kerberos auth failed: permission denied/) + end + end + + context 'with custom auth path' do + it 'uses the custom auth path' do + expect(vault_logical).to receive(:write).with( + 'auth/custom-kerberos/login', + authorization: 'Negotiate token123' + ).and_return(response_double) + + result = described_class.login(spnego_token: 'token123', auth_path: 'auth/custom-kerberos/login') + expect(result[:token]).to eq(vault_token) + end + end + end + + describe '.login!' do + before do + allow(Vault).to receive(:token=) + end + + it 'sets the Vault token after login' do + described_class.login!(spnego_token: spnego_token) + expect(Vault).to have_received(:token=).with(vault_token) + end + + it 'returns the auth result' do + result = described_class.login!(spnego_token: spnego_token) + expect(result[:token]).to eq(vault_token) + end + + it 'propagates AuthError from login' do + Legion::Settings[:crypt][:vault][:connected] = false + + expect { described_class.login!(spnego_token: spnego_token) } + .to raise_error(Legion::Crypt::VaultKerberosAuth::AuthError, 'Vault is not connected') + end + end + + describe 'AuthError' do + it 'inherits from StandardError' do + expect(Legion::Crypt::VaultKerberosAuth::AuthError.ancestors).to include(StandardError) + end + + it 'carries the error message' do + err = Legion::Crypt::VaultKerberosAuth::AuthError.new('something went wrong') + expect(err.message).to eq('something went wrong') + end + end + + describe 'constants' do + it 'DEFAULT_AUTH_PATH is auth/kerberos/login' do + expect(described_class::DEFAULT_AUTH_PATH).to eq('auth/kerberos/login') + end + end +end diff --git a/spec/legion/vault_renewer_spec.rb b/spec/legion/vault_renewer_spec.rb index 8869eb3..23c3dc5 100644 --- a/spec/legion/vault_renewer_spec.rb +++ b/spec/legion/vault_renewer_spec.rb @@ -1,43 +1,98 @@ -# # require 'spec_helper' -# # -# require 'legion/extensions/helpers/core' -# require 'legion/extensions/helpers/logger' -# require 'legion/extensions/helpers/lex' -# require 'legion/extensions/actors/every' -# require 'legion/crypt/vault_renewer' -# -# RSpec.describe Legion::Crypt::Vault::Renewer do -# it 'can init' do -# expect { Legion::Crypt::Vault::Renewer.new }.not_to raise_exception -# end -# -# before do -# @renewer = Legion::Crypt::Vault::Renewer.new -# end -# it 'is an actor' do -# expect(@renewer).to be_a Legion::Extensions::Actors::Every -# end -# -# it 'has settings set for the actor' do -# expect(@renewer.runner_function).to eq 'renew_sessions' -# expect(@renewer.class).to eq Legion::Crypt::Vault::Renewer -# expect(@renewer.time).to eq 5 -# expect(@renewer.use_runner?).to eq false -# end -# -# it 'can cancel' do -# expect { @renewer.cancel }.not_to raise_exception -# end -# -# it '.generate_task?' do -# expect(@renewer.generate_task?).to eq false -# end -# -# it '.check_subtask?' do -# expect(@renewer.check_subtask?).to eq false -# end -# -# it 'uses the correct class' do -# expect(@renewer.runner_class).to eq Legion::Crypt -# end -# end +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/crypt/vault_cluster' +require 'legion/crypt/vault' + +RSpec.describe Legion::Crypt::Vault do + describe '#renew_sessions' do + context 'when no clusters are configured (single-cluster fallback)' do + let(:obj) do + obj = Object.new + obj.extend(described_class) + obj.instance_variable_set(:@sessions, []) + obj + end + + it 'does not raise when sessions list is empty' do + expect { obj.renew_sessions }.not_to raise_error + end + + it 'calls renew_session for each session' do + obj.instance_variable_set(:@sessions, ['lease/abc', 'lease/xyz']) + expect(obj).to receive(:renew_session).with(session: 'lease/abc') + expect(obj).to receive(:renew_session).with(session: 'lease/xyz') + obj.renew_sessions + end + end + + context 'when clusters are configured (multi-cluster path)' do + let(:mock_client) { instance_double(Vault::Client) } + let(:mock_auth_token) { double('auth_token') } + let(:connected_cluster_config) do + { protocol: 'https', address: 'vault.example.com', port: 8200, token: 'tok', connected: true } + end + + let(:obj) do + obj = Object.new + obj.extend(Legion::Crypt::VaultCluster) + obj.extend(described_class) + obj.instance_variable_set(:@sessions, []) + vault_settings_hash = { default: :primary, clusters: { primary: connected_cluster_config } } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + before do + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(mock_client).to receive(:auth_token).and_return(mock_auth_token) + allow(mock_auth_token).to receive(:renew_self) + end + + it 'calls renew_self on the vault client for each connected cluster' do + expect(mock_auth_token).to receive(:renew_self).once + obj.renew_sessions + end + + it 'captures errors per cluster without stopping' do + allow(mock_auth_token).to receive(:renew_self).and_raise(StandardError, 'renewal failed') + expect { obj.renew_sessions }.not_to raise_error + end + end + end + + describe '#renew_cluster_tokens' do + let(:mock_client) { instance_double(Vault::Client) } + let(:mock_auth_token) { double('auth_token') } + let(:cluster_config) do + { protocol: 'https', address: 'vault.example.com', port: 8200, token: 'tok', connected: true } + end + + let(:obj) do + obj = Object.new + obj.extend(Legion::Crypt::VaultCluster) + obj.extend(described_class) + vault_settings_hash = { default: :primary, clusters: { primary: cluster_config } } + obj.define_singleton_method(:vault_settings) { vault_settings_hash } + obj + end + + before do + allow(Vault::Client).to receive(:new).and_return(mock_client) + allow(mock_client).to receive(:namespace=) + allow(mock_client).to receive(:auth_token).and_return(mock_auth_token) + allow(mock_auth_token).to receive(:renew_self) + end + + it 'renews tokens for each connected cluster' do + expect(mock_auth_token).to receive(:renew_self) + obj.renew_cluster_tokens + end + + it 'handles errors without raising' do + allow(mock_auth_token).to receive(:renew_self).and_raise(StandardError, 'timeout') + expect { obj.renew_cluster_tokens }.not_to raise_error + end + end +end diff --git a/spec/legion/vault_spec.rb b/spec/legion/vault_spec.rb index 82e604b..8f56691 100644 --- a/spec/legion/vault_spec.rb +++ b/spec/legion/vault_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/crypt/vault' @@ -15,31 +17,35 @@ expect { @vault.connect_vault }.not_to raise_exception end - before do - Legion::Crypt.connect_vault - end + describe '#connect_vault rescue logging' do + before do + # Ensure a token is present so connect_vault reaches ::Vault.sys.health_status + allow(Legion::Settings[:crypt][:vault]).to receive(:[]).and_call_original + allow(Legion::Settings[:crypt][:vault]).to receive(:[]).with(:token).and_return('test-token') + allow(Legion::Settings[:crypt][:vault]).to receive(:[]=) + allow(Vault).to receive(:address=) + allow(Vault).to receive(:token=) + allow(Vault.sys).to receive(:health_status).and_raise(StandardError, 'connection refused') + end - it '.write' do - # expect { @vault.write('test', 'key', 'value') }.not_to raise_exception - end + it 'returns false and does not raise when Vault.sys.health_status raises' do + expect(@vault.connect_vault).to eq false + end - it '.read' do - # expect(@vault.read('creds/legion', 'rabbitmq')).to be_a Hash + it 'logs the exception via handle_exception' do + expect(@vault).to receive(:handle_exception).with(instance_of(StandardError), hash_including(level: :error)) + @vault.connect_vault + end end - it '.get' do - # expect(@vault.get('test')).to be_a Hash - # expect(@vault.get('test')).to eq({ key: 'value' }) + before do + Legion::Crypt.connect_vault end it '.add_session' do expect(@vault.add_session(path: '/test')).to be_a Array end - it 'exist?' do - # expect(@vault.exist?('test')).to eq true - end - it '.close_sessions' do expect(@vault.close_sessions).to be_a Array end @@ -52,11 +58,217 @@ expect(Legion::Crypt.close_sessions).to be_a Array end - it '.renew_session' do - # empty block - end - it '.renew_sessions' do expect(Legion::Crypt.renew_sessions).to eq [] end + + # Multi-cluster KV routing: kv_client / logical_client helpers + # + # The test object extends both Vault and VaultCluster so that connected_clusters + # and vault_client are available, mirroring how Legion::Crypt composes them. + describe 'multi-cluster client routing' do + let(:kv_path) { 'legion' } + let(:mock_kv) { double('kv') } + let(:mock_logical) { double('logical') } + let(:mock_cluster_client) do + dbl = instance_double(Vault::Client) + allow(dbl).to receive(:kv).with(kv_path).and_return(mock_kv) + allow(dbl).to receive(:logical).and_return(mock_logical) + dbl + end + + # A host object that mixes in both modules so connected_clusters and + # vault_client are available alongside the Vault KV methods. + let(:host) do + obj = Object.new + obj.extend(Legion::Crypt::VaultCluster) + obj.extend(Legion::Crypt::Vault) + obj.sessions = [] + obj + end + + context 'when clusters are connected' do + before do + allow(host).to receive(:connected_clusters).and_return({ primary: { token: 'tok', connected: true } }) + allow(host).to receive(:selected_connected_cluster_name).with(nil).and_return(:primary) + allow(host).to receive(:vault_client).with(:primary).and_return(mock_cluster_client) + end + + describe '#kv_client' do + it 'returns vault_client.kv(kv_path)' do + expect(host.send(:kv_client)).to eq(mock_kv) + end + + it 'does not touch the global ::Vault singleton' do + expect(Vault).not_to receive(:kv) + host.send(:kv_client) + end + end + + describe '#logical_client' do + it 'returns vault_client.logical' do + expect(host.send(:logical_client)).to eq(mock_logical) + end + + it 'does not touch the global ::Vault singleton' do + expect(Vault).not_to receive(:logical) + host.send(:logical_client) + end + end + + describe '#get' do + it 'reads through the cluster kv client' do + secret = double('secret', data: { value: 'secret_val' }) + allow(mock_kv).to receive(:read).with('mypath').and_return(secret) + result = host.get('mypath') + expect(result).to eq({ value: 'secret_val' }) + end + + it 'returns nil when the cluster kv client returns nil' do + allow(mock_kv).to receive(:read).with('missing').and_return(nil) + expect(host.get('missing')).to be_nil + end + end + + describe '#write' do + it 'writes through the cluster kv client' do + expect(mock_kv).to receive(:write).with('mypath', key: 'val') + host.write('mypath', key: 'val') + end + end + + describe '#exist?' do + it 'reads metadata through the cluster kv client' do + allow(mock_kv).to receive(:read_metadata).with('mypath').and_return(double('meta')) + expect(host.exist?('mypath')).to be true + end + + it 'returns false when metadata is nil' do + allow(mock_kv).to receive(:read_metadata).with('gone').and_return(nil) + expect(host.exist?('gone')).to be false + end + end + + describe '#delete' do + it 'deletes through the cluster logical client' do + allow(mock_logical).to receive(:delete).with('secret/mypath').and_return(nil) + result = host.delete('secret/mypath') + expect(result[:success]).to be true + end + + it 'returns success: false on error' do + allow(mock_logical).to receive(:delete).and_raise(StandardError, 'permission denied') + result = host.delete('secret/mypath') + expect(result[:success]).to be false + expect(result[:error]).to match(/permission denied/) + end + end + + describe '#read (logical)' do + it 'reads through the cluster logical client' do + lease = double('lease', lease_id: nil, data: { token: 'abc' }) + allow(lease).to receive(:respond_to?).with(:lease_id).and_return(false) + allow(mock_logical).to receive(:read).and_return(lease) + result = host.read('database/creds/myrole', nil) + expect(result).to eq({ token: 'abc' }) + end + end + + describe 'explicit cluster selection' do + let(:mock_secondary_kv) { double('kv-secondary') } + let(:mock_secondary_logical) { double('logical-secondary') } + let(:mock_secondary_client) do + dbl = instance_double(Vault::Client) + allow(dbl).to receive(:kv).with(kv_path).and_return(mock_secondary_kv) + allow(dbl).to receive(:logical).and_return(mock_secondary_logical) + dbl + end + + before do + allow(host).to receive(:connected_clusters).and_return( + { + primary: { token: 'tok', connected: true }, + secondary: { token: 'tok-2', connected: true } + } + ) + allow(host).to receive(:selected_connected_cluster_name).with(:secondary).and_return(:secondary) + allow(host).to receive(:vault_client).with(:secondary).and_return(mock_secondary_client) + end + + it 'routes get through the requested cluster client' do + secret = double('secret', data: { value: 'secondary' }) + allow(mock_secondary_kv).to receive(:read).with('mypath').and_return(secret) + + expect(host.get('mypath', cluster_name: :secondary)).to eq({ value: 'secondary' }) + end + + it 'raises when the requested cluster is not connected' do + allow(host).to receive(:selected_connected_cluster_name).with(:missing) + .and_raise(ArgumentError, 'Vault cluster not connected: missing') + + expect do + host.get('mypath', cluster_name: :missing) + end.to raise_error(ArgumentError, /not connected/) + end + end + end + + context 'when no clusters are connected' do + before do + allow(host).to receive(:connected_clusters).and_return({}) + end + + let(:global_kv) { double('global_kv') } + let(:global_logical) { double('global_logical') } + + before do + allow(Vault).to receive(:kv).with(kv_path).and_return(global_kv) + allow(Vault).to receive(:logical).and_return(global_logical) + end + + describe '#kv_client' do + it 'falls back to the global ::Vault.kv client' do + expect(host.send(:kv_client)).to eq(global_kv) + end + + it 'does not call vault_client' do + expect(host).not_to receive(:vault_client) + host.send(:kv_client) + end + end + + describe '#logical_client' do + it 'falls back to the global ::Vault.logical client' do + expect(host.send(:logical_client)).to eq(global_logical) + end + + it 'does not call vault_client' do + expect(host).not_to receive(:vault_client) + host.send(:logical_client) + end + end + + describe '#get' do + it 'reads through the global ::Vault kv client' do + secret = double('secret', data: { val: 1 }) + allow(global_kv).to receive(:read).with('mypath').and_return(secret) + expect(host.get('mypath')).to eq({ val: 1 }) + end + end + + describe '#write' do + it 'writes through the global ::Vault kv client' do + expect(global_kv).to receive(:write).with('mypath', x: 1) + host.write('mypath', x: 1) + end + end + + describe '#exist?' do + it 'checks metadata through the global ::Vault kv client' do + allow(global_kv).to receive(:read_metadata).with('mypath').and_return(double('meta')) + expect(host.exist?('mypath')).to be true + end + end + end + end end diff --git a/spec/legion/version_spec.rb b/spec/legion/version_spec.rb index 9a6db89..c8d1490 100644 --- a/spec/legion/version_spec.rb +++ b/spec/legion/version_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/crypt/version' diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 62d66dd..36566f3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + begin require 'simplecov' SimpleCov.start do @@ -11,6 +13,8 @@ end require 'bundler/setup' +lib_path = File.expand_path('../lib', __dir__) +$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) require 'legion/logging' require 'legion/settings'