diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml index 915557415b..55a9045141 100644 --- a/.github/workflows/docker-build-ci.yml +++ b/.github/workflows/docker-build-ci.yml @@ -93,9 +93,22 @@ jobs: timeout-minutes: 25 steps: - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + java-version: '17' + distribution: 'zulu' + cache: maven + cache-jdk: false + - name: Build the pinned configuration reader + run: | + mvn -B -ntp -pl hugegraph-commons/hugegraph-common -am clean compile dependency:build-classpath \ + -Dmdep.outputFile=target/config-readback-classpath.txt -Dmaven.javadoc.skip=true -DskipTests - name: Verify deployment shell contracts run: | bash hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh + HG_CONFIG_TEST_CLASSPATH="$PWD/hugegraph-commons/hugegraph-common/target/classes" + HG_CONFIG_TEST_CLASSPATH+=":$(cat hugegraph-commons/hugegraph-common/target/config-readback-classpath.txt)" + export HG_CONFIG_TEST_CLASSPATH bash hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh bash hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-port.sh \ hugegraph-server/hugegraph-dist/src/assembly/static \ diff --git a/docker/README.md b/docker/README.md index df530d6cae..66caeb1ad7 100644 --- a/docker/README.md +++ b/docker/README.md @@ -50,6 +50,27 @@ A non-empty `HUGEGRAPH_ADMIN_PASSWORD` enables Server authentication, and Hubble `HUGEGRAPH_ADMIN_PASSWORD` initializes the built-in `admin` account on its first authenticated startup. Changing `.env` does not rotate an existing administrator password; use the HugeGraph user API for credential changes. +The Server image writes `PASSWORD` to `auth.admin_pa` in the REST configuration. +It writes `HG_SERVER_AUTH_TOKEN_SECRET` to `auth.token_secret` in both REST and +graph configuration. An explicit signing key must be at least 32 bytes. Authentication +string values retain spaces, tabs, backslashes and Unicode through the Server's +properties reader; keep the same signing key on every replica. Use passwords +compatible with the existing authentication protocol and the chart's documented +password constraints. When the signing-key environment variable is omitted, +the image reuses the configured key instead of rotating it. + +Older images could change signing keys containing spaces or Unicode, and a +trailing space could hide the following configuration property. On upgrade, +explicitly provide the intended key for those deployments. Tokens signed with +the previous interpreted key require a new login after the correction. + +An older image could also initialize a Unicode administrator password incorrectly +when the initializer inherited the C locale. Upgrading or setting `PASSWORD` +again does not repair an existing account's stored password. A working +administrator must rotate it through the user API. This fix provides no automatic +recovery when no administrator identity is usable; preserve the existing data and +arrange account recovery before upgrading. + For the verification commands below, load `.env` into your current shell and set the password: ```bash diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh index e6823e897e..1cfa7915a6 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh @@ -44,6 +44,7 @@ EOF cat > "${TEST_HOME}/bin/init-store.sh" <<'EOF' #!/usr/bin/env bash printf 'called\n' >> ./docker/init-store-calls +printf '%s' "${LC_ALL-}" > ./docker/init-store-locale if IFS= read -r password; then printf '%s' "${password}" > ./docker/init-store-password fi @@ -67,6 +68,29 @@ exit 0 EOF chmod +x "${TEST_HOME}/bin/"*.sh +# CI supplies current HugeConfig and the POM-pinned Commons dependency classpath. +if [[ -n "${HG_CONFIG_TEST_CLASSPATH:-}" ]]; then + javac -cp "${HG_CONFIG_TEST_CLASSPATH}" -d "${TEST_HOME}/docker" \ + "${SCRIPT_DIR}/../../hugegraph-core/src/main/java/org/apache/hugegraph/config/AuthOptions.java" \ + "${SCRIPT_DIR}/test/AuthConfigReadback.java" +else + echo "SKIP: native auth readback (HG_CONFIG_TEST_CLASSPATH is unset)" +fi +assert_auth_readback() { + if [[ -n "${HG_CONFIG_TEST_CLASSPATH:-}" ]]; then + local secret_base64 admin_base64 + secret_base64=$(printf '%s' "$2" | base64 | tr -d '\n') + if [[ $# -eq 3 ]]; then + admin_base64=$(printf '%s' "$3" | base64 | tr -d '\n') + java -Xmx128m -cp "${TEST_HOME}/docker:${HG_CONFIG_TEST_CLASSPATH}" AuthConfigReadback \ + "$1" "${secret_base64}" "${admin_base64}" + else + java -Xmx128m -cp "${TEST_HOME}/docker:${HG_CONFIG_TEST_CLASSPATH}" AuthConfigReadback \ + "$1" "${secret_base64}" + fi + fi +} + ( cd "${TEST_HOME}" HG_SERVER_BACKEND=hstore \ @@ -205,7 +229,7 @@ reused_complex_secret=$(sed -n 's/^auth\.token_secret=//p' \ "${TEST_HOME}/conf/rest-server.properties") [[ "${reused_complex_secret}" == "${complex_secret}" ]] [[ "${reused_complex_secret}" == \ - 'Strong\\Secret\ 9!0123456789abcdef' ]] + 'Strong\\Secret\u00209!0123456789abcdef' ]] ( cd "${TEST_HOME}" @@ -222,7 +246,7 @@ trailing_space_secret=$(sed -n 's/^auth\.token_secret=//p' \ reused_trailing_space_secret=$(sed -n 's/^auth\.token_secret=//p' \ "${TEST_HOME}/conf/rest-server.properties") [[ "${trailing_space_secret}" == \ - 'SecretEnds\ 0123456789abcdefABCDE\ ' ]] + 'SecretEnds\u00200123456789abcdefABCDE\u0020' ]] [[ "${reused_trailing_space_secret}" == "${trailing_space_secret}" ]] grep -Fqx 'auth.admin_pa=pa' \ "${TEST_HOME}/conf/rest-server.properties" @@ -231,7 +255,7 @@ grep -Fqx 'auth.admin_pa=pa' \ cd "${TEST_HOME}" PASSWORD='Strong\Pass 9!' bash ./docker-entrypoint.sh ) -grep -Fqx 'auth.admin_pa=Strong\\Pass\ 9!' \ +grep -Fqx 'auth.admin_pa=Strong\\Pass\u00209!' \ "${TEST_HOME}/conf/rest-server.properties" rm -f "${TEST_HOME}/docker/init_complete" @@ -329,5 +353,40 @@ done ) assert_start_timeout 120 +# Signing keys are opaque strings: both files must load exactly what the caller supplied. +fake_key='FAKE_SIGNING_KEY_0123456789abcdef' +control_soh=$(printf '\001') +control_us=$(printf '\037') +min_key="${fake_key:0:31}${control_soh}" +[[ ${#min_key} -eq 32 ]] +auth_secrets=("${fake_key}" " ${fake_key}" "${fake_key} inner" "${fake_key} " + "${fake_key}"$'\t' "${fake_key}\\" "${fake_key}cafΓ©" "${fake_key}δΈ­ζ–‡" "${fake_key}πŸ˜€" + "${fake_key}\\u0020πŸ˜€ cafΓ© " "${fake_key}"$'\377' + "${control_soh}${fake_key}" "${fake_key}${control_soh}" "${fake_key}"$'\v' "${fake_key}${control_us}" + "${min_key}") +fake_admin_password="${control_soh}"' fake\ admin '"${control_us}" +for auth_secret in "${auth_secrets[@]}"; do + cp "${SCRIPT_DIR}/../src/assembly/static/conf/rest-server.properties" \ + "${TEST_HOME}/conf/rest-server.properties" + (cd "${TEST_HOME}"; PASSWORD="${fake_admin_password}" HG_SERVER_AUTH_TOKEN_SECRET="${auth_secret}" \ + bash ./docker-entrypoint.sh) + expected_secret="${auth_secret}" + if [[ "${auth_secret}" == "${fake_key}"$'\377' ]]; then + expected_secret="${fake_key}ΓΏ" + fi + assert_auth_readback "${TEST_HOME}/conf/rest-server.properties" "${expected_secret}" "${fake_admin_password}" + assert_auth_readback "${TEST_HOME}/conf/graphs/hugegraph.properties" "${expected_secret}" + cp "${TEST_HOME}/conf/rest-server.properties" "${TEST_HOME}/conf/rest-server.properties.before-reuse" + cp "${TEST_HOME}/conf/graphs/hugegraph.properties" "${TEST_HOME}/conf/graphs/hugegraph.properties.before-reuse" + (cd "${TEST_HOME}"; PASSWORD="${fake_admin_password}" bash ./docker-entrypoint.sh) + cmp "${TEST_HOME}/conf/rest-server.properties.before-reuse" "${TEST_HOME}/conf/rest-server.properties" + cmp "${TEST_HOME}/conf/graphs/hugegraph.properties.before-reuse" "${TEST_HOME}/conf/graphs/hugegraph.properties" + assert_auth_readback "${TEST_HOME}/conf/rest-server.properties" "${expected_secret}" "${fake_admin_password}" +done + +(cd "${TEST_HOME}"; LC_ALL=en_US.UTF-8 PASSWORD='locale probe' \ + HG_SERVER_AUTH_TOKEN_SECRET="${fake_key}" bash ./docker-entrypoint.sh) +[[ "$(cat "${TEST_HOME}/docker/init-store-locale")" == 'en_US.UTF-8' ]] + echo "PASS: Docker entrypoint configures HStore discovery and authentication" bash "${SCRIPT_DIR}/test/test-docker-entrypoint-process.sh" diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh index 0e3cc4eab2..5b804e5ba8 100755 --- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh +++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh @@ -26,18 +26,13 @@ mkdir -p "${DOCKER_FOLDER}" log() { echo "[hugegraph-server-entrypoint] $*"; } -# TODO: write values so the Server reads them back unchanged. A space is escaped as "\ " here, -# which java.util.Properties would unescape, but the Server reads these files through Commons -# Configuration's PropertiesConfiguration, which keeps the backslash before a space: PASSWORD -# "a b" becomes auth.admin_pa "a\ b" and the admin login with "a b" answers 401. Emit interior -# spaces as-is (they round-trip) and add a write-then-read test through HugeConfig for the keys -# this script sets. The Helm chart (helm/hugegraph) refuses admin passwords containing spaces in -# its schema and Server wrapper; drop that guard once this is fixed. +# Keep typed/list mappings on their existing encoder; auth strings use native-reader escapes below. +# TODO: relax Helm's password-space guard once its minimum Server image includes this auth encoder. encode_prop_value() { local value="$1" encoded="" char local i - LC_ALL=C + local LC_ALL=C for ((i = 0; i < ${#value}; i++)); do char="${value:i:1}" case "${char}" in @@ -75,6 +70,24 @@ set_prop() { set_prop_encoded "$key" "$(encode_prop_value "$val")" "$file" } +# Auth values are strings, not typed/list options. Commons keeps escaped spaces +# and trims a physical trailing space before detecting line continuation. +encode_auth_prop_value() { + local value="$1" encoding=UTF-8 + if (( $(printf '%s' "${value}" | LC_ALL=C tr -d '\040-\176' | wc -c) > 0 )); then + # Retain legacy byte values that the ISO-8859-1 reader already accepted. + if ! printf '%s' "${value}" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then + encoding=ISO-8859-1 + fi + # The Server properties reader is ISO-8859-1. Standard UTF-16 escapes + # preserve UTF-8 input and prevent trimming boundary control characters. + printf '%s' "${value}" | iconv -f "${encoding}" -t UTF-16BE | od -An -v -tx1 | \ + tr -d ' \n' | sed 's/..../\\u&/g' + else + encode_prop_value "${value}" | sed 's/\\ /\\u0020/g' + fi +} + get_prop_encoded() { local key="$1" file="$2" local esc_key @@ -98,8 +111,7 @@ migrate_env "BACKEND" "HG_SERVER_BACKEND" migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS" if [[ -n "${HG_SERVER_AUTH_TOKEN_SECRET:-}" ]]; then - LC_ALL=C - if (( ${#HG_SERVER_AUTH_TOKEN_SECRET} < 32 )); then + if (( $(printf '%s' "${HG_SERVER_AUTH_TOKEN_SECRET}" | LC_ALL=C wc -c) < 32 )); then log "ERROR: HG_SERVER_AUTH_TOKEN_SECRET must be at least 32 bytes" exit 1 fi @@ -152,6 +164,15 @@ if [[ -n "${PASSWORD:-}" && -z "${HG_SERVER_AUTH_TOKEN_SECRET:-}" ]]; then fi fi +# Encode authentication strings before changing either configuration file. +if [[ -n "${HG_SERVER_AUTH_TOKEN_SECRET:-}" ]]; then + AUTH_TOKEN_SECRET_ENCODED=$(encode_auth_prop_value "${HG_SERVER_AUTH_TOKEN_SECRET}") +fi +AUTH_ADMIN_PASSWORD_ENCODED="" +if [[ -n "${PASSWORD:-}" ]]; then + AUTH_ADMIN_PASSWORD_ENCODED=$(encode_auth_prop_value "${PASSWORD}") +fi + # ── Map env β†’ properties file ───────────────────────────────────────── # TODO: map server.urls_to_pd and server.deploy_in_k8s from the environment here, as PASSWORD is # mapped to auth.admin_pa below. Without them the Helm chart (helm/hugegraph) rewrites @@ -170,18 +191,14 @@ fi "${HG_SERVER_REST_URL}" "${REST_SERVER_CONF}" [[ -n "${HG_SERVER_MIN_FREE_MEMORY:-}" ]] && set_prop "restserver.min_free_memory" \ "${HG_SERVER_MIN_FREE_MEMORY}" "${REST_SERVER_CONF}" -if [[ -n "${HG_SERVER_AUTH_TOKEN_SECRET:-}" ]]; then - set_prop "auth.token_secret" "${HG_SERVER_AUTH_TOKEN_SECRET}" \ - "${REST_SERVER_CONF}" - set_prop "auth.token_secret" "${HG_SERVER_AUTH_TOKEN_SECRET}" "${GRAPH_CONF}" -elif [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then +if [[ -n "${AUTH_TOKEN_SECRET_ENCODED}" ]]; then set_prop_encoded "auth.token_secret" "${AUTH_TOKEN_SECRET_ENCODED}" \ "${REST_SERVER_CONF}" set_prop_encoded "auth.token_secret" "${AUTH_TOKEN_SECRET_ENCODED}" \ "${GRAPH_CONF}" fi if [[ -n "${PASSWORD:-}" ]]; then - set_prop "auth.admin_pa" "${PASSWORD}" "${REST_SERVER_CONF}" + set_prop_encoded "auth.admin_pa" "${AUTH_ADMIN_PASSWORD_ENCODED}" "${REST_SERVER_CONF}" # This script is idempotent and must run outside the initialization guard: # an upgrade can preserve the marker from an unauthenticated deployment. ./bin/enable-auth.sh diff --git a/hugegraph-server/hugegraph-dist/docker/test/AuthConfigReadback.java b/hugegraph-server/hugegraph-dist/docker/test/AuthConfigReadback.java new file mode 100644 index 0000000000..c07791fcc8 --- /dev/null +++ b/hugegraph-server/hugegraph-dist/docker/test/AuthConfigReadback.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * 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. + */ + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; + +import org.apache.hugegraph.auth.TokenGenerator; +import org.apache.hugegraph.config.AuthOptions; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.OptionSpace; + +public final class AuthConfigReadback { + + public static void main(String[] args) { + OptionSpace.register("auth", AuthOptions.instance()); + HugeConfig config = new HugeConfig(args[0]); + String expected = new String(Base64.getDecoder().decode(args[1]), StandardCharsets.UTF_8); + String actual = config.get(AuthOptions.AUTH_TOKEN_SECRET); + if (!expected.equals(actual)) { + throw new AssertionError("Signing key mismatch: expected characters=" + expected.length() + + ", loaded characters=" + actual.length()); + } + String token = new TokenGenerator(expected).create(Collections.singletonMap("probe", "auth"), 60000L); + if (!"auth".equals(new TokenGenerator(actual).verify(token).get("probe"))) { + throw new AssertionError("Signing key verification mismatch"); + } + if (args.length == 3) { + String admin = new String(Base64.getDecoder().decode(args[2]), StandardCharsets.UTF_8); + if (!admin.equals(config.getProperty("auth.admin_pa"))) { + throw new AssertionError("Administrator password mismatch"); + } + } + System.out.println("PASS: exact authentication property readback; signing key characters=" + actual.length()); + } +}