Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #25903: Refactor API tokens after clear-text removal #6031

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ import com.normation.rudder.repository.ldap.LDAPDiffMapper
import com.normation.rudder.repository.ldap.LDAPEntityMapper
import com.normation.rudder.services.user.PersonIdentService
import com.normation.zio.*
import java.security.MessageDigest
import org.joda.time.DateTime
import zio.*
import zio.syntax.*
Expand All @@ -71,7 +70,7 @@ trait RoApiAccountRepository {
*/
def getAllStandardAccounts: IOResult[Seq[ApiAccount]]

def getByToken(token: ApiToken): IOResult[Option[ApiAccount]]
def getByToken(hashedToken: ApiTokenHash): IOResult[Option[ApiAccount]]

def getById(id: ApiAccountId): IOResult[Option[ApiAccount]]

Expand All @@ -98,16 +97,16 @@ final class RoLDAPApiAccountRepository(
val rudderDit: RudderDit,
val ldapConnexion: LDAPConnectionProvider[RoLDAPConnection],
val mapper: LDAPEntityMapper,
val tokenGen: TokenGenerator,
val systemAcl: List[ApiAclElement]
val systemAcl: List[ApiAclElement],
val systemToken: ApiTokenHash
) extends RoApiAccountRepository {

val systemAPIAccount: ApiAccount = {
ApiAccount(
ApiAccountId("rudder-system-api-account"),
ApiAccountKind.System,
ApiAccountName("Rudder system account"),
ApiToken(ApiToken.generate_secret(tokenGen, "-system")),
systemToken,
"For internal use",
isEnabled = true,
creationDate = DateTime.now,
Expand Down Expand Up @@ -140,36 +139,25 @@ final class RoLDAPApiAccountRepository(
}
}

// Here the process is:
//
// * Ensure it is a clear-text token
// * Check if token matches in-memory system account
// * Then look for it in the LDAP:
// * First as a hash
// * Then, in fallback, as clear-text token
//
// Warning: When matching clear-text value we MUST make sure it is not
// a hash but a clear text token to avoid accepting the hash as valid token itself.
//
override def getByToken(token: ApiToken): IOResult[Option[ApiAccount]] = {
if (token.isHashed) {
None.succeed
} else if (MessageDigest.isEqual(token.value.getBytes(), systemAPIAccount.token.value.getBytes())) {
// Constant-time comparison
Some(systemAPIAccount).succeed
} else {
val hash = ApiToken.hash(token.value)
for {
ldap <- ldapConnexion
// here, be careful to the semantic of get with a filter!
optEntry <- ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, hash))
optRes <- optEntry match {
case None => None.succeed
case Some(e) => mapper.entry2ApiAccount(e).map(Some(_)).toIO
}
} yield {
optRes
}
/*
Look for a given token hash in the LDAP.
*/
override def getByToken(hashedToken: ApiTokenHash): IOResult[Option[ApiAccount]] = {
val hash = hashedToken.exposeHash();
for {
ldap <- ldapConnexion
// here, be careful to the semantic of get with a filter!
optEntry <- hash match {
case None => None.succeed
case Some(h) => ldap.get(rudderDit.API_ACCOUNTS.dn, BuildFilter.EQ(RudderLDAPConstants.A_API_TOKEN, h))
}
optRes <- optEntry match {
case None => None.succeed
case Some(e) => mapper.entry2ApiAccount(e).map(Some(_)).toIO
}
} yield {
optRes

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ package com.normation.rudder.api

import cats.data.*
import cats.implicits.*
import com.normation.rudder.api.ApiToken.prefixV2
import com.normation.rudder.facts.nodes.NodeSecurityContext
import enumeratum.*
import java.nio.charset.StandardCharsets
Expand All @@ -58,53 +57,130 @@ final case class ApiAccountId(value: String) extends AnyVal
final case class ApiAccountName(value: String) extends AnyVal

/**
* The actual authentication token.
* The actual authentication token, in clear text.
*
* TODO: Once support for plain text tokens is dropped, make separate types for plain and hashed tokens.
Current situation is confusing, and hence a bit risky.
* All tokens are 32 alphanumeric characters, optionally
* followed by a "-system" suffix, indicating a system token.
*
* There are two versions of tokens:
*/
final case class ApiTokenSecret(private val secret: String) {
// Avoid printing the value in logs, regardless of token type
override def toString: String = "[REDACTED ApiTokenSecret]"

// For cases when we need to print a part of the plain token for debugging.
// Show the first 4 chars: enough to disambiguate, and preserves 166 bits of randomness.
def exposeSecretBeginning: String = {
secret.take(4) + "[SHORTENED ApiTokenSecret]"
}

def exposeSecret(): String = {
secret
}

def toHash(): ApiTokenHash = {
ApiTokenHash.fromSecret(this)
}
}

object ApiTokenSecret {
private val tokenSize = 32

def generate(tokenGenerator: TokenGenerator, suffix: String = ""): ApiTokenSecret = {
val completeSuffix = if (suffix.isEmpty) "" else "-" + suffix
ApiTokenSecret(tokenGenerator.newToken(tokenSize) + completeSuffix)
}
}

/*
* There are two versions of token hashes:
*
* * v1: 32 alphanumeric characters stored as clear text
* they are also displayed in clear text in the interface.
* * v1: 32 alphanumeric characters stored as clear text.
* They were also displayed in clear text in the interface.
* They are not supported anymore since 8.3, we just ignore them.
* * v2: starting from Rudder 8.1, tokens are still 32 alphanumeric characters,
* but are now stored hashed in sha512 (128 characters), prefixed with "v2:".
* The tokens are only displayed once at creation.
* The secret are only displayed once at creation.
*
* Both can have a `-system` suffix to mark the system token.
*
* To make the difference, we use a prefix to the hash value in v2
* Hashes are stored with a prefix indicating the hash algorithm:
*
* * If it starts with "v2:", it is a v2 SHA512 hash of the token
* * If it does not start with "v2:", it is a clear-text v1 token
* Note: v2 tokens can never start with "v" as they are encoded as en hexadecimal string
* Note: stored v1 tokens can never start with "v" as they are encoded as en hexadecimal string.
*
* We don't have generic code as V2 is (very) likely the last simple API key mechanism we'll need.
*
* Token hash comparison must use the isEqual method.
*
*/
case class ApiToken(value: String) extends AnyVal {
// Avoid printing the value in logs, regardless of token type
override def toString: String = "[REDACTED ApiToken]"

// For cases we need to print a part of the plain token for debug.
// Show the first 4 chars: enough to disambiguate, and preserves 166 bits of randomness.
def exposeSecretBeginning: String = {
value.take(4) + "[SHORTENED ApiToken]"
trait CompareToken {
def equalsToken(other: ApiTokenHash): Boolean
}

sealed trait ApiTokenHash extends CompareToken {
override def toString: String = "[REDACTED ApiTokenHash]"

override def equalsToken(token: ApiTokenHash) = {
(this, token) match {
case (ApiTokenHash.V2(a), ApiTokenHash.V2(b)) => MessageDigest.isEqual(a.getBytes(), b.getBytes())
case _ => false
}
}

/*
Deprecated or invalid hashes are not returned.
*/
def exposeHash(): Option[String] = {
this match {
case ApiTokenHash.V2(h) => Some(h)
case _ => None
}
clarktsiory marked this conversation as resolved.
Show resolved Hide resolved
}

def isHashed: Boolean = {
value.startsWith(prefixV2)
def version(): Int = {
this match {
case ApiTokenHash.V2(_) => 2
case _ => 1
}
}
}

object ApiToken {
private val tokenSize = 32
private val prefixV2 = "v2:"
object ApiTokenHash {
// A hash that will never match
private case object Disabled extends ApiTokenHash
// A pre-hash API token. The value is not stored.
private case object V1 extends ApiTokenHash
private case class V2(private val value: String) extends ApiTokenHash

private object V2 {
val prefix = "v2:"

def hash(token: ApiTokenSecret): ApiTokenHash = {
val sha512Digest = MessageDigest.getInstance("SHA-512")
val hash = sha512Digest.digest(token.exposeSecret().getBytes(StandardCharsets.UTF_8))
val hexHash = Hex.encode(hash)
val hexString = new String(hexHash, StandardCharsets.UTF_8)
V2(prefix + hexString)
}
}

def fromHashValue(hash: String): ApiTokenHash = {
if (hash.startsWith(V2.prefix)) {
V2(hash)
} else {
// Don't bother to try to recognize old patterns.
// The hash is not used anyway.
V1
}
}

def hash(clearText: String): String = {
val digest = MessageDigest.getInstance("SHA-512")
prefixV2 + new String(Hex.encode(digest.digest(clearText.getBytes(StandardCharsets.UTF_8))), StandardCharsets.UTF_8)
// Use latest version for new hashes
def fromSecret(token: ApiTokenSecret): ApiTokenHash = {
V2.hash(token)
}

def generate_secret(tokenGenerator: TokenGenerator, suffix: String = ""): String = {
tokenGenerator.newToken(tokenSize) + suffix
def disabled(): ApiTokenHash = {
Disabled
}
}

Expand Down Expand Up @@ -354,10 +430,54 @@ final case class ApiAccount(

name: ApiAccountName, // used in event log to know who did actions.

token: ApiToken,
token: ApiTokenHash,
description: String,
isEnabled: Boolean,
creationDate: DateTime,
tokenGenerationDate: DateTime,
tenants: NodeSecurityContext
)
) {
def toNewApiAccount(secret: ApiTokenSecret): NewApiAccount = {
NewApiAccount(
id,
kind,
name,
secret,
description,
isEnabled,
creationDate,
tokenGenerationDate,
tenants
)
}
}

/**
* An API principal, containing the secret, to be used just after creation, and never stored.
*/
final case class NewApiAccount(
id: ApiAccountId,
kind: ApiAccountKind,
name: ApiAccountName,
// Clear text token, only used for just-created accounts, never stored
token: ApiTokenSecret,
description: String,
isEnabled: Boolean,
creationDate: DateTime,
tokenGenerationDate: DateTime,
tenants: NodeSecurityContext
) {
def toApiAccount(): ApiAccount = {
ApiAccount(
id,
kind,
name,
token.toHash(),
description,
isEnabled,
creationDate,
tokenGenerationDate,
tenants
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import java.security.SecureRandom
import scala.util.Random

/**
* Generate random string usable as token
* Generate a random string usable as secret (token, password, etc.)
*/
trait TokenGenerator {

Expand Down
Loading