Skip to content

Commit 8775ef4

Browse files
authored
fix(auth): bind internal JWTs to principal secret generation (#5053)
1 parent b42f931 commit 8775ef4

8 files changed

Lines changed: 624 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
7171
and a subsequent `bootstrap` would create a second, empty set of tables in the other schema.
7272
Either remove the setting from the URL, or point it at the schema that already holds your
7373
Polaris tables.
74+
- Internal JWTs minted before credentials-generation binding (tokens without the `polaris-cv` claim) can no longer be used as subject tokens in token exchange; they remain valid as bearer tokens until expiry. During a rolling upgrade, an old node may still mint claim-less tokens: exchanging such a token on any already-upgraded node fails with `invalid_grant`, so clients can see intermittent exchange failures until the last old node is gone; after that, rejection is consistent.
7475

7576
### New Features
7677

@@ -148,6 +149,9 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
148149
directly under an allowed location, at `s3://b1/ns`, and rejected it as a custom location even
149150
though the request asked for none. The namespace location is now compared against the
150151
catalog's `default-base-location`, which is what it is derived from.
152+
- Internal JWTs are bound to principal secret generation via `polaris-cv` (no secret material in the
153+
token). Credential-generation is enforced on token exchange; bearer verify is signature and claims
154+
only. Secrets-load failures during exchange return service unavailable.
151155

152156
### Commits
153157

polaris-core/src/main/java/org/apache/polaris/core/DigestUtils.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import com.google.common.hash.Hashing;
2222
import java.nio.charset.StandardCharsets;
23+
import java.security.MessageDigest;
24+
import java.util.Objects;
2325

2426
public final class DigestUtils {
2527
private DigestUtils() {
@@ -29,4 +31,14 @@ private DigestUtils() {
2931
public static String sha256Hex(String input) {
3032
return Hashing.sha256().hashString(input, StandardCharsets.UTF_8).toString();
3133
}
34+
35+
/**
36+
* Constant-time equality check for secret material. Both arguments must be non-null; callers are
37+
* expected to guard nullable inputs before comparing.
38+
*/
39+
public static boolean constantTimeEquals(String a, String b) {
40+
return MessageDigest.isEqual(
41+
Objects.requireNonNull(a).getBytes(StandardCharsets.UTF_8),
42+
Objects.requireNonNull(b).getBytes(StandardCharsets.UTF_8));
43+
}
3244
}

polaris-core/src/main/java/org/apache/polaris/core/entity/PolarisPrincipalSecrets.java

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.fasterxml.jackson.annotation.JsonCreator;
2222
import com.fasterxml.jackson.annotation.JsonProperty;
2323
import java.security.SecureRandom;
24+
import java.util.Optional;
2425
import org.apache.polaris.core.DigestUtils;
2526
import org.jspecify.annotations.Nullable;
2627

@@ -167,10 +168,45 @@ public String getPrincipalClientId() {
167168
return principalClientId;
168169
}
169170

171+
/**
172+
* @deprecated no longer used for authentication; kept for compatibility and will be removed in a
173+
* future release. Use {@link #getCredentialsVersionForSecret(String)} instead.
174+
*/
175+
@Deprecated
170176
public boolean matchesSecret(String potentialSecret) {
177+
return getCredentialsVersionForSecret(potentialSecret).isPresent();
178+
}
179+
180+
/**
181+
* Credentials-generation fingerprint corresponding to the secret that matches {@code
182+
* potentialSecret}: the main generation when it matches the main secret hash, the secondary
183+
* generation when it matches the secondary secret hash, or empty when it matches neither. Newly
184+
* minted tokens carry this fingerprint in the {@code polaris-cv} claim. That claim gates
185+
* <em>token-exchange</em> eligibility against the current (and secondary) generation; bearer
186+
* verify checks only the JWT signature and claims, so a token remains usable as a bearer until
187+
* JWT expiry even after its generation is no longer current.
188+
*
189+
* <p>Comparisons are constant-time, as in {@link #matchesCredentialsVersion(String)}.
190+
*/
191+
public Optional<String> getCredentialsVersionForSecret(String potentialSecret) {
171192
String potentialSecretHash = hashSecret(potentialSecret);
172-
return potentialSecretHash.equals(this.mainSecretHash)
173-
|| potentialSecretHash.equals(this.secondarySecretHash);
193+
Optional<String> mainVersion = credentialsVersionForHash(mainSecretHash);
194+
Optional<String> secondaryVersion = credentialsVersionForHash(secondarySecretHash);
195+
// Always compare against both hashes and combine the flags, so the amount of comparison work
196+
// does not reveal which generation matched.
197+
boolean matchesMain =
198+
mainSecretHash != null
199+
&& DigestUtils.constantTimeEquals(potentialSecretHash, mainSecretHash);
200+
boolean matchesSecondary =
201+
secondarySecretHash != null
202+
&& DigestUtils.constantTimeEquals(potentialSecretHash, secondarySecretHash);
203+
if (matchesMain) {
204+
return mainVersion;
205+
}
206+
if (matchesSecondary) {
207+
return secondaryVersion;
208+
}
209+
return Optional.empty();
174210
}
175211

176212
public String getMainSecret() {
@@ -185,6 +221,54 @@ public String getMainSecretHash() {
185221
return mainSecretHash;
186222
}
187223

224+
/**
225+
* Credentials-generation fingerprint for tokens minted against the <em>current</em> main secret.
226+
* Derived from existing fields with the principal's secret salt, so no schema change is required.
227+
* The value is not itself a credential and is safe to embed in signed (but not encrypted) JWTs.
228+
*
229+
* @return the main credentials version; never {@code null} since the main secret hash is always
230+
* set by the constructors
231+
*/
232+
public String getCredentialsVersion() {
233+
return credentialsVersionForHash(mainSecretHash).orElseThrow();
234+
}
235+
236+
/**
237+
* Returns true when {@code credentialsVersion} matches the salted fingerprint of the current main
238+
* secret hash <em>or</em> the secondary secret hash. After a single rotate, the previous main
239+
* hash is kept as secondary so client secrets and bound JWTs both remain valid for that
240+
* generation; a further rotate/reset advances secondary and invalidates the older fingerprint.
241+
*
242+
* <p>Comparisons are constant-time against each candidate fingerprint.
243+
*/
244+
public boolean matchesCredentialsVersion(@Nullable String credentialsVersion) {
245+
if (credentialsVersion == null || credentialsVersion.isEmpty()) {
246+
return false;
247+
}
248+
boolean matches = false;
249+
Optional<String> mainVersion = credentialsVersionForHash(mainSecretHash);
250+
if (mainVersion.isPresent()) {
251+
matches |= DigestUtils.constantTimeEquals(credentialsVersion, mainVersion.get());
252+
}
253+
Optional<String> secondaryVersion = credentialsVersionForHash(secondarySecretHash);
254+
if (secondaryVersion.isPresent()) {
255+
matches |= DigestUtils.constantTimeEquals(credentialsVersion, secondaryVersion.get());
256+
}
257+
return matches;
258+
}
259+
260+
/**
261+
* Salted, non-reversible fingerprint of a secret-verification hash for embedding in JWTs. Uses
262+
* the same per-principal {@link #secretSalt} already stored for secret hashing, which is always
263+
* set by the constructors.
264+
*/
265+
private Optional<String> credentialsVersionForHash(@Nullable String secretHash) {
266+
if (secretHash == null || secretHash.isEmpty()) {
267+
return Optional.empty();
268+
}
269+
return Optional.of(DigestUtils.sha256Hex(secretHash + ":" + secretSalt));
270+
}
271+
188272
public String getSecondarySecretHash() {
189273
return secondarySecretHash;
190274
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.polaris.core;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
23+
24+
import org.junit.jupiter.api.Test;
25+
26+
public class DigestUtilsTest {
27+
28+
@Test
29+
void constantTimeEquals() {
30+
assertThat(DigestUtils.constantTimeEquals("abc", "abc")).isTrue();
31+
assertThat(DigestUtils.constantTimeEquals("abc", "abd")).isFalse();
32+
assertThat(DigestUtils.constantTimeEquals("abc", "abcd")).isFalse();
33+
assertThat(DigestUtils.constantTimeEquals("", "")).isTrue();
34+
}
35+
36+
@Test
37+
void constantTimeEqualsRejectsNull() {
38+
assertThatThrownBy(() -> DigestUtils.constantTimeEquals(null, "a"))
39+
.isInstanceOf(NullPointerException.class);
40+
assertThatThrownBy(() -> DigestUtils.constantTimeEquals("a", null))
41+
.isInstanceOf(NullPointerException.class);
42+
assertThatThrownBy(() -> DigestUtils.constantTimeEquals(null, null))
43+
.isInstanceOf(NullPointerException.class);
44+
}
45+
}

0 commit comments

Comments
 (0)