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..4783e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # rspec failure tracking .rspec_status legionio.key + +# git worktrees +.worktrees/ 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..a71a205 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# 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. + +## 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..ad0a3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,298 @@ # Legion::Crypt +## [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 + +### 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 + +### 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..bba405b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,206 @@ +# 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.4.15 +**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 +├── 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/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..ceffc0c 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.4.22 -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..cf6f577 100644 --- a/legion-crypt.gemspec +++ b/legion-crypt.gemspec @@ -6,27 +6,27 @@ 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 'ed25519', '~> 1.3' + spec.add_dependency 'jwt', '>= 2.7' + spec.add_dependency 'legion-logging', '>= 1.4.0' + spec.add_dependency 'vault', '>= 0.17' end diff --git a/lib/legion/crypt.rb b/lib/legion/crypt.rb index fe72c1e..f1b9983 100644 --- a/lib/legion/crypt.rb +++ b/lib/legion/crypt.rb @@ -1,14 +1,34 @@ +# 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 + class << self attr_reader :sessions + include Legion::Logging::Helper include Legion::Crypt::Cipher unless Gem::Specification.find_by_name('vault').nil? @@ -16,11 +36,45 @@ 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' + 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 + log.info 'Legion::Crypt startup completed' end def settings @@ -31,9 +85,124 @@ def settings end end + def jwt_settings + settings[:jwt] || Legion::Crypt::Settings.jwt + 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 + log.info 'Legion::Crypt shutdown initiated' + Legion::Crypt::LeaseManager.instance.shutdown + stop_token_renewers shutdown_renewer close_sessions + stop_svid_rotation + log.info 'Legion::Crypt shutdown completed' + end + + private + + def start_lease_manager + leases = settings.dig(:vault, :leases) || {} + return if leases.empty? + return unless settings.dig(:vault, :connected) || connected_clusters.any? + + client = nil + + if settings.dig(:vault, :connected) + client = vault_client + elsif connected_clusters.any? + default_cluster = vault_settings[:default] + selected_cluster = + if default_cluster && connected_clusters.include?(default_cluster.to_sym) + default_cluster.to_sym + else + connected_clusters.keys.first + end + + client = selected_cluster ? vault_client(selected_cluster) : nil + end + lease_manager = Legion::Crypt::LeaseManager.instance + lease_manager.start(leases, vault_client: client) + lease_manager.start_renewal_thread + 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 + 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 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..cc95282 --- /dev/null +++ b/lib/legion/crypt/cert_rotation.rb @@ -0,0 +1,129 @@ +# 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 + 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 + if @thread&.alive? + @thread.kill + @thread.join(2) + end + @thread = nil + 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) + @current_cert = new_cert + @issued_at = Time.now + log.info("[mTLS] Certificate rotated: serial=#{new_cert[:serial]} expiry=#{new_cert[:expiry]}") + emit_rotated_event(new_cert) + new_cert + end + + def needs_renewal? + 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 + 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 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..b938b3b 100644 --- a/lib/legion/crypt/cipher.rb +++ b/lib/legion/crypt/cipher.rb @@ -1,41 +1,65 @@ +# frozen_string_literal: true + require 'securerandom' +require 'legion/logging/helper' require 'legion/crypt/cluster_secret' module Legion module Crypt module Cipher include Legion::Crypt::ClusterSecret + include Legion::Logging::Helper def encrypt(message) cipher = OpenSSL::Cipher.new('aes-256-cbc') cipher.encrypt cipher.key = cs iv = cipher.random_iv - { enciphered_message: Base64.encode64(cipher.update(message) + cipher.final), iv: Base64.encode64(iv) } + result = { enciphered_message: Base64.encode64(cipher.update(message) + cipher.final), iv: Base64.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) + def decrypt(message, init_vector) until cs.is_a?(String) || Legion::Settings[:client][:shutting_down] - Legion::Logging.debug('sleeping Legion::Crypt.decrypt due to CS not being set') + log.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) + decipher.iv = Base64.decode64(init_vector) message = Base64.decode64(message) - decipher.update(message) + decipher.final + result = decipher.update(message) + decipher.final + 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 = Base64.encode64(rsa_public_key.public_encrypt(message)) + log.debug 'Cipher keypair encryption completed' + encrypted_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 = private_key.private_decrypt(Base64.decode64(message)) + 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 +68,15 @@ 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 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..1a18dd3 --- /dev/null +++ b/lib/legion/crypt/ed25519.rb @@ -0,0 +1,94 @@ +# 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 defined?(Legion::Crypt::Vault) + log.info "Ed25519 storing keypair for agent #{agent_id}" + Legion::Crypt::Vault.write("#{key_prefix}/#{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}" + data = Legion::Crypt::Vault.read("#{key_prefix}/#{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 || 'secret/data/legion/keys' + 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..894a4cc --- /dev/null +++ b/lib/legion/crypt/erasure.rb @@ -0,0 +1,53 @@ +# 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}" + delete_vault_key(key_path) if defined?(Legion::Crypt::Vault) + 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" + data = Legion::Crypt::Vault.read(key_path) + 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: true, tenant_id: tenant_id } + 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..9c8e6a7 --- /dev/null +++ b/lib/legion/crypt/jwks_client.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' +require 'openssl' +require 'jwt' +require 'legion/logging/helper' + +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 + + raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}" + 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 clear_cache + @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 = uri.scheme == 'https' + 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 + raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}" unless e.is_a?(Legion::Crypt::JWT::Error) + + 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 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..7b76724 --- /dev/null +++ b/lib/legion/crypt/jwt.rb @@ -0,0 +1,188 @@ +# 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.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..13633df --- /dev/null +++ b/lib/legion/crypt/kerberos_auth.rb @@ -0,0 +1,113 @@ +# 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("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("login: vault_client.address=#{addr}, namespace=#{ns}") + + @kerberos_principal = nil + token = obtain_token(service_principal) + log_debug("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("login: authenticated as #{@kerberos_principal.inspect}, policies=#{result[:policies].inspect}") + log_debug("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 + + def self.log_debug(message) + log.debug("KerberosAuth: #{message}") + end + private_class_method :log_debug + + 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. + log_debug("exchange_token: PUT /v1/#{auth_path} (namespace=#{vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a'})") + 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("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..0b6f67d --- /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 + mark_vault_connected + + 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..7309353 --- /dev/null +++ b/lib/legion/crypt/lease_manager.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' +require 'singleton' + +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 + end + + def start(definitions, vault_client: nil) + @vault_client = vault_client + return if definitions.nil? || definitions.empty? + + 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 + response = logical.read(path) + unless response + log.warn("LeaseManager: no data at '#{name}' (#{path}) — path may not exist or role not configured") + next + end + + @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 + } + log.info("LeaseManager: fetched lease for '#{name}' from #{path}") + 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 + @active_leases.size + end + + def fetch(name, key) + data = @lease_cache[name.to_sym] || @lease_cache[name.to_s] + return nil unless data + + data[key.to_sym] || data[key.to_s] + end + + def lease_data(name) + @lease_cache[name] + end + + attr_reader :active_leases + + def register_ref(name, key, path) + @refs[name] ||= {} + @refs[name][key] = path + end + + def push_to_settings(name) + refs = @refs[name] + return if refs.nil? || refs.empty? + + data = @lease_cache[name] + 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 + + def start_renewal_thread + return if renewal_thread_alive? + + @running = true + @renewal_thread = Thread.new { renewal_loop } + log.info 'LeaseManager renewal thread started' + end + + def renewal_thread_alive? + @renewal_thread&.alive? || false + end + + def shutdown + log.info 'LeaseManager shutdown requested' + stop_renewal_thread + + @active_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 + + @lease_cache.clear + @active_leases.clear + @refs.clear + @vault_client = nil + log.info 'LeaseManager shutdown complete' + end + + def reset! + @running = false + @lease_cache.clear + @active_leases.clear + @refs.clear + @vault_client = nil + end + + private + + def logical + @vault_client ? @vault_client.logical : ::Vault.logical + end + + def sys + @vault_client ? @vault_client.sys : ::Vault.sys + end + + def stop_renewal_thread + @running = false + if @renewal_thread&.alive? + @renewal_thread.kill + @renewal_thread.join(2) + end + @renewal_thread = nil + log.debug 'LeaseManager renewal thread stopped' + end + + def renewal_loop + while @running + sleep(RENEWAL_CHECK_INTERVAL) + renew_approaching_leases if @running + end + rescue StandardError => e + handle_exception(e, level: :error, operation: 'crypt.lease_manager.renewal_loop') + log.error("LeaseManager: renewal loop error: #{e.message}") + retry if @running + end + + def renew_approaching_leases + @active_leases.each do |name, lease| + next unless lease[:renewable] + next unless approaching_expiry?(lease) + + renew_lease(name, lease) + end + end + + def renew_lease(name, lease) + response = sys.renew(lease[:lease_id]) + lease[:expires_at] = Time.now + (response.lease_duration || 0) + log.info("LeaseManager: renewed lease '#{name}'") + + if response.data && response.data != @lease_cache[name] + @lease_cache[name] = response.data + push_to_settings(name) + 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}") + end + + def lease_valid?(name) + meta = @active_leases[name] + 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 = @active_leases[name] + 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 + @active_leases.delete(name) + @lease_cache.delete(name) + 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 log_debug(message) + log.debug(message) + 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..da883fb 100644 --- a/lib/legion/crypt/settings.rb +++ b/lib/legion/crypt/settings.rb @@ -1,30 +1,77 @@ +# 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 + } + 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 + } + 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' + }, + clusters: {} } end end @@ -32,11 +79,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..e9365da --- /dev/null +++ b/lib/legion/crypt/spiffe.rb @@ -0,0 +1,145 @@ +# 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) 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) 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 + + 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..f2e5923 --- /dev/null +++ b/lib/legion/crypt/spiffe/svid_rotation.rb @@ -0,0 +1,143 @@ +# 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) + @thread = nil + 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..8ca483f --- /dev/null +++ b/lib/legion/crypt/spiffe/workload_api_client.rb @@ -0,0 +1,426 @@ +# 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 = [0, 4, 0, 0, 0, 0].pack('NnCCNN') + + CONNECT_TIMEOUT = 5 + READ_TIMEOUT = 10 + + def initialize(socket_path: nil, trust_domain: nil) + @socket_path = socket_path || Legion::Crypt::Spiffe.socket_path + @trust_domain = trust_domain || Legion::Crypt::Spiffe.trust_domain + 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: true) + 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 + sock.close rescue nil # rubocop:disable Style/RescueModifier + end + 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 + ) + 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 + ) + 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 + ) + 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..03d7a40 --- /dev/null +++ b/lib/legion/crypt/tls.rb @@ -0,0 +1,102 @@ +# 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 + + def log_warn(msg) + log.warn(msg) + 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..94b23bf --- /dev/null +++ b/lib/legion/crypt/token_renewer.rb @@ -0,0 +1,175 @@ +# 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 + @stop = false + @thread = Thread.new { renewal_loop } + @thread.name = "vault-renewer-#{@cluster_name}" + log_info('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 + log_info("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("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('re-authenticated via Kerberos') + true + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.reauth_kerberos', cluster_name: @cluster_name) + log_warn("Kerberos re-auth failed: #{e.message}") + false + end + + def sleep_duration + duration = (@config[:lease_duration].to_i * RENEWAL_RATIO).to_i + [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 renew_token || reauth_kerberos + on_renewal_success + else + on_renewal_failure + end + end + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.renewal_loop', cluster_name: @cluster_name) + log_warn("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("backoff retry in #{delay}s") + interruptible_sleep(delay) + 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('stopping token renewal thread') + @thread.join(5) + thread_still_running = @thread.alive? + @thread = nil + + if thread_still_running + log_warn('token renewal thread did not stop within timeout; skipping token revocation') + else + revoke_token + log_debug('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('Vault token revoked') + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'crypt.token_renewer.revoke_token', cluster_name: @cluster_name) + log_warn("Vault token revoke failed: #{e.message}") + end + + def log_debug(message) + log.debug("TokenRenewer[#{@cluster_name}]: #{message}") + end + + def log_info(message) + log.info("TokenRenewer[#{@cluster_name}]: #{message}") + end + + def log_warn(message) + log.warn("TokenRenewer[#{@cluster_name}]: #{message}") + end + end + end +end diff --git a/lib/legion/crypt/vault.rb b/lib/legion/crypt/vault.rb index e59569d..a7f8e7e 100644 --- a/lib/legion/crypt/vault.rb +++ b/lib/legion/crypt/vault.rb @@ -1,63 +1,116 @@ +# 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) + namespace = vault_settings[:vault_namespace] + log.info "Vault connection requested address=#{::Vault.address} namespace=#{namespace || 'none'}" 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 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 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 + log_read_context(full_path) + lease = logical_client.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) - result = ::Vault.kv(settings[:vault][:kv_path]).read(path) - return nil if result.nil? + log.debug "Vault kv get: path=#{path}" + result = kv_client.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) + log.info "Vault kv write requested path=#{path}" + kv_client.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 delete(path) + logical_client.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) - !::Vault.kv(settings[:vault][:kv_path]).read_metadata(path).nil? + !kv_client.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 +121,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 +134,92 @@ 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 + if respond_to?(:connected_clusters) && connected_clusters.any? + vault_client.kv(settings[:vault][:kv_path]) + else + ::Vault.kv(settings[:vault][:kv_path]) + end + end + + def logical_client + if respond_to?(:connected_clusters) && connected_clusters.any? + vault_client.logical + else + ::Vault.logical + end + end + + def log_read_context(full_path) + namespace = if respond_to?(:connected_clusters) && connected_clusters.any? + client = vault_client + 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 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_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..7722ef5 --- /dev/null +++ b/lib/legion/crypt/vault_cluster.rb @@ -0,0 +1,183 @@ +# 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") + mark_vault_connected if 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/ + + raise + end + + def mark_vault_connected + return unless defined?(Legion::Settings) + + Legion::Settings[:crypt][:vault][:connected] = true + 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]}" + log.info "Building Vault client address=#{addr} namespace=#{config[:namespace].inspect}" + log_vault_debug("build_vault_client: address=#{addr}") + client = ::Vault::Client.new( + address: addr, + token: config[:token] + ) + 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 log_vault_error(name, error, operation: 'crypt.vault_cluster.error') + handle_exception(error, level: :error, operation: operation, cluster_name: name) + log.error("Vault cluster #{name}: #{error.message}") + 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_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..5de3941 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.0' end end diff --git a/lib/legion/logging/helper.rb b/lib/legion/logging/helper.rb new file mode 100644 index 0000000..fc0f5b0 --- /dev/null +++ b/lib/legion/logging/helper.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +helper_path = File.join( + Gem::Specification.find_by_name('legion-logging').full_gem_path, + 'lib/legion/logging/helper.rb' +) +require helper_path + +module Legion + module Logging + module Helper + unless method_defined?(:handle_exception) || private_method_defined?(:handle_exception) + unless const_defined?(:CompatLogger, false) + CompatLogger = Class.new do + %i[debug info warn error fatal unknown].each do |level| + define_method(level) do |message = nil, &block| + payload = block ? block.call : message + return if payload.nil? + + if Legion.const_defined?('Logging') && Legion::Logging.is_a?(Module) && Legion::Logging.respond_to?(level) + Legion::Logging.public_send(level, payload) + elsif %i[error fatal warn].include?(level) + ::Kernel.warn(payload) + elsif !Legion.const_defined?('Logging') || Legion::Logging.is_a?(Module) + $stdout.puts(payload) + end + end + end + end + end + + def log + @log ||= CompatLogger.new + end + + def handle_exception(exception, task_id: nil, level: :error, handled: true, **opts) # rubocop:disable Lint/UnusedMethodArgument,Style/ArgumentsForwarding + message = exception_log_message(exception, level: level, **opts) # rubocop:disable Style/ArgumentsForwarding + + if Legion.const_defined?('Logging') + if !Legion::Logging.is_a?(Module) && Legion::Logging.respond_to?(:log_exception) + Legion::Logging.log_exception(exception, lex: 'crypt', component_type: :helper) + return + end + if Legion::Logging.respond_to?(level) + Legion::Logging.public_send(level, message) + return + end + if Legion::Logging.respond_to?(:error) + Legion::Logging.error(message) + return + end + if Legion::Logging.respond_to?(:warn) + Legion::Logging.warn(message) + return + end + end + + ::Kernel.warn(message) + end + + private + + def exception_log_message(exception, level:, **opts) + operation = opts[:operation] || opts['operation'] + prefix = operation ? "#{operation} failed: " : '' + details = opts.reject { |key, _value| key.to_s == 'operation' }.map { |key, value| "#{key}=#{value}" } + detail_suffix = details.empty? ? '' : " (#{details.join(' ')})" + backtrace = Array(exception.backtrace).first(10).join("\n") + base = "#{prefix}#{exception.class}: #{exception.message}#{detail_suffix}" + return base if backtrace.empty? && level == :debug + return base if backtrace.empty? + + "#{base}\n#{backtrace}" + end + end + end + end +end 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..d055743 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' diff --git a/spec/legion/cluster_secret_spec.rb b/spec/legion/cluster_secret_spec.rb index d8c3197..20e0a1b 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,104 @@ 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 a warning when Legion::Logging is available' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:info) + expect(logging).to receive(:warn).with(match(/push_cs_to_vault failed/)) + @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 +173,93 @@ 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 log_exception when available' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(true) + expect(logging).to receive(:log_exception).with(instance_of(StandardError), lex: 'crypt', component_type: :helper) + @cs.from_transport + end + + it 'falls back to Logging.error with backtrace when log_exception unavailable' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(false) + allow(logging).to receive(:respond_to?).with(:error).and_return(true) + expect(logging).to receive(:error).with(match(/transport error/)) + @cs.from_transport + end + + it 'does not raise and returns nil when Legion::Logging is absent' do + hide_const('Legion::Logging') + allow(Kernel).to receive(:warn) + result = nil + expect { result = @cs.from_transport }.not_to raise_error + expect(result).to be_nil + 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 log_exception when available' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(true) + expect(logging).to receive(:log_exception).with(instance_of(StandardError), lex: 'crypt', component_type: :helper) + @cs.cs + end + + it 'falls back to Logging.error with backtrace when log_exception unavailable' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(false) + allow(logging).to receive(:respond_to?).with(:error).and_return(true) + expect(logging).to receive(:error).with(match(/digest error/)) + @cs.cs + end + + it 'falls back to Logging.warn when only warn is available' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(false) + allow(logging).to receive(:respond_to?).with(:error).and_return(false) + allow(logging).to receive(:respond_to?).with(:warn).and_return(true) + expect(logging).to receive(:warn).with(match(/digest error/)) + @cs.cs + end + + it 'falls back to Kernel.warn when Legion::Logging is absent' do + hide_const('Legion::Logging') + expect(Kernel).to receive(:warn).with(match(/digest error/)) + expect(@cs.cs).to be_nil + end + + it 'returns nil without raising when Legion::Logging is absent' do + hide_const('Legion::Logging') + allow(Kernel).to receive(:warn) + result = nil + expect { result = @cs.cs }.not_to raise_error + expect(result).to be_nil + 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..676d7d0 --- /dev/null +++ b/spec/legion/crypt/cert_rotation_spec.rb @@ -0,0 +1,143 @@ +# 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 + end +end diff --git a/spec/legion/crypt/ed25519_spec.rb b/spec/legion/crypt/ed25519_spec.rb new file mode 100644 index 0000000..522bd3e --- /dev/null +++ b/spec/legion/crypt/ed25519_spec.rb @@ -0,0 +1,35 @@ +# 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 +end diff --git a/spec/legion/crypt/erasure_spec.rb b/spec/legion/crypt/erasure_spec.rb new file mode 100644 index 0000000..c3ad75f --- /dev/null +++ b/spec/legion/crypt/erasure_spec.rb @@ -0,0 +1,24 @@ +# 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(described_class).to receive(:delete_vault_key) + + 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(described_class).to receive(:delete_vault_key).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 +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..6320452 --- /dev/null +++ b/spec/legion/crypt/mtls_spec.rb @@ -0,0 +1,129 @@ +# 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(:[]).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..b066c49 --- /dev/null +++ b/spec/legion/crypt/spiffe_spec.rb @@ -0,0 +1,216 @@ +# 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 '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..11338d7 --- /dev/null +++ b/spec/legion/crypt/spiffe_svid_rotation_spec.rb @@ -0,0 +1,116 @@ +# 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 + stub_const('Legion::Settings', Module.new) + 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 + 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..5121139 --- /dev/null +++ b/spec/legion/crypt/spiffe_workload_api_client_spec.rb @@ -0,0 +1,183 @@ +# 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') } + + 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 '#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 'returns a self-signed fallback SVID' do + svid = client.fetch_x509_svid + expect(svid).to be_a(Legion::Crypt::Spiffe::X509Svid) + 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 + + 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 'falls back to self-signed SVID' do + svid = client.fetch_x509_svid + expect(svid).to be_a(Legion::Crypt::Spiffe::X509Svid) + expect(svid.valid?).to be true + 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(File).to receive(:exist?).and_return(false) + svid1 = client.fetch_x509_svid + svid2 = client.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 + allow(File).to receive(:exist?).and_return(false) + svid = 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 '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..9e528c9 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,189 @@ 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 '.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 + 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 + end end diff --git a/spec/legion/jwks_client_spec.rb b/spec/legion/jwks_client_spec.rb new file mode 100644 index 0000000..5764f41 --- /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 re-fetch' do + expect(described_class).not_to receive(:fetch_keys) + + 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..21c1b2d --- /dev/null +++ b/spec/legion/jwt_spec.rb @@ -0,0 +1,373 @@ +# 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 '.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..6da2cf5 --- /dev/null +++ b/spec/legion/ldap_auth_spec.rb @@ -0,0 +1,126 @@ +# 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(: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 '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 'sets the top-level vault connected flag when Legion::Settings is defined' 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(true) + 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..85cf207 --- /dev/null +++ b/spec/legion/lease_manager_spec.rb @@ -0,0 +1,396 @@ +# 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 '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 + 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 '#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 +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..54dda88 --- /dev/null +++ b/spec/legion/token_renewer_spec.rb @@ -0,0 +1,172 @@ +# 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')) + 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) + 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 'returns at least MIN_SLEEP seconds' do + config[:lease_duration] = 10 + expect(renewer.sleep_duration).to eq(30) + 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 + 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 +end diff --git a/spec/legion/vault_cluster_spec.rb b/spec/legion/vault_cluster_spec.rb new file mode 100644 index 0000000..716cb49 --- /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' + ).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' + ).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] to true' do + vault_hash = { connected: false } + crypt_hash = { vault: vault_hash } + stub_const('Legion::Settings', Module.new do + define_singleton_method(:[]) { |_k| crypt_hash } + end) + 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 '#mark_vault_connected (via connect_all_clusters)' do + it 'sets the top-level vault connected flag when Legion::Settings is defined' 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_jwt_auth_spec.rb b/spec/legion/vault_jwt_auth_spec.rb new file mode 100644 index 0000000..32f4224 --- /dev/null +++ b/spec/legion/vault_jwt_auth_spec.rb @@ -0,0 +1,263 @@ +# 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=) + allow(Legion::Logging).to receive(:info) + 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 'logs the authenticated policies' do + expect(Legion::Logging).to receive(:info).with(/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..b9d5848 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,55 @@ 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 via log_exception when available' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(true) + expect(logging).to receive(:log_exception).with(instance_of(StandardError), lex: 'crypt', component_type: :helper) + @vault.connect_vault + end + + it 'falls back to Logging.error with backtrace when log_exception unavailable' do + logging = double('Legion::Logging') + stub_const('Legion::Logging', logging) + allow(logging).to receive(:respond_to?).with(:log_exception).and_return(false) + allow(logging).to receive(:respond_to?).with(:error).and_return(true) + expect(logging).to receive(:error).with(match(/connection refused/)) + @vault.connect_vault + end + + it 'does not raise and returns false when Legion::Logging is absent' do + hide_const('Legion::Logging') + allow(Kernel).to receive(:warn) + result = nil + expect { result = @vault.connect_vault }.not_to raise_error + expect(result).to eq false + 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 +78,181 @@ 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) + # settings must return the full crypt hash so settings[:vault][:kv_path] resolves + obj.define_singleton_method(:settings) { Legion::Settings[:crypt] } + obj.define_singleton_method(:vault_settings) { Legion::Settings[: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(:vault_client).with(no_args).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 + 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..0ee4f34 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