Skip to content

Commit

Permalink
feat: proof of work skeleton/implementations
Browse files Browse the repository at this point in the history
  • Loading branch information
Z-Kris committed Apr 23, 2024
1 parent 7d862e3 commit a990acf
Show file tree
Hide file tree
Showing 19 changed files with 525 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.rsprot.protocol.loginprot.incoming.pow

import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeVerifier

/**
* Proof of work is a procedure during login to attempt to throttle login requests from a single source,
* by requiring them to do CPU-bound work before accepting the login.
* @property challengeType the type of the challenge to require the client to solve
* @property challengeVerifier the verifier of that challenge, to ensure the client did complete
* the world successfully
*/
@Suppress("MemberVisibilityCanBePrivate")
public class ProofOfWork<T : ChallengeType<MetaData>, in MetaData : ChallengeMetaData>(
public val challengeType: T,
public val challengeVerifier: ChallengeVerifier<T>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.rsprot.protocol.loginprot.incoming.pow

import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType

/**
* An interface to return proof of work implementations based on the input ip.
*/
public fun interface ProofOfWorkProvider<T : ChallengeType<MetaData>, in MetaData : ChallengeMetaData> {
/**
* Provides a proof of work instance for a given [ip].
* @param ip the IP from which the client is connecting.
* @return a proof of work instance that the client needs to solve.
*/
public fun provide(ip: Int): ProofOfWork<T, MetaData>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package net.rsprot.protocol.loginprot.incoming.pow

import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeGenerator
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaData
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaDataProvider
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType
import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeVerifier

/**
* A single type proof of work provider is used to always return proof of work instances
* of a single specific type.
* @property metaDataProvider the provider used to return instances of metadata for the
* challenges.
* @property challengeGenerator the generator that will create a new proof of work challenge
* based on the input metadata.
* @property challengeVerifier the verifier that will check if the answer sent by the client
* is correct.
*/
public class SingleTypeProofOfWorkProvider<T : ChallengeType<MetaData>, in MetaData : ChallengeMetaData>(
private val metaDataProvider: ChallengeMetaDataProvider<MetaData>,
private val challengeGenerator: ChallengeGenerator<MetaData, T>,
private val challengeVerifier: ChallengeVerifier<T>,
) : ProofOfWorkProvider<T, MetaData> {
override fun provide(ip: Int): ProofOfWork<T, MetaData> {
val metadata = metaDataProvider.provide(ip)
val challenge = challengeGenerator.generate(metadata)
return ProofOfWork(challenge, challengeVerifier)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

/**
* A challenge generator used to construct a challenge out of the provided metadata.
*/
public fun interface ChallengeGenerator<in MetaData : ChallengeMetaData, out Type : ChallengeType<MetaData>> {
/**
* A function to generate a challenge out of the provided metadata.
* @param input the metadata input necessary to generate a challenge
* @return a challenge generated out of the metadata
*/
public fun generate(input: MetaData): Type
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

/**
* A common binding interface for metadata necessary to pass into the challenge constructors.
*/
public interface ChallengeMetaData
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

/**
* A challenge metadata provider is used to generate a metadata necessary to construct a challenge.
*/
public fun interface ChallengeMetaDataProvider<out T : ChallengeMetaData> {
/**
* Provides a metadata instance for a challenge, using the ip as the input parameter.
* @param ip the IP from which the user is connecting to the server.
* This is provided in case an implementation which scales with the number of requests
* from a given host is desired.
* @return the metadata object necessary to construct a challenge.
*/
public fun provide(ip: Int): T
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

/**
* A common binding interface for challenge types.
* Currently, the client only supports SHA-256 as a challenge, but it is set up to
* support other types with ease.
* @param MetaData the metadata necessary to construct a challenge of this type.
* @property id the id of the challenge, used by the client to identify what challenge
* solver to use.
* @property resultSize the number of bytes the server must have in the socket before
* it can attempt to verify the challenge.
*/
public interface ChallengeType<in MetaData : ChallengeMetaData> {
public val id: Int
public val resultSize: Int
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

import net.rsprot.buffer.JagByteBuf

/**
* A challenge verifier is used to check the work that the client did.
* The general idea here is that the client has to perform the work N times, where N is
* pseudo-random, while the server only has to do that same work one time - to verify the
* result that the client sent. The complexity of the work to perform is configurable by the
* server.
* @param T the challenge type to verify
*/
public interface ChallengeVerifier<in T : ChallengeType<*>> {
/**
* Verifies the work performed by the client.
* @param result the byte buffer containing the result sent by the client.
* @param challenge the challenge to verify using the [result] provided.
* @return whether the challenge is solved using the [result] provided.
*/
public fun verify(
result: JagByteBuf,
challenge: T,
): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

import net.rsprot.buffer.JagByteBuf
import java.util.concurrent.CompletableFuture

/**
* A worker is used to perform the verifications of the data sent by the client for our
* proof of work requests. While the work itself is relatively cheap, servers may wish
* to perform the work on other threads - this interface allows doing that.
*/
public interface ChallengeWorker {
/**
* Verifies the result sent by the client.
* @param result the byte buffer containing the result data sent by the client
* @param challenge the challenge the client had to solve
* @param verifier the verifier used to check the work done by the client for out challenge
* @return a future object containing the result of the work, or an exception.
* If the future doesn't return immediately, there will be a 30-second timeout applied to it,
* after which the work will be concluded failed.
*/
public fun <T : ChallengeType<*>, V : ChallengeVerifier<T>> verify(
result: JagByteBuf,
challenge: T,
verifier: V,
): CompletableFuture<Boolean>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges

import net.rsprot.buffer.JagByteBuf
import java.util.concurrent.CompletableFuture

/**
* The default challenge worker will perform the work on the calling thread.
* The SHA-256 challenges are fairly inexpensive and the overhead of switching threads
* is similar to the work itself done.
*/
public data object DefaultChallengeWorker : ChallengeWorker {
override fun <T : ChallengeType<*>, V : ChallengeVerifier<T>> verify(
result: JagByteBuf,
challenge: T,
verifier: V,
): CompletableFuture<Boolean> {
return try {
CompletableFuture.completedFuture(verifier.verify(result, challenge))
} catch (t: Throwable) {
CompletableFuture.failedFuture(t)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256

import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeGenerator
import java.math.BigInteger
import kotlin.random.Random

/**
* The default SHA-256 challenge generator is used to generate challenges which align
* up with what OldSchool RuneScape is generating, which is a combination of epoch time millis,
* the world id and a 495-byte [BigInteger] that is turned into a hexadecimal string,
* which will have a length of 1004 or 1005 characters, depending on if the [BigInteger] was negative.
*/
public class DefaultSha256ChallengeGenerator :
ChallengeGenerator<Sha256MetaData, Sha256Challenge> {
override fun generate(input: Sha256MetaData): Sha256Challenge {
val randomData = Random.Default.nextBytes(RANDOM_DATA_LENGTH)
val hexSalt = BigInteger(randomData).toString(HEX_RADIX)
val salt =
java.lang.Long.toHexString(input.epochTimeMillis) +
Integer.toHexString(input.world) +
hexSalt
return Sha256Challenge(
input.unknown,
input.difficulty,
salt,
)
}

private companion object {
private const val RANDOM_DATA_LENGTH: Int = 495
private const val HEX_RADIX: Int = 16
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256

import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeMetaDataProvider

/**
* The default SHA-256 metadata provider will return a metadata object
* that matches what OldSchool RuneScape sends.
* @property world the world that the client is connecting to.
*/
public class DefaultSha256MetaDataProvider(
private val world: Int,
) : ChallengeMetaDataProvider<Sha256MetaData> {
override fun provide(ip: Int): Sha256MetaData {
return Sha256MetaData(world)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256

import net.rsprot.protocol.loginprot.incoming.pow.ProofOfWorkProvider
import net.rsprot.protocol.loginprot.incoming.pow.SingleTypeProofOfWorkProvider

/**
* A value class to wrap the properties of a SHA-256 into a single instance.
* @property provider the SHA-256 proof of work provider.
*/
@Suppress("MemberVisibilityCanBePrivate")
@JvmInline
public value class DefaultSha256ProofOfWorkProvider private constructor(
public val provider: SingleTypeProofOfWorkProvider<Sha256Challenge, Sha256MetaData>,
) : ProofOfWorkProvider<Sha256Challenge, Sha256MetaData> by provider {
public constructor(
world: Int,
) : this(
SingleTypeProofOfWorkProvider(
DefaultSha256MetaDataProvider(world),
DefaultSha256ChallengeGenerator(),
Sha256ChallengeVerifier(),
),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package net.rsprot.protocol.loginprot.incoming.pow.challenges.sha256

import net.rsprot.protocol.loginprot.incoming.pow.challenges.ChallengeType

/**
* A SHA-256 challenge is a challenge which forces the client to find a hash which
* has at least [difficulty] number of leading zero bits in the hash.
* As hashing returns pseudo-random results, as a general rule of thumb, the work
* needed to solve a challenge doubles with each difficulty increase, since each
* bit can be either true or false, and the solution must have at least [difficulty]
* amount of false (zero) bits.
* Since the requirement is that there are at least [difficulty] amount of leading
* zero bits, these challenges aren't constrained to only having a single successful
* answer.
* @property unknown an unknown byte value that is appended to the start of each
* base string that needs hashing. The value of this byte is **always** one in OldSchool
* RuneScape.
* @property difficulty the difficulty of the challenge, as explained above, is the number
* of leading zero bits the hash must have for it to be considered successful.
* The default difficulty in OldSchool RuneScape is 18 as of writing this.
* When Proof of Work was first introduced, this value was 16.
* It is possible that the value gets dynamically increased as the pressure increases,
* or if there are a lot of requests from a single IP.
* @property salt the salt string that is the bulk of the input to hash.
* @property id the id of the challenge as identified by the client.
* @property resultSize the number of bytes the server must have in its socket after sending
* a SHA-256 challenge request, in order to attempt to verify it.
*/
public class Sha256Challenge(
public val unknown: Int,
public val difficulty: Int,
public val salt: String,
) : ChallengeType<Sha256MetaData> {
override val id: Int
get() = 0
override val resultSize: Int
get() = Long.SIZE_BYTES

/**
* Gets the base string that is part of the input for the hash.
* A long will be appended to this base string at the end, which will additionally
* be the solution to the challenge. The full string of baseString + the long is what
* must result in [difficulty] number of leading zero bits after having been hashed.
* @return the base string used for the hashing input.
*/
public fun getBaseString(): String {
return Integer.toHexString(this.unknown) + Integer.toHexString(this.difficulty) + this.salt
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Sha256Challenge) return false

if (unknown != other.unknown) return false
if (difficulty != other.difficulty) return false
if (salt != other.salt) return false

return true
}

override fun hashCode(): Int {
var result = unknown
result = 31 * result + difficulty
result = 31 * result + salt.hashCode()
return result
}

override fun toString(): String {
return "Sha256Challenge(" +
"unknown=$unknown, " +
"difficulty=$difficulty, " +
"salt='$salt'" +
")"
}
}
Loading

0 comments on commit a990acf

Please sign in to comment.