feat(auth): support per-deploy login password key override
Multi-replica deployments need every Pod/container to share one RSA private key, otherwise public keys handed to the browser may not match the key that decrypts the resulting ciphertext. Introduce a `*.local.yaml` override layer that ops-config and tenant-config both load on top of the base config, ship example overrides plus compose/k3s wiring, and tolerate PEM bodies that arrive folded or escaped from Secrets.
This commit is contained in:
@@ -6,6 +6,8 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.signing
|
.env.signing
|
||||||
|
deploy/config.local.yaml
|
||||||
|
deploy/ops-config.local.yaml
|
||||||
apps/*/.env.signing
|
apps/*/.env.signing
|
||||||
apps/*/dist/
|
apps/*/dist/
|
||||||
apps/*/out/
|
apps/*/out/
|
||||||
@@ -24,3 +26,4 @@ apps/*/firefox-mv*/
|
|||||||
apps/*/stats.html
|
apps/*/stats.html
|
||||||
ops-api
|
ops-api
|
||||||
tmp/*
|
tmp/*
|
||||||
|
*.pem
|
||||||
|
|||||||
@@ -40,3 +40,15 @@ Default ports are NAS-friendly:
|
|||||||
- MinIO console: `19001`
|
- MinIO console: `19001`
|
||||||
|
|
||||||
Edit `.env` before rerunning `bash deploy.sh` if you need real API keys, stronger secrets, or different ports.
|
Edit `.env` before rerunning `bash deploy.sh` if you need real API keys, stronger secrets, or different ports.
|
||||||
|
|
||||||
|
For multi-container / multi-Pod login password encryption, put the same RSA private key in every replica's config override:
|
||||||
|
|
||||||
|
- tenant API: copy `config.local.yaml.example` to `config.local.yaml`, then fill `auth.password_cipher.private_key_pem`
|
||||||
|
- ops API: copy `ops-config.local.yaml.example` to `ops-config.local.yaml`, then fill `auth.password_cipher.private_key_pem`
|
||||||
|
|
||||||
|
Generate keys with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login-password-rsa.pem
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ops-login-password-rsa.pem
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
enabled: true
|
||||||
|
key_id: login-password-rsa-oaep-v1
|
||||||
|
# 多 Pod / 多容器部署必须使用同一把私钥。复制本文件为 config.local.yaml,
|
||||||
|
# 再粘贴完整 PEM;不要提交生产私钥。留空时应用会生成单进程临时私钥,
|
||||||
|
# 只适合本机单副本试跑,不适合多副本生产。
|
||||||
|
#
|
||||||
|
# 生成示例:
|
||||||
|
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login-password-rsa.pem
|
||||||
|
private_key_pem: ""
|
||||||
@@ -169,6 +169,13 @@ jwt:
|
|||||||
access_ttl: 15m
|
access_ttl: 15m
|
||||||
refresh_ttl: 720h
|
refresh_ttl: 720h
|
||||||
|
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
# 生产多实例请在 config.local.yaml 中配置同一把 private_key_pem。
|
||||||
|
enabled: true
|
||||||
|
key_id: login-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: info,warn,error
|
level: info,warn,error
|
||||||
format: json
|
format: json
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ x-app-env: &app-env
|
|||||||
|
|
||||||
x-app-volumes: &app-volumes
|
x-app-volumes: &app-volumes
|
||||||
- ./config.yaml:/app/configs/config.yaml:ro
|
- ./config.yaml:/app/configs/config.yaml:ro
|
||||||
|
- ./config.local.yaml:/app/configs/config.local.yaml:ro
|
||||||
- ./prompts.yml:/app/configs/prompts.yml:ro
|
- ./prompts.yml:/app/configs/prompts.yml:ro
|
||||||
|
|
||||||
x-app-depends: &app-depends
|
x-app-depends: &app-depends
|
||||||
@@ -226,6 +227,7 @@ services:
|
|||||||
CONFIG_PATH: /app/configs/config.yaml
|
CONFIG_PATH: /app/configs/config.yaml
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/app/configs/config.yaml:ro
|
- ./config.yaml:/app/configs/config.yaml:ro
|
||||||
|
- ./config.local.yaml:/app/configs/config.local.yaml:ro
|
||||||
- ./prompts.yml:/app/configs/prompts.yml:ro
|
- ./prompts.yml:/app/configs/prompts.yml:ro
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
@@ -355,6 +357,7 @@ services:
|
|||||||
OPS_DEFAULT_ADMIN_EMAIL: ${OPS_DEFAULT_ADMIN_EMAIL:-}
|
OPS_DEFAULT_ADMIN_EMAIL: ${OPS_DEFAULT_ADMIN_EMAIL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./ops-config.yaml:/app/configs/ops-config.yaml:ro
|
- ./ops-config.yaml:/app/configs/ops-config.yaml:ro
|
||||||
|
- ./ops-config.local.yaml:/app/configs/ops-config.local.yaml:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
pgbouncer:
|
pgbouncer:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -36,11 +36,22 @@ sudo k3s ctr images import images.tar
|
|||||||
- `SCHEDULER_INTERNAL_METRICS_TOKEN`
|
- `SCHEDULER_INTERNAL_METRICS_TOKEN`
|
||||||
- `BROWSER_FETCH_TOKEN`
|
- `BROWSER_FETCH_TOKEN`
|
||||||
- `POSTGRES_PASSWORD` / `RABBITMQ_DEFAULT_PASS` / `MINIO_ROOT_*`
|
- `POSTGRES_PASSWORD` / `RABBITMQ_DEFAULT_PASS` / `MINIO_ROOT_*`
|
||||||
|
- `config.local.yaml` 里的 `auth.password_cipher.private_key_pem`
|
||||||
|
- `ops-config.local.yaml` 里的 `auth.password_cipher.private_key_pem`
|
||||||
- `config/config.yaml` 里的 LLM / SiliconFlow API key
|
- `config/config.yaml` 里的 LLM / SiliconFlow API key
|
||||||
- `config.local.yaml` 里对应的数据库、RabbitMQ、MinIO、JWT 配置
|
- `config.local.yaml` 里对应的数据库、RabbitMQ、MinIO、JWT 配置
|
||||||
|
|
||||||
注意:应用运行时通过 `pgbouncer` / `monitoring-pgbouncer` 访问数据库;`migrate` 任务仍然直连 `postgres` / `monitoring-postgres`。数据库密码统一来自 `POSTGRES_PASSWORD` secret,应用配置和 PgBouncer 都会跟随这个 secret。
|
注意:应用运行时通过 `pgbouncer` / `monitoring-pgbouncer` 访问数据库;`migrate` 任务仍然直连 `postgres` / `monitoring-postgres`。数据库密码统一来自 `POSTGRES_PASSWORD` secret,应用配置和 PgBouncer 都会跟随这个 secret。
|
||||||
|
|
||||||
|
登录密码加密使用 RSA-OAEP-SHA256。多 Pod 部署时,同一类服务的所有副本必须读取同一把私钥和同一个 `key_id`,否则某个 Pod 下发的公钥对应密文可能被另一个 Pod 解不开。建议租户端和 ops 端分别生成不同私钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out login-password-rsa.pem
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ops-login-password-rsa.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
然后把完整 PEM 分别粘贴到 `deploy/k3s/secrets.yaml` 的 `config.local.yaml` 和 `ops-config.local.yaml`。
|
||||||
|
|
||||||
## 3. 部署
|
## 3. 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -556,6 +556,11 @@ spec:
|
|||||||
items:
|
items:
|
||||||
- key: ops-config.yaml
|
- key: ops-config.yaml
|
||||||
path: ops-config.yaml
|
path: ops-config.yaml
|
||||||
|
- secret:
|
||||||
|
name: geo-rankly-app-secret
|
||||||
|
items:
|
||||||
|
- key: ops-config.local.yaml
|
||||||
|
path: ops-config.local.yaml
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: release
|
mode: release
|
||||||
|
# 生产环境请配置为真实前端域名,例如 https://app.example.com。
|
||||||
|
# 留空时沿用开发环境 CORS 放开策略。
|
||||||
|
allowed_origins: []
|
||||||
|
security_headers:
|
||||||
|
enabled: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
- ::1
|
- ::1
|
||||||
@@ -30,6 +35,47 @@ monitoring_database:
|
|||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
url: amqp://geo:geo_dev@rabbitmq:5672/geo
|
url: amqp://geo:geo_dev@rabbitmq:5672/geo
|
||||||
|
monitor_result_exchange: monitor.result
|
||||||
|
monitor_result_routing_key: monitor.result.ingest
|
||||||
|
monitor_result_queue: monitor.result.ingest
|
||||||
|
monitor_result_dlx: monitor.result.dlx
|
||||||
|
monitor_result_dlq: monitor.result.ingest.dlq
|
||||||
|
monitor_result_dlq_routing_key: monitor.result.ingest.dlq
|
||||||
|
monitor_projection_exchange: monitor.projection
|
||||||
|
monitor_projection_routing_key: monitor.projection.rebuild
|
||||||
|
monitor_projection_queue: monitor.projection.rebuild
|
||||||
|
monitor_projection_dlx: monitor.projection.dlx
|
||||||
|
monitor_projection_dlq: monitor.projection.rebuild.dlq
|
||||||
|
monitor_projection_dlq_routing_key: monitor.projection.rebuild.dlq
|
||||||
|
generation_exchange: generation.task
|
||||||
|
generation_routing_key: generation.task.run
|
||||||
|
generation_queue: generation.task.run
|
||||||
|
generation_dlx: generation.task.dlx
|
||||||
|
generation_dlq: generation.task.run.dlq
|
||||||
|
generation_dlq_routing_key: generation.task.run.dlq
|
||||||
|
generation_event_exchange: generation.event
|
||||||
|
desktop_task_event_exchange: desktop.task.event
|
||||||
|
desktop_dispatch_exchange: desktop.task.dispatch
|
||||||
|
desktop_dispatch_publish_prefix: publish
|
||||||
|
desktop_dispatch_monitor_prefix: monitor
|
||||||
|
desktop_account_health_exchange: desktop.account.health
|
||||||
|
desktop_account_health_routing_key: desktop.account.health.report
|
||||||
|
desktop_account_health_queue: desktop.account.health.report
|
||||||
|
desktop_account_health_dlx: desktop.account.health.dlx
|
||||||
|
desktop_account_health_dlq: desktop.account.health.report.dlq
|
||||||
|
desktop_account_health_dlq_routing_key: desktop.account.health.report.dlq
|
||||||
|
template_assist_exchange: template.assist
|
||||||
|
template_assist_routing_key: template.assist.run
|
||||||
|
template_assist_queue: template.assist.run
|
||||||
|
template_assist_dlx: template.assist.dlx
|
||||||
|
template_assist_dlq: template.assist.run.dlq
|
||||||
|
template_assist_dlq_routing_key: template.assist.run.dlq
|
||||||
|
kol_assist_exchange: kol.assist
|
||||||
|
kol_assist_routing_key: kol.assist.run
|
||||||
|
kol_assist_queue: kol.assist.run
|
||||||
|
kol_assist_dlx: kol.assist.dlx
|
||||||
|
kol_assist_dlq: kol.assist.run.dlq
|
||||||
|
kol_assist_dlq_routing_key: kol.assist.run.dlq
|
||||||
compliance_review_exchange: compliance.review
|
compliance_review_exchange: compliance.review
|
||||||
compliance_review_routing_key: compliance.review.run
|
compliance_review_routing_key: compliance.review.run
|
||||||
compliance_review_queue: compliance.review.run
|
compliance_review_queue: compliance.review.run
|
||||||
@@ -53,6 +99,9 @@ monitoring_workers:
|
|||||||
result_ingest_concurrency: 4
|
result_ingest_concurrency: 4
|
||||||
projection_rebuild_concurrency: 2
|
projection_rebuild_concurrency: 2
|
||||||
|
|
||||||
|
monitoring_dispatch:
|
||||||
|
desktop_tasks_rollout_percentage: 5
|
||||||
|
|
||||||
membership:
|
membership:
|
||||||
default_plan_code: free
|
default_plan_code: free
|
||||||
plans:
|
plans:
|
||||||
@@ -96,7 +145,6 @@ brand_library:
|
|||||||
max_keywords: 5
|
max_keywords: 5
|
||||||
max_questions_per_keyword: 5
|
max_questions_per_keyword: 5
|
||||||
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
addr: redis:6379
|
addr: redis:6379
|
||||||
db: 0
|
db: 0
|
||||||
@@ -126,6 +174,16 @@ jwt:
|
|||||||
access_ttl: 15m
|
access_ttl: 15m
|
||||||
refresh_ttl: 720h
|
refresh_ttl: 720h
|
||||||
|
|
||||||
|
auth:
|
||||||
|
login_guard:
|
||||||
|
enabled: true
|
||||||
|
password_cipher:
|
||||||
|
# 留空 private_key_pem 时会在单进程内生成临时密钥;生产多 Pod 请在
|
||||||
|
# geo-rankly-app-secret/config.local.yaml 中配置同一把私钥。
|
||||||
|
enabled: true
|
||||||
|
key_id: login-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: info,warn,error
|
level: info,warn,error
|
||||||
format: json
|
format: json
|
||||||
@@ -182,14 +240,16 @@ browser_fetch:
|
|||||||
enabled: true
|
enabled: true
|
||||||
provider: lightpanda
|
provider: lightpanda
|
||||||
service_url: http://browser-fetch:8082
|
service_url: http://browser-fetch:8082
|
||||||
|
token: dev-browser-fetch-token
|
||||||
queue_size: 64
|
queue_size: 64
|
||||||
worker_concurrency: 2
|
worker_concurrency: 3
|
||||||
request_timeout: 45s
|
request_timeout: 45s
|
||||||
cache_ttl: 6h
|
cache_ttl: 6h
|
||||||
allowed_domains:
|
allowed_domains:
|
||||||
- zhuanlan.zhihu.com
|
- zhuanlan.zhihu.com
|
||||||
- zhihu.com
|
- zhihu.com
|
||||||
- baidu.com
|
- baidu.com
|
||||||
|
- weixin.sogou.com
|
||||||
trigger_status:
|
trigger_status:
|
||||||
- 403
|
- 403
|
||||||
- 429
|
- 429
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
server:
|
server:
|
||||||
port: 8090
|
port: 8090
|
||||||
mode: release
|
mode: release
|
||||||
|
# 生产环境请配置为真实运维后台域名,例如 https://ops.example.com。
|
||||||
|
# 留空时沿用开发环境 CORS 放开策略。
|
||||||
|
allowed_origins: []
|
||||||
|
security_headers:
|
||||||
|
enabled: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
- ::1
|
- ::1
|
||||||
@@ -43,6 +48,14 @@ jwt:
|
|||||||
secret: "geo-ops-15645415841"
|
secret: "geo-ops-15645415841"
|
||||||
access_ttl: 8h
|
access_ttl: 8h
|
||||||
|
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
# 留空 private_key_pem 时会在单进程内生成临时密钥;生产多 Pod 请在
|
||||||
|
# geo-rankly-app-secret/ops-config.local.yaml 中配置同一把私钥。
|
||||||
|
enabled: true
|
||||||
|
key_id: ops-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
default_admin:
|
default_admin:
|
||||||
username: admin
|
username: admin
|
||||||
display_name: system-admin
|
display_name: system-admin
|
||||||
|
|||||||
@@ -35,3 +35,25 @@ stringData:
|
|||||||
|
|
||||||
browser_fetch:
|
browser_fetch:
|
||||||
token: ${BROWSER_FETCH_TOKEN:geo-browser-fetch-change-me}
|
token: ${BROWSER_FETCH_TOKEN:geo-browser-fetch-change-me}
|
||||||
|
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
enabled: true
|
||||||
|
key_id: login-password-rsa-oaep-v1
|
||||||
|
# 多 Pod 必须使用同一把私钥;上线前用 `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`
|
||||||
|
# 生成后粘贴完整 PEM。不要把生产私钥提交到代码仓库。
|
||||||
|
private_key_pem: |
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
REPLACE_WITH_TENANT_LOGIN_PASSWORD_RSA_PRIVATE_KEY
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
|
||||||
|
ops-config.local.yaml: |
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
enabled: true
|
||||||
|
key_id: ops-password-rsa-oaep-v1
|
||||||
|
# 建议与租户端使用不同私钥;所有 ops-api Pod 必须挂载同一份 Secret。
|
||||||
|
private_key_pem: |
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
REPLACE_WITH_OPS_LOGIN_PASSWORD_RSA_PRIVATE_KEY
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
enabled: true
|
||||||
|
key_id: ops-password-rsa-oaep-v1
|
||||||
|
# 建议与租户端使用不同私钥。复制本文件为 ops-config.local.yaml,
|
||||||
|
# 所有 ops-api 副本必须读取同一份私钥;不要提交生产私钥。
|
||||||
|
# 留空时应用会生成单进程临时私钥,只适合本机单副本试跑。
|
||||||
|
private_key_pem: ""
|
||||||
@@ -43,6 +43,13 @@ jwt:
|
|||||||
secret: "geo-ops-15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供
|
secret: "geo-ops-15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供
|
||||||
access_ttl: 8h
|
access_ttl: 8h
|
||||||
|
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
# 生产多实例请在 ops-config.local.yaml 中配置同一把 private_key_pem。
|
||||||
|
enabled: true
|
||||||
|
key_id: ops-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
default_admin:
|
default_admin:
|
||||||
username: admin
|
username: admin
|
||||||
display_name: 系统管理员
|
display_name: 系统管理员
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then
|
|||||||
echo " 如需填写真实 API Key 或修改端口,可编辑 ${SCRIPT_DIR}/.env 后再次执行 bash deploy.sh"
|
echo " 如需填写真实 API Key 或修改端口,可编辑 ${SCRIPT_DIR}/.env 后再次执行 bash deploy.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_local_config() {
|
||||||
|
local file="$1" example="$2"
|
||||||
|
if [[ -f "${SCRIPT_DIR}/${file}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -f "${SCRIPT_DIR}/${example}" ]]; then
|
||||||
|
cp "${SCRIPT_DIR}/${example}" "${SCRIPT_DIR}/${file}"
|
||||||
|
echo "==> 已从 ${example} 创建 ${file}。"
|
||||||
|
echo " 生产多副本部署前请把其中的 password_cipher.private_key_pem 替换为同一把 RSA 私钥。"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
env_value() {
|
env_value() {
|
||||||
local key="$1" default_value="$2" value
|
local key="$1" default_value="$2" value
|
||||||
value="$(grep -E "^${key}=" "${SCRIPT_DIR}/.env" | tail -1 | cut -d= -f2- || true)"
|
value="$(grep -E "^${key}=" "${SCRIPT_DIR}/.env" | tail -1 | cut -d= -f2- || true)"
|
||||||
@@ -34,6 +46,9 @@ env_value() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deploy_compose() {
|
deploy_compose() {
|
||||||
|
ensure_local_config "config.local.yaml" "config.local.yaml.example"
|
||||||
|
ensure_local_config "ops-config.local.yaml" "ops-config.local.yaml.example"
|
||||||
|
|
||||||
# ─── Step 1: 加载镜像 ───────────────────────────────────────────────────────
|
# ─── Step 1: 加载镜像 ───────────────────────────────────────────────────────
|
||||||
echo "==> [1/3] 加载 Docker 镜像(可能需要几分钟)..."
|
echo "==> [1/3] 加载 Docker 镜像(可能需要几分钟)..."
|
||||||
docker load -i "${SCRIPT_DIR}/images.tar"
|
docker load -i "${SCRIPT_DIR}/images.tar"
|
||||||
|
|||||||
@@ -268,10 +268,12 @@ func RestartRequired(changes []FieldChange) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load(path string) (*Config, error) {
|
func load(path string) (*Config, error) {
|
||||||
resolved := configruntime.New(configruntime.WithSource(
|
sources := []configruntime.Source{runtimefile.NewSource(path)}
|
||||||
runtimefile.NewSource(path),
|
if localPath := localOverridePath(path); localPath != "" {
|
||||||
runtimeenv.NewSource("OPS"),
|
sources = append(sources, runtimefile.NewSource(localPath))
|
||||||
))
|
}
|
||||||
|
sources = append(sources, runtimeenv.NewSource("OPS"))
|
||||||
|
resolved := configruntime.New(configruntime.WithSource(sources...))
|
||||||
defer resolved.Close()
|
defer resolved.Close()
|
||||||
|
|
||||||
if err := resolved.Load(); err != nil {
|
if err := resolved.Load(); err != nil {
|
||||||
@@ -571,12 +573,37 @@ func watchFiles(path string) []string {
|
|||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []string{path}
|
files := []string{path}
|
||||||
|
if localPath := localOverridePath(path); localPath != "" {
|
||||||
|
files = append(files, localPath)
|
||||||
|
}
|
||||||
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchNames(path string) []string {
|
func watchNames(path string) []string {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []string{filepath.Base(path)}
|
names := []string{filepath.Base(path)}
|
||||||
|
if localPath := localOverridePath(path); localPath != "" {
|
||||||
|
names = append(names, filepath.Base(localPath))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func localOverridePath(path string) string {
|
||||||
|
trimmed := strings.TrimSpace(path)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(trimmed)
|
||||||
|
base := strings.TrimSuffix(trimmed, ext)
|
||||||
|
if strings.HasSuffix(base, ".local") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
localPath := base + ".local" + ext
|
||||||
|
if _, err := os.Stat(localPath); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return localPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := normalizePrivateKeyPEM(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return nil, fmt.Errorf("password cipher private key is required")
|
return nil, fmt.Errorf("password cipher private key is required")
|
||||||
}
|
}
|
||||||
@@ -126,3 +126,48 @@ func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
|||||||
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
|
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizePrivateKeyPEM(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := strings.ReplaceAll(trimmed, "\r\n", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||||
|
normalized = strings.ReplaceAll(normalized, `\n`, "\n")
|
||||||
|
if block, _ := pem.Decode([]byte(normalized)); block != nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, blockType := range []string{"RSA PRIVATE KEY", "PRIVATE KEY"} {
|
||||||
|
begin := "-----BEGIN " + blockType + "-----"
|
||||||
|
end := "-----END " + blockType + "-----"
|
||||||
|
start := strings.Index(normalized, begin)
|
||||||
|
stop := strings.Index(normalized, end)
|
||||||
|
if start < 0 || stop <= start {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := normalized[start+len(begin) : stop]
|
||||||
|
body = strings.Join(strings.Fields(body), "")
|
||||||
|
if body == "" {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return begin + "\n" + wrapBase64Lines(body, 64) + "\n" + end + "\n"
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapBase64Lines(value string, width int) string {
|
||||||
|
if width <= 0 || len(value) <= width {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(value) + len(value)/width)
|
||||||
|
for len(value) > width {
|
||||||
|
b.WriteString(value[:width])
|
||||||
|
b.WriteByte('\n')
|
||||||
|
value = value[width:]
|
||||||
|
}
|
||||||
|
b.WriteString(value)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -62,3 +63,53 @@ func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) {
|
|||||||
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
|
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
|
||||||
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
|
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordCipherAcceptsFoldedPrivateKeyPEM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "PRIVATE KEY",
|
||||||
|
Bytes: mustMarshalPKCS8PrivateKey(t, key),
|
||||||
|
}))
|
||||||
|
foldedPEM := strings.Join(strings.Fields(privatePEM), " ")
|
||||||
|
|
||||||
|
_, err = NewPasswordCipher(foldedPEM, "folded")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordCipherAcceptsEscapedNewlinePrivateKeyPEM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "PRIVATE KEY",
|
||||||
|
Bytes: mustMarshalPKCS8PrivateKey(t, key),
|
||||||
|
}))
|
||||||
|
escapedPEM := strings.ReplaceAll(strings.TrimSpace(privatePEM), "\n", `\n`)
|
||||||
|
|
||||||
|
_, err = NewPasswordCipher(escapedPEM, "escaped")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePrivateKeyPEMPreservesValidPEM(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "PRIVATE KEY",
|
||||||
|
Bytes: mustMarshalPKCS8PrivateKey(t, key),
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Equal(t, strings.TrimSpace(privatePEM), strings.TrimSpace(normalizePrivateKeyPEM(privatePEM)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalPKCS8PrivateKey(t *testing.T, key *rsa.PrivateKey) []byte {
|
||||||
|
t.Helper()
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return der
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user