aboutsummaryrefslogtreecommitdiffstats
path: root/security-utils/src/main/java/com
diff options
context:
space:
mode:
authorTor Brede Vekterli <vekterli@yahooinc.com>2023-06-06 13:18:47 +0200
committerTor Brede Vekterli <vekterli@yahooinc.com>2023-06-06 13:43:46 +0200
commit071d65629215994094b580861e06edb760958af2 (patch)
tree7b69400674b1abb5ee70f6570a56a8b0485ead72 /security-utils/src/main/java/com
parent212a1934ff38662183609827ac91a67a34179eb0 (diff)
Add a simple token primitive to security utils
A token is an arbitrary, opaque (secret) string from which a fingerprint and audience-specific access-check hashes can be derived. A CSPRNG-backed token generator that returns random Base62-encoded tokens (with an optional prefix) is included.
Diffstat (limited to 'security-utils/src/main/java/com')
-rw-r--r--security-utils/src/main/java/com/yahoo/security/token/Token.java85
-rw-r--r--security-utils/src/main/java/com/yahoo/security/token/TokenCheckHash.java46
-rw-r--r--security-utils/src/main/java/com/yahoo/security/token/TokenDomain.java51
-rw-r--r--security-utils/src/main/java/com/yahoo/security/token/TokenFingerprint.java52
-rw-r--r--security-utils/src/main/java/com/yahoo/security/token/TokenGenerator.java39
5 files changed, 273 insertions, 0 deletions
diff --git a/security-utils/src/main/java/com/yahoo/security/token/Token.java b/security-utils/src/main/java/com/yahoo/security/token/Token.java
new file mode 100644
index 00000000000..e830bdfd63d
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/token/Token.java
@@ -0,0 +1,85 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.token;
+
+import com.yahoo.security.HKDF;
+
+import java.util.Objects;
+
+import static com.yahoo.security.ArrayUtils.toUtf8Bytes;
+
+/**
+ * <p>A token represents an arbitrary, opaque sequence of secret bytes (preferably from a secure
+ * random source) whose possession gives the holder the right to some resource(s) or action(s).
+ * For a token to be recognized it must be presented in its entirety, i.e. bitwise exact. This
+ * includes any (optional) text prefixes.
+ * </p><p>
+ * Only the party <em>presenting</em> the token should store the token secret itself; any
+ * parties that need to identify and/or verify the token should store <em>derivations</em>
+ * of the token instead (TokenFingerprint and TokenCheckHash, respectively).
+ * </p><p>
+ * A Token <em>object</em> is bound to a particular TokenDomain, but any given secret token
+ * string may be used to create many Token objects for any number of domains; it is opaque and
+ * not in and by itself tied to any specific domain.
+ * </p>
+ */
+public class Token {
+
+ private final TokenDomain domain;
+ private final String secretTokenString;
+ private final byte[] secretTokenBytes;
+ private final TokenFingerprint fingerprint;
+
+ Token(TokenDomain domain, String secretTokenString) {
+ this.domain = domain;
+ this.secretTokenString = secretTokenString;
+ this.secretTokenBytes = toUtf8Bytes(secretTokenString);
+ this.fingerprint = TokenFingerprint.of(this);
+ }
+
+ public static Token of(TokenDomain domain, String secretTokenString) {
+ return new Token(domain, secretTokenString);
+ }
+
+ public TokenDomain domain() { return domain; }
+ public String secretTokenString() { return secretTokenString; }
+ public TokenFingerprint fingerprint() { return fingerprint; }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Token token = (Token) o;
+ // We assume that domain+fingerprint suffices for equality check.
+ // If underlying secret bytes checking is added it MUST use SideChannelSafe.arraysEqual()
+ // to avoid leaking secret data via timing side-channels.
+ return Objects.equals(domain, token.domain) &&
+ Objects.equals(fingerprint, token.fingerprint);
+ }
+
+ // Important: actual secret bytes must NOT be part of hashCode calculation, as that risks
+ // leaking parts of the secret to an attacker that can influence and observe side effects
+ // of the hash code.
+ @Override
+ public int hashCode() {
+ return Objects.hash(domain, fingerprint);
+ }
+
+ @Override
+ public String toString() {
+ // Avoid leaking raw token secret as part of toString() output
+ return "Token(fingerprint: %s)".formatted(fingerprint.toHexString());
+ }
+
+ /**
+ * Token derivations are created by invoking a HKDF (using HMAC-SHA256) that expands the
+ * original token secret to the provided number of bytes and the provided domain separation
+ * context. The same source token secret will result in different derivations when
+ * different contexts are used, but will always generate a deterministic result for the
+ * same token+#bytes+context combination.
+ */
+ byte[] toDerivedBytes(int nHashBytes, byte[] domainSeparationContext) {
+ var hkdf = HKDF.unsaltedExtractedFrom(secretTokenBytes);
+ return hkdf.expand(nHashBytes, domainSeparationContext);
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/token/TokenCheckHash.java b/security-utils/src/main/java/com/yahoo/security/token/TokenCheckHash.java
new file mode 100644
index 00000000000..e4d9825842e
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/token/TokenCheckHash.java
@@ -0,0 +1,46 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.token;
+
+import java.util.Arrays;
+
+import static com.yahoo.security.ArrayUtils.hex;
+
+/**
+ * A token check hash represents a hash derived from a token in such a way that
+ * distinct "audiences" for the token compute entirely different hashes even for
+ * identical token values.
+ */
+public record TokenCheckHash(byte[] hashBytes) {
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TokenCheckHash tokenCheckHash = (TokenCheckHash) o;
+ // We don't consider token hashes secret data, so no harm in data-dependent equals()
+ return Arrays.equals(hashBytes, tokenCheckHash.hashBytes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(hashBytes);
+ }
+
+ public String toHexString() {
+ return hex(hashBytes);
+ }
+
+ @Override
+ public String toString() {
+ return toHexString();
+ }
+
+ public static TokenCheckHash of(Token token, int nHashBytes) {
+ return new TokenCheckHash(token.toDerivedBytes(nHashBytes, token.domain().checkHashContext()));
+ }
+
+ public static TokenCheckHash ofRawBytes(byte[] hashBytes) {
+ return new TokenCheckHash(Arrays.copyOf(hashBytes, hashBytes.length));
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/token/TokenDomain.java b/security-utils/src/main/java/com/yahoo/security/token/TokenDomain.java
new file mode 100644
index 00000000000..b29815f3a56
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/token/TokenDomain.java
@@ -0,0 +1,51 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.token;
+
+import java.util.Arrays;
+
+import static com.yahoo.security.ArrayUtils.toUtf8Bytes;
+
+/**
+ * <p>A token domain controls how token fingerprints and check-hashes are derived from
+ * a particular token. Even with identical token contents, different domain contexts
+ * are expected to produce entirely different derivations (with an extremely high
+ * probability).
+ * </p><p>
+ * Since tokens are just opaque sequences of high entropy bytes (with an arbitrary
+ * prefix), they do not by themselves provide any kind of inherent domain separation.
+ * Token domains exist to allow for <em>explicit</em> domain separation between
+ * different usages of tokens.
+ * </p><p>
+ * Fingerprint contexts will usually be the same across an entire deployment of a token
+ * evaluation infrastructure, in order to allow for identifying tokens "globally"
+ * across that deployment.
+ * </p><p>
+ * Access check hash contexts should be unique for each logical token evaluation audience,
+ * ensuring that access hashes from an unrelated audience (with a different context) can
+ * never be made to match, be it accidentally or deliberately.
+ * </p>
+ */
+public record TokenDomain(byte[] fingerprintContext, byte[] checkHashContext) {
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TokenDomain that = (TokenDomain) o;
+ return Arrays.equals(fingerprintContext, that.fingerprintContext) &&
+ Arrays.equals(checkHashContext, that.checkHashContext);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Arrays.hashCode(fingerprintContext);
+ result = 31 * result + Arrays.hashCode(checkHashContext);
+ return result;
+ }
+
+ public static TokenDomain of(String fingerprintContext, String checkHashContext) {
+ return new TokenDomain(toUtf8Bytes(fingerprintContext),
+ toUtf8Bytes(checkHashContext));
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/token/TokenFingerprint.java b/security-utils/src/main/java/com/yahoo/security/token/TokenFingerprint.java
new file mode 100644
index 00000000000..acbf7c085fd
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/token/TokenFingerprint.java
@@ -0,0 +1,52 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.token;
+
+import java.util.Arrays;
+
+import static com.yahoo.security.ArrayUtils.hex;
+
+/**
+ * <p>A token fingerprint represents an opaque sequence of bytes that is expected
+ * to globally identify any particular token within a particular token domain.
+ * </p><p>
+ * Token fingerprints should not be used directly for access checks; use derived
+ * {@link TokenCheckHash} instances for this purpose.
+ * </p>
+ */
+public record TokenFingerprint(byte[] hashBytes) {
+
+ public static final int FINGERPRINT_BITS = 128;
+ public static final int FINGERPRINT_BYTES = FINGERPRINT_BITS / 8;
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TokenFingerprint that = (TokenFingerprint) o;
+ // We don't consider token fingerprints secret data, so no harm in data-dependent equals()
+ return Arrays.equals(hashBytes, that.hashBytes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(hashBytes);
+ }
+
+ public String toHexString() {
+ return hex(hashBytes);
+ }
+
+ @Override
+ public String toString() {
+ return toHexString();
+ }
+
+ public static TokenFingerprint of(Token token) {
+ return new TokenFingerprint(token.toDerivedBytes(FINGERPRINT_BYTES, token.domain().fingerprintContext()));
+ }
+
+ public static TokenFingerprint ofRawBytes(byte[] hashBytes) {
+ return new TokenFingerprint(Arrays.copyOf(hashBytes, hashBytes.length));
+ }
+
+}
diff --git a/security-utils/src/main/java/com/yahoo/security/token/TokenGenerator.java b/security-utils/src/main/java/com/yahoo/security/token/TokenGenerator.java
new file mode 100644
index 00000000000..4dabca4b4ba
--- /dev/null
+++ b/security-utils/src/main/java/com/yahoo/security/token/TokenGenerator.java
@@ -0,0 +1,39 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.token;
+
+import com.yahoo.security.Base62;
+
+import java.security.SecureRandom;
+
+/**
+ * <p>
+ * Generates new {@link Token} instances that encapsulate a given number of cryptographically
+ * secure random bytes and, with a sufficiently high number of bytes (>= 16), can be expected
+ * to be globally unique and computationally infeasible to guess or brute force.
+ * </p><p>
+ * Tokens are returned in a printable and copy/paste-friendly form (Base62) with an optional
+ * prefix string.
+ * </p><p>
+ * Example of token string generated with the prefix "itsa_me_mario_" and 32 random bytes:
+ * </p>
+ * <pre>
+ * itsa_me_mario_nALfICMyrC4NFagwAkiOdGh80DPS1vSUPprGUKVPLya
+ * </pre>
+ * <p>
+ * Tokens are considered secret information, and must be treated as such.
+ * </p>
+ */
+public class TokenGenerator {
+
+ private static final SecureRandom CSPRNG = new SecureRandom();
+
+ public static Token generateToken(TokenDomain domain, String prefix, int nRandomBytes) {
+ if (nRandomBytes <= 0) {
+ throw new IllegalArgumentException("Token bytes must be a positive integer");
+ }
+ byte[] tokenRand = new byte[nRandomBytes];
+ CSPRNG.nextBytes(tokenRand);
+ return new Token(domain, "%s%s".formatted(prefix, Base62.codec().encode(tokenRand)));
+ }
+
+}