Skip to content

Implementing custom secret encoding

Eugene Fox edited this page Sep 18, 2024 · 1 revision

Important

This tutorial is made for educational purposes only! Do not use custom encodings in public authenticator implementations!

Justification

This library is designed to be as flexible as possible to suit everyone's needs. This part is no exception, as it possibly can help to improve your service security by, say, encrypting the secret.

Another reason why this was implemented is an Internet Draft by Alexey Melnikov which proposes to use "Extended Hex" Base32 alphabet to encode and decode secrets in OTP URIs.

In the current real-world scenarios it is unlikely that you ever need to implement custom encoders.

Best practicies

✅ Do

  • Do consider creating custom secret encoding only for internal business apps implementations
  • Do consider creating custom secret encoding only if you believe that will improve your corporate app's security
  • Do implement a mechanism that allows users to use default Base32 encoding as well.

❌ Don't

  • Do not ever implement custom encoders for creating otpauth: URIs in public services.

Scenario 1: Implementing custom Base32 alphabet

Let's say, you want to adhere the SCRAM specification, and instead of standard Base32, use Base32 "Extended Hex" alphabet.

The Base 32 Alphabet (RFC 4648 §6)
 Value Encoding  Value Encoding  Value Encoding  Value Encoding
     0 A             9 J            18 S            27 3
     1 B            10 K            19 T            28 4
     2 C            11 L            20 U            29 5
     3 D            12 M            21 V            30 6
     4 E            13 N            22 W            31 7
     5 F            14 O            23 X
     6 G            15 P            24 Y         (pad) =
     7 H            16 Q            25 Z
     8 I            17 R            26 2
The "Extended Hex" Base 32 Alphabet (RFC 4648 §7)
 Value Encoding  Value Encoding  Value Encoding  Value Encoding
     0 0             9 9            18 I            27 R
     1 1            10 A            19 J            28 S
     2 2            11 B            20 K            29 T
     3 3            12 C            21 L            30 U
     4 4            13 D            22 M            31 V
     5 5            14 E            23 N
     6 6            15 F            24 O         (pad) =
     7 7            16 G            25 P
     8 8            17 H            26 Q

This is relatively easy one. Default Base32Encoder provides override methods for character-to-value conversion and vice versa. All you need to do, is create a new class that will inherit Base32Encoder and override its methods:

// Base32HexEncoder.cs
using SimpleOTP.Encoding;

namespace MyProgram;

/// <summary>
/// Base32 "Extended Hex" encoder.
/// </summary>
public class Base32HexEncoder : Base32Encoder
{
	/// <inheritdoc />
	public override string Scheme => "base32hex";

	/// <inheritdoc />
	protected override int CharToValue(char c) =>
		(int)c switch
		{
			> 0x2F and < 0x3A => c - 0x30,		// Digits
			> 0x40 and < 0x57 => c - 0x37,		// Uppercase letters
			> 0x60 and < 0x77 => c - 0x57,		// Lowercase letters
			_ => throw new ArgumentException("Character is not a Base32 Hex character.", nameof(c)),
		};

	/// <inheritdoc />
	protected override char ValueToChar(int value) =>
		value switch
		{
			< 10 => (char)(value + 0x30),		// Digits
			< 32 => (char)(value + 0x37),		// Uppercase letters
			_ => throw new ArgumentException("Byte is not a Base32 Hex byte.", nameof(value)),
		};
}

Scenario 2: Implementing other encodings

For example, you want to use any another encoding, or even add some additional encryption to the secret.

In this case, you need to create a new class that implements SimpleOTP.Encoding.IEncoder interface. Then you can use its constructor to pass any additional parameters, such as encryption key.

This example creates a new encoder that uses Base64 encoding with an addtional layer of AES encryption:

// Base64AesEncoder.cs
using SimpleOTP.Encoding;
using System.Security.Cryptography;

namespace MyProgram;

/// <summary>
/// Base64 encoder with AES encryption.
/// </summary>
/// <param name="key">The secret key used for encryption.</param>
public class Base64AesEncoder(byte[] key) : IEncoder
{
	/// <inheritdoc />
	public string Scheme => "base64-aes";

	/// <summary>
	/// The secret key used for encryption.
	/// </summary>
	public byte[] Key { get; } = key;

	/// <summary>
	/// Initializes a new instance of the <see cref="Base64AesEncoder"/> class.
	/// </summary>
	/// <param name="key">The secret key used for encryption in Base64 encoding.</param>
	public Base64AesEncoder(string key) : this(Convert.FromBase64String(key)) { }

	/// <inheritdoc />
	public string EncodeBytes(byte[] data)
	{
		using Aes aes = Aes.Create();

		ICryptoTransform encryptor = aes.CreateEncryptor();
		return Convert.ToBase64String(encryptor.TransformFinalBlock(data, 0, data.Length));
	}

	/// <inheritdoc />
	public byte[] GetBytes(string data)
	{
		byte[] dataBytes = Convert.FromBase64String(data);

		using Aes aes = Aes.Create();

		ICryptoTransform decryptor = aes.CreateDecryptor();
		return decryptor.TransformFinalBlock(dataBytes, 0, dataBytes.Length);
	}
}

Using the encoder

There are multiple ways you can use your custom encoder:

Directly creating OtpSecret object

string base32 = "KRUGKIDROVUWG2ZAMJZG653OEBTG66BO"; // Base32
string base32hex = "AHK6A83HELKM6QP0C9P6UTRE41J6UU1E"; // Base32 "Extended Hex"

// Decode
IEncoder encoder = new Base32HexEncoder();
OtpSecret secret = OtpSecret.Parse(base32);
OtpSecret hexSecret = OtpSecret.Parse(base32hex, encoder);

// Encode
Console.WriteLine(secret == hexSecret); // True
Console.WriteLine(secret.ToString()); // KRUGKIDROVUWG2ZAMJZG653OEBTG66BO (Base32)
Console.WriteLine(secret.ToString(encoder)); // AHK6A83HELKM6QP0C9P6UTRE41J6UU1E (Base32 "Extended Hex")

When parsing OtpConfig

// OTP URI with Base32 "Extended Hex" encoded secret
string uri = "otpauth://totp/user@example.com?secret=AHK6A83HELKM6QP0C9P6UTRE41J6UU1E&issuer=example.com";

OtpConfig config = OtpConfig.Parse(uri, new Base32HexEncoder());

Overriding default encoder

OtpSecret.DefaultEncoder = new Base32HexEncoder();

string base32hex = "AHK6A83HELKM6QP0C9P6UTRE41J6UU1E"; // Base32 "Extended Hex"

OtpSecret secret = OtpSecret.Parse(base32hex);

Console.WriteLine(secret.ToString()); // AHK6A83HELKM6QP0C9P6UTRE41J6UU1E (Base32 "Extended Hex")

Important

JSON/XML Serialization will always use default encoder. You can override it globally via OtpSecret.DefaultEncoder property.

Important

There is no implemented mechanism to detect custom encodings.

Clone this wiki locally