-
Notifications
You must be signed in to change notification settings - Fork 0
Implementing custom secret encoding
Important
This tutorial is made for educational purposes only! Do not use custom encodings in public authenticator implementations!
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.
- 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.
-
Do not ever implement custom encoders for creating
otpauth:URIs in public services.
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)),
};
}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);
}
}There are multiple ways you can use your custom encoder:
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")// 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());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.
©2025 Eugene Fox. Licensed under MIT license