summaryrefslogtreecommitdiffstats
path: root/security-utils
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
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')
-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
-rw-r--r--security-utils/src/test/java/com/yahoo/security/token/TokenTest.java125
6 files changed, 398 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)));
+ }
+
+}
diff --git a/security-utils/src/test/java/com/yahoo/security/token/TokenTest.java b/security-utils/src/test/java/com/yahoo/security/token/TokenTest.java
new file mode 100644
index 00000000000..24c1be4cfa3
--- /dev/null
+++ b/security-utils/src/test/java/com/yahoo/security/token/TokenTest.java
@@ -0,0 +1,125 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.security.token;
+
+import org.junit.jupiter.api.Test;
+
+import static com.yahoo.security.ArrayUtils.toUtf8Bytes;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class TokenTest {
+
+ private static final TokenDomain TEST_DOMAIN = TokenDomain.of("my fingerprint", "my check hash");
+
+ @Test
+ void tokens_are_equality_comparable() {
+ var td1 = TokenDomain.of("fingerprint 1", "hash 1");
+ var td2 = TokenDomain.of("fingerprint 2", "hash 2");
+
+ var td1_t1 = Token.of(td1, "foo");
+ var td1_t2 = Token.of(td1, "foo");
+ var td1_t3 = Token.of(td1, "bar");
+ var td2_t1 = Token.of(td2, "foo");
+ // Tokens in same domain with same content are equal
+ assertEquals(td1_t1, td1_t2);
+ // Tokens in same domain with different content are not equal
+ assertNotEquals(td1_t1, td1_t3);
+ // Tokens in different domains are not considered equal
+ assertNotEquals(td1_t1, td2_t1);
+ }
+
+ @Test
+ void check_hashes_are_equality_comparable() {
+ var h1 = TokenCheckHash.ofRawBytes(toUtf8Bytes("foo"));
+ var h2 = TokenCheckHash.ofRawBytes(toUtf8Bytes("foo"));
+ var h3 = TokenCheckHash.ofRawBytes(toUtf8Bytes("bar"));
+ assertEquals(h1, h2);
+ assertNotEquals(h1, h3);
+ }
+
+ @Test
+ void token_generator_generates_new_tokens() {
+ var t1 = TokenGenerator.generateToken(TEST_DOMAIN, "foo_", 16);
+ var t2 = TokenGenerator.generateToken(TEST_DOMAIN, "foo_", 16);
+ // The space of possible generated tokens is effectively infinite, so we'll
+ // pragmatically round down infinity to 2...!
+ assertNotEquals(t1, t2);
+ assertTrue(t1.secretTokenString().startsWith("foo_"));
+ assertTrue(t2.secretTokenString().startsWith("foo_"));
+ // Token sizes are always greater than their raw binary size due to base62-encoding
+ assertTrue(t1.secretTokenString().length() > 20);
+ assertTrue(t2.secretTokenString().length() > 20);
+ }
+
+ @Test
+ void token_fingerprint_considers_entire_token_string_and_domain() {
+ var td = TokenDomain.of("my fingerprint", "my check hash");
+ var t1 = Token.of(td, "kittens_123456789");
+ var t2 = Token.of(td, "puppies_123456789");
+ assertEquals("563487a25ae28bc64ed804244bce70de", t1.fingerprint().toHexString());
+ assertEquals("4b63155af536346d49a52300f5d65364", t2.fingerprint().toHexString());
+
+ var td2 = TokenDomain.of("my fingerprint 2", "my check hash");
+ var t3 = Token.of(td2, "kittens_123456789");
+ assertEquals("201890b5e18e69c364ca09f3c7a00f8e", t3.fingerprint().toHexString());
+
+ // Only the _fingerprint_ context should matter
+ var td3 = TokenDomain.of("my fingerprint 2", "my check hash 2");
+ var t4 = Token.of(td3, "kittens_123456789");
+ assertEquals("201890b5e18e69c364ca09f3c7a00f8e", t4.fingerprint().toHexString());
+ }
+
+ @Test
+ void token_check_hash_differs_from_fingerprint() { // ... with extremely high probability
+ var t = Token.of(TEST_DOMAIN, "foo");
+ var fp = t.fingerprint();
+ // Generate check-hashes with the same length as fingerprints.
+ // If we generate with different lengths, hashes will differ by definition, but that wouldn't
+ // really tell us anything about whether the hashes are actually derived differently.
+ var hash = TokenCheckHash.of(t, TokenFingerprint.FINGERPRINT_BYTES);
+ assertEquals("532e4e09d54f96f41a4482eff044b9a2", fp.toHexString());
+ assertEquals("f0f56b46df55f73eccb9409c203b02c7", hash.toHexString());
+ }
+
+ @Test
+ void different_check_hash_domains_give_different_outputs() {
+ var d1 = TokenDomain.of("my fingerprint", "domain: 1");
+ var d2 = TokenDomain.of("my fingerprint", "domain: 2");
+ var d3 = TokenDomain.of("my fingerprint", "domain: 3");
+ assertEquals("cc0c504b52bfd9b0a9cdb1651c0f3515", TokenCheckHash.of(Token.of(d1, "foo"), 16).toHexString());
+ assertEquals("a27c7fc350699c71bc456a86bd571479", TokenCheckHash.of(Token.of(d2, "foo"), 16).toHexString());
+ assertEquals("119cc7046689e6de796fd4005aaab6dc", TokenCheckHash.of(Token.of(d3, "foo"), 16).toHexString());
+ }
+
+ @Test
+ void token_stringification_only_contains_fingerprint() {
+ var t = Token.of(TEST_DOMAIN, "foo");
+ assertEquals("Token(fingerprint: 532e4e09d54f96f41a4482eff044b9a2)", t.toString());
+ }
+
+ @Test
+ void token_fingerprints_and_check_hashes_are_stable() {
+ var d1 = TokenDomain.of("my fingerprint: 1", "domain: 1");
+ var d2 = TokenDomain.of("my fingerprint: 2", "domain: 2");
+
+ var t1 = Token.of(d1, "my_token_1");
+ assertEquals("e029edf4b9061a82b45fdf5cf1507804", t1.fingerprint().toHexString());
+ assertEquals("e029edf4b9061a82b45fdf5cf1507804", TokenFingerprint.of(t1).toHexString());
+ var t1_h1 = TokenCheckHash.of(t1, 32);
+ var t1_h2 = TokenCheckHash.of(t1, 16);
+ assertEquals("65da02dbed156442d85c93caf930217488916082936d17fef29137dc12110062", t1_h1.toHexString());
+ assertEquals("65da02dbed156442d85c93caf9302174", t1_h2.toHexString()); // same prefix, just truncated
+
+ var t2 = Token.of(d1, "my_token_2");
+ assertEquals("f1b9f90e996ec16125fec41ebc0c46a9", t2.fingerprint().toHexString());
+ var t2_h = TokenCheckHash.of(t2, 32);
+ assertEquals("8f3695492c3fd977b44067580ad57e87883317973e7c09cd859666da8edbd42f", t2_h.toHexString());
+
+ var t3 = Token.of(d2, "my_token_1"); // Different domain
+ assertEquals("90960354d1a6e5ec316117da72c31792", t3.fingerprint().toHexString());
+ var t3_h = TokenCheckHash.of(t3, 32);
+ assertEquals("f566dbec641aa64723dd19124afe6c96a821638f9b59f46bbe14f61c3704b32a", t3_h.toHexString());
+ }
+
+}