Authentication

S12G implements the Pusher Protocol with one major deviation: authentication. In service of lower operational complexity, S12G swaps out Pusher's stateful API tokens for ECDSA key pairs. The difference matters when subscribing to a private channel, triggering an event, or authenticating a user. There's a Node.js library that swaps in S12G compatible authorization implementations for the Pusher Protocol standards.

This document is meant for library authors who want to implement the S12G authentication protocol.

User authentication string generation

Authentication and authorization strings are generated by your backend and sent to the client so it can represent itself to the S12G service. The client then sends the auth string to the S12G service when it subscribes to a private channel or authenticates a user.

These strings are generated on your backend in order to protect your ECDSA private key. The equivalent Pusher Protocol logic is documented here.

When the client requests an auth string it will send a POST request to your backend with a JSON payload like this:

{
  "socket_id": "123.456", // example socket id (always in format of NUMBER.NUMBER)
  "channel_name": "private-channel" // example channel_name
}

You specify the exact endpoint the client should send this request to when you setup the client's authEndpoint value.

For both user authentication and private channel authorization, auth strings have the following format

<ecdsa-public-key-in-hex>:<unix-timestamp-in-milliseconds>:<ecdsa-signature-in-hex>

The signature is generated by signing the authentication string with the corresponding ECDSA private key. The authentication string includes a timestamp to prevent replay attacks, and you can expect S12G to accept signatures that are up to 1 minute old. Note, the timestamp is only evaluated when initially subscribing to a channel and the channel connection will persist (as long as it's uninterrupted) beyond the validity of the signature's timestamp. The standard Pusher client libraries can be relied upon to request auth strings just before subscribing to a new channel, so you don't need to worry about the timestamp expiration.

For private channel authorization, the authentication string should be: <socket_id>:<unix-timestamp-in-milliseconds>:<channel_name>

The entire process is implemented in Javascript like so:

From: https://github.com/zshannon/pusher-http-node/blob/18f200434f016fa88f6ee6a993b105dfd9f5a358/lib/auth.js#L24

const Buffer = require("buffer").Buffer
const crypto = require("crypto")
const secp256k1 = require("secp256k1")

// example values
// const ecdsaPublicKeyInHex = '02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47'
// const ecdsaPrivateKeyInHex = '6e8e39380e6472ae7bf5f270e05e77008df667fe58355c49c07f37630ce7e137'
// const channelName = 'private-channel'
// const socketId = '123.456'

function authenticatePrivateChannel(ecdsaPublicKeyInHex, ecdsaPrivateKeyInHex, channelName, socketId) {
  const ts = Date.now() // returns 1701389697959 at Fri Dec 01 2023 00:14:57 GMT+0000
  const ecdsaPrivateKey = hexToUint8Array(ecdsaPrivateKeyInHex) // (see below for implementation of `hexToUint8Array`) => Uint8Array(32) [110, 142,  57,  56,  14, 100, 114, 174, 123, 245, 242, 112, 224,  94, 119,   0, 141, 246, 103, 254,  88,  53,  92,  73, 192, 127,  55,  99,  12, 231, 225,  55]
  const stringToSign = `${socketId}:${ts}:${channelName}` // => '123.456:1701389697959:private-channel'
  const digest = crypto.createHash('sha256').update(Buffer.from(stringToSign)).digest() // => <Buffer ab e4 ee d8 78 40 f0 2e 88 2d dd 0a 47 0f 19 f4 c6 c3 6c a5 72 ee 90 d1 e9 fb 3a f5 e6 17 ae e8>
  const { signature } = secp256k1.ecdsaSign(new Uint8Array(digest), ecdsaPrivateKey, {
    noncefn: () => crypto.randomBytes(32),
  }) // => Uint8Array(64) [23, 115, 245, 180, 130, 192, 137, 158, 241,  48, 241, 143,   2, 196,  32, 254,  69, 162, 207, 206, 229,  44, 9,  13,  18, 126, 236,  65, 226,  36, 156, 187,  39, 165,  69, 100, 138, 182, 236,  95, 196,  98, 146,  48, 107, 222, 244,  18, 170, 189, 157, 191, 222, 224, 129, 119, 242, 206,  28,  93, 147, 249, 237, 126]
  const signatureInHex = Buffer.from(signature).toString('hex') // => '1773f5b482c0899ef130f18f02c420fe45a2cfcee52c090d127eec41e2249cbb27a545648ab6ec5fc46292306bdef412aabd9dbfdee08177f2ce1c5d93f9ed7e'
  return { auth: `${ecdsaPublicKeyInHex}:${ts}:${signatureInHex}` } // => { "auth": "02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47:1701389697959:1773f5b482c0899ef130f18f02c420fe45a2cfcee52c090d127eec41e2249cbb27a545648ab6ec5fc46292306bdef412aabd9dbfdee08177f2ce1c5d93f9ed7e" }
}

API request authentication string generation

All API requests to S12G are signed with an ECDSA key pair. The equivalent Pusher Protocol logic is documented here.

In general, you must include the following query parameters with every request to S12G:

  • auth_key: your ECDSA public key in hex, eg 02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47
  • auth_timestamp: the current unix timestamp in seconds, eg 1701389697
  • auth_version: the string "1.0"

If you're making a request with a non-empty body, eg POST to /events, you must also include the following query parameter:

  • body_md5: the hex-encoded MD5 hash of the request body, eg d41d8cd98f00b204e9800998ecf8427e

Once all of these authentication query parameters have been added to your request, you must generate and add a signature parameter:

  • auth_signature: the hex-encoded ECDSA signature of the request body, eg 1773f5b482c0899ef130f18f...dbfdee08177f2ce1c5d93f9ed7e (truncated with ... for brevity in these docs)

The entire process is implemented in Javascript like so:

From: https://github.com/zshannon/pusher-http-node/blob/18f200434f016fa88f6ee6a993b105dfd9f5a358/lib/pusher.js#L70

const Buffer = require("buffer").Buffer
const crypto = require("crypto")
const secp256k1 = require("secp256k1")

// example values
// const ecdsaPublicKeyInHex = '02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47'
// const ecdsaPrivateKeyInHex = '6e8e39380e6472ae7bf5f270e05e77008df667fe58355c49c07f37630ce7e137'
// const channelName = 'private-channel'
// const socketId = '123.456'

function createSignedQueryString = (ecdsaPublicKeyInHex, ecdsaPrivateKeyInHex, request) => {
	const timestamp = (Date.now() / 1000) | 0 // => 1701389697
	const parameters = {
		auth_key: ecdsaPublicKeyInHex, // => '02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47'
		auth_timestamp: timestamp, // => 1701389697
		auth_version: '1.0',
	}
	if (request.body) {
		parameters.body_md5 = crypto.createHash('md5').update(request.body).digest('hex') // => 'd41d8cd98f00b204e9800998ecf8427e'
	}
	if (request.params) {
		for (const key in request.params) {
			if ([
              'auth_key',
              'auth_signature',
              'auth_timestamp',
              'auth_version',
              'body_md5',
            ].includes(key)) {
				throw new Error(key + ' is a required parameter and cannot be overidden')
			}
			parameters[key] = request.params[key]
		}
	}
	const method = request.method.toUpperCase() // => 'POST'
	const sortedKeyValue = toOrderedArray(parameters) // (see below for example implementation) => ['auth_key=02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47', 'auth_timestamp=1701389697', 'auth_version=1.0', 'body_md5=d41d8cd98f00b204e9800998ecf8427e']
	let queryString = sortedKeyValue.join('&') // => 'auth_key=02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47&auth_timestamp=1701389697&auth_version=1.0&body_md5=d41d8cd98f00b204e9800998ecf8427e'
	const signData = [method, request.path, queryString].join('\n') // => 'POST\n/events\nauth_key=02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47&auth_timestamp=1701389697&auth_version=1.0&body_md5=d41d8cd98f00b204e9800998ecf8427e'
	const digest = crypto.createHash('sha256').update(Buffer.from(signData)).digest() // => <Buffer 84 3d 20 ba 3b 41 04 51 bc a1 49 5a d7 a5 7b 77 e6 76 d7 fb da 3f 82 8c f3 7c 5c c4 b2 71 31 40>
	const ecdsaPrivateKey = hexToUint8Array(ecdsaPrivateKeyInHex) // (see below for implementation of `hexToUint8Array`) => Uint8Array(32) [110, 142,  57,  56,  14, 100, 114, 174, 123, 245, 242, 112, 224,  94, 119,   0, 141, 246, 103, 254,  88,  53,  92,  73, 192, 127,  55,  99,  12, 231, 225,  55]
	const { signature } = secp256k1.ecdsaSign(new Uint8Array(digest), ecdsaPrivateKey, {
		noncefn: () => crypto.getRandomValues(new Uint8Array(32)),
	}) // => Uint8Array(64) [243,  68, 200, 124, 133, 155, 127, 194,  91, 216, 207, 158,  40,  62, 242,  98,  84,  44, 235,  80,  59, 162, 43,  70,  58,  96, 119, 215,  81,  88,  33,  44,   3, 76, 193, 110, 143, 240, 238, 108, 166,  62,  95,  48, 163,  69, 169, 184, 240, 243,  89, 152, 192, 173,  70, 249, 221,  44,  63,  29, 178,  65,   2, 112]
    const auth_signature = Buffer.from(signature).toString('hex') // 'f344c87c859b7fc25bd8cf9e283ef262542ceb503ba22b463a6077d75158212c034cc16e8ff0ee6ca63e5f30a345a9b8f0f35998c0ad46f9dd2c3f1db2410270'
	queryString += '&auth_signature=' + `${auth_signature}`
	return queryString // => 'auth_key=02f2b76aeecea808999383f63a5a8166a9b22c1fdc1debd8f72c4174b1c9491c47&auth_timestamp=1701389697&auth_version=1.0&body_md5=d41d8cd98f00b204e9800998ecf8427e&auth_signature=f344c87c859b7fc25bd8cf9e283ef262542ceb503ba22b463a6077d75158212c034cc16e8ff0ee6ca63e5f30a345a9b8f0f35998c0ad46f9dd2c3f1db2410270'
}

hexToUint8Array example Javascript implementation

function hexToUint8Array(hexString) {
	// Remove the "0x" prefix if it exists
	hexString = hexString.startsWith('0x') ? hexString.slice(2) : hexString

	// Ensure that the hex string has an even length
	if (hexString.length % 2 !== 0) {
		throw new Error(`Hex string must have an even number of characters: [${hexString}]`)
	}

	const byteArray = new Uint8Array(hexString.length / 2)

	for (let i = 0; i < hexString.length; i += 2) {
		const byte = Number.parseInt(hexString.substr(i, 2), 16)
		byteArray[i / 2] = byte
	}

	return byteArray
}

toOrderedArray example Javascript implementation

function toOrderedArray(map) {
  return Object.keys(map)
    .map(function (key) {
      return [key, map[key]]
    })
    .sort(function (a, b) {
      if (a[0] < b[0]) {
        return -1
      }
      if (a[0] > b[0]) {
        return 1
      }
      return 0
    })
    .map(function (pair) {
      return pair[0] + "=" + pair[1]
    })
}