Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
bennadel committed Jan 14, 2023
0 parents commit e839295
Show file tree
Hide file tree
Showing 11 changed files with 504 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.DS_Store
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

# CUID2 for ColdFusion

by [Ben Nadel][ben-nadel]

This is a **ColdFusion / CFML port** of the [Cuid2][cuid2] token generator created by [Eric Elliott][eric-elliott]. Cuid2 is an evolution of the [Cuid][cuid] library (for which I also have a [ColdFusion port][ben-nadel-cuid]) that is intended to address some security issues.

Each Cuid token starts with a letter and is a consistent, configured length between 24 (default) and 34 characters.

The Cuid library for ColdFusion is **thread safe** and is intended to be instantiated once within an application and then cached for future usage. The Cuid library exposes one public method, `.createCuid()`, which will generate and return your Cuid token:

```cfml
<cfscript>
// Cachced reference to the CUID library.
cuid2 = new lib.Cuid2();
writeDump({ token: cuid2.createCuid() });
writeDump({ token: cuid2.createCuid() });
writeDump({ token: cuid2.createCuid() });
writeDump({ token: cuid2.createCuid() });
</cfscript>
```

Running the above ColdFusion code will produce the following output:

```txt
token: uem955pnse56id49y6bcmjz8
token: ek9lgqi0mfkh9wmxnb6rvzuc
token: lycfyvl0dlspi0us6smqkkr0
token: x0hhypk7l7k4hga8newn4gnw
```

The `Cuid2.cfc` ColdFusion component can be instantiated with two optional arguments:

`new Cuid2( [ length [, fingerprint ] ] )`

* `length` - Numeric: The length of the generated token. Defaults to 24 but can be anything between 24 and 32.

* `fingerprint` - String: The machine fingerprint. This is provided as an additional source of entropy. It defaults to the name of the JVM process as reported by the `ManagementFactory` Runtime MX Bean.

## Known Issues

Eric Elliott uses the `SHA3-256` hashing algorithm in order to reduce the various sources of entropy down into a single token. Unfortunately, the `SHA3` algorithms weren't available in Java until version 9. As such, I'm using the `SHA-256` hashing algorithm. I don't know what kind of impact this will have on the security; but, I believe the `SHA-256` algorithm to still be a commonly used and secure algorithm. I could always make this a configurable property of the ColdFusion component.

## Random Distribution

Under the hood, the `Cuid2.cfc` ColdFusion component generates random values using the `randRange()` built-in function with the `sha1prng` algorithm. With over 1,000,000 keys, we can see that this randomness is well distributed into buckets:

<img
src="/bennadel/CUID2-For-ColdFusion/raw/master/public/histogram.png"
width="100%"
/>


[ben-nadel]: "https://www.bennadel.com/"

[ben-nadel-cuid]: https://github.com/bennadel/CUID-For-ColdFusion

[cuid]: https://github.com/paralleldrive/cuid

[cuid2]: https://github.com/paralleldrive/cuid2

[eric-elliott]: https://medium.com/@_ericelliott
274 changes: 274 additions & 0 deletions lib/Cuid2.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/**
* This is a ColdFusion port of cuid2 by Eric Elliot.
* --
* Read more: https://github.com/paralleldrive/cuid2
*/
component
output = false
hint = "I provide secure, collision-resistant ids optimized for horizontal scaling and performance."
{

/**
* I initialize the CUID2 generator with the given (optional) parameters.
*/
public void function init(
numeric length,
string fingerprint
) {

variables.minCuidLength = 24;
variables.maxCuidLength = 32;

// The set of letters that can be used to start the CUID token.
variables.letters = [
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"
];

// The set of prime numbers that can be used to help generate entropy.
variables.primeNumbers = [
109717, 109721, 109741, 109751, 109789, 109793, 109807, 109819, 109829,
109831
];

// The counter will increment "forever" and is used as part of the hash inputs.
// The assumption here is that the service will be restarted / redeployed before
// the counter's Long value runs "out of space". The counter will start at a
// random value for additional entropy.
variables.counter = createObject( "java", "java.util.concurrent.atomic.AtomicLong" )
.init( secureRandRange( 0, 2057 ) )
;

// Shared class definitions as a performance optimization.
variables.LongClass = createObject( "java", "java.lang.Long" );
variables.BigIntegerClass = createObject( "java", "java.math.BigInteger" );

// Store and test arguments.
// --
// CAUTION: These assignments must come last because generating the fingerprint
// depends on other variables being defined.
variables.cuidLength = testCuidLength( arguments.length ?: minCuidLength );
variables.processFingerprint = testProcessFingerprint( arguments.fingerprint ?: generateFingerprint() );

}

// ---
// PUBLIC METHODS.
// ---

/**
* I generate a unique ID of the configured length. Guaranteed to start with a letter
* and be of the configured length.
*/
public string function createCuid() {

var token = ( generateLetterBlock() & generateHashBlock() );

// The full generated token will always be longer than the desired CUID token. As
// such, let's ensure the exact length by taking only the necessary prefix.
return( token.left( cuidLength ) );

}

// ---
// PRIVATE METHODS.
// ---

/**
* I generate a random base32 string of the given length.
*/
private string function generateEntropy( required numeric length ) {

var value = "";

while ( value.len() < length ) {

// NOTE: I don't understand the significance of using a prime number in this
// random selection. This is what Eric Elliott is doing in his version. I've
// left a comment asking about this:
// --
// https://github.com/paralleldrive/cuid2/issues/24#issuecomment-1381812737
value &= toBase36( secureRandRange( 0, secureRandArrayValue( primeNumbers ) ) );

}

return( value.left( length ) );

}


/**
* I generate the devince fingerprint for the CUID.
*
* DIVERGENCE FROM CUID v1: In first version of CUID, the fingerprint generation was
* guaranteed to be 4-characters. However, in CUID v2, the fingerprint is nothing more
* than a source of additional entropy for use in the hash-generation. As such, this
* function no longer needs to make any guarantees about its length. In fact, doesn't
* even need to use process name.
*/
private string function generateFingerprint() {

var jvmProcessName = createObject( "java", "java.lang.management.ManagementFactory" )
.getRuntimeMXBean()
.getName()
;

return( secureHash( jvmProcessName ) );

}


/**
* I generate the secure hash block for the CUID.
*/
private string function generateHashBlock() {

var timePart = toBase36( getTickCount() );
var entropyPart = generateEntropy( cuidLength );
var counterPart = toBase36( counter.getAndIncrement() );
// All of the entropy between the time, the counter, the fingerprint, and the
// additional random values all, ultimately, get hashed-down to a consistently-
// sized block.
var input = "#timePart##entropyPart##counterPart##processFingerprint#";

return( secureHash( input, cuidLength ) );

}


/**
* I generate the single letter to be used as the CUID token prefix.
*/
private string function generateLetterBlock() {

return( secureRandArrayValue( letters ) );

}


/**
* I hash the given input down to a base36 string. The length of the resultant value is
* not consistent.
*
* CAUTION: In the native implementation, Eric Elliott uses SHA3-256. However, the SHA3
* algorithms weren't added to Java until version 9 - see this post by Pete Freitag -
* https://www.petefreitag.com/item/843.cfm - As such, I'm using SHA-256 with the hopes
* that this will be sufficiently secure.
*/
private string function secureHash(
required string input,
numeric tokenLength = maxCuidLength
) {

// From the JavaScript version: The salt should be long enough to be globally
// unique across the full length of the hash. For simplicity, we use the same
// length as the intended id output, defaulting to the maximum recommended size.
var salt = generateEntropy( tokenLength );
var text = ( input & salt );

// The native ColdFusion hash() function always returns the value as a hex-encoded
// string. However, we need to get it into a base36-encoded string. As such, we
// need to decode the hex back into its binary value and then use the BigInteger
// class to re-encode as base36.
var bytes = binaryDecode( hash( text, "sha-256" ), "hex" );
// NOTE: While the hash() method always returns a value with a consistent length,
// converting the hex-encoded value into a base36-encoding value results in a
// variable-length string.
var result = BigIntegerClass
.init( bytes )
.toString( 36 )
// NOTE: In the JavaScript version of CUID2, Eric Elliott removes the first
// two letters of the hash. His note says that the first two letters bias the
// generated CUIDs towards a narrower set of values. Anecdotally, I do see the
// dash ("-") showing up a lot unless I remove the first 2 characters as well.
.right( -2 )
;

return( result );

}


/**
* I return a random value from the given array using the SHA1PRNG secure algorithm.
*/
private any function secureRandArrayValue( required array values ) {

return( values[ secureRandRange( 1, values.len() ) ] );

}


/**
* I get a random value within the given range, inclusive, using the SHA1PRNG secure
* algorithm.
*/
private numeric function secureRandRange(
required numeric minValue,
required numeric maxValue
) {

return( randRange( minValue, maxValue, "sha1prng" ) );

}


/**
* I test and return the given length, throwing an error if the length is invalid.
*/
private numeric function testCuidLength( required numeric value ) {

if (
( value < minCuidLength ) ||
( value > maxCuidLength ) ||
( fix( value ) != value )
) {

throw(
type = "Cuid2.Length.Invalid",
message = "Cuid2 token length must be between [#minCuidLength#] and [#maxCuidLength#].",
detail = "Provided length: [#value#]."
);

}

return( value );

}


/**
* I test and return the given fingerprint, throwing an error if the fingerprint is
* invalid.
*/
private string function testProcessFingerprint( required string value ) {

if ( ! value.len() ) {

throw(
type = "Cuid2.Fingerprint.Invalid",
message = "Cuid2 process fingerprint must not be empty.",
detail = "The fingerprint provides an important source of device-related entropy and cannot be empty."
);

}

return( value )

}


/**
* I convert the given number into a Base36 character encoding.
*/
private string function toBase36( required numeric input ) {

// NOTE: Not all of the values we are dealing with can fit inside an INT. And,
// Adobe ColdFusion can only use INTs with the formatBaseN() function (Note that
// Lucee CFML does not have this constraint). As such, we're dipping into the Long
// class for our encoding.
return( LongClass.toString( input, 36 ) );

}

}
Binary file added public/histogram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions tests/.cfconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"adminPassword": "password"
}
17 changes: 17 additions & 0 deletions tests/Application.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
component
output = false
hint = "I define the application settings and event handlers."
{

// Define the application settings.
this.name = "CUIDv2Testing";
this.applicationTimeout = createTimeSpan( 1, 0, 0, 0 );
this.sessionManagement = false;
this.setClientCookies = false;

this.directory = getDirectoryFromPath( getCurrentTemplatePath() );
this.mappings = {
"/lib": "#this.directory#../lib"
};

}
Loading

0 comments on commit e839295

Please sign in to comment.