Showing posts with label Base32. Show all posts
Showing posts with label Base32. Show all posts

Tuesday, 20 September 2016

C# OTP Implementation with TOTP and HOTP

Sample implementation of HOTP and TOTP One Time Passwords (OTP) in C# with .NET Core

This includes an example of bacis caching which can easily be tied into an IMemoryCache instance for web usage.

Gist available at https://gist.github.com/BravoTango86/9ebb578fa4df3a0ffed28bd634f8f3c0
/*
 * Copyright (C) 2016 BravoTango86
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using static OtpAuthenticator;

public class OtpAuthenticator : IDisposable {

    public ICachingProvider CachingProvider { get; set; }
    public int CodeLength { get; set; } = 6;
    public long HotpCounter { get; set; } = 0;
    public int TotpInterval { get; set; } = 30;
    public long TotpTimeOffsetSeconds { get; set; } = 0;

    private OtpType Type;
    private HMAC HMAC;

    public OtpAuthenticator(OtpType type, OtpAlgorithm algorithm = OtpAlgorithm.SHA1, byte[] key = null) {
        HMAC = GetHMAC(algorithm, key ?? GenerateKey(algorithm));
        Type = type;
    }

    public bool VerifyOtp(string code, byte[] challenge = null, int forward = 1, int back = 1) {
        if (string.IsNullOrEmpty(code) || code.Length != CodeLength || (CachingProvider != null && !CachingProvider.ValidateToken(code))) return false;
        long State = GetState(-back);
        List<string> Tried = new List<string>();
        for (int I = 0; I <= (forward + back); I++) {
            string Code = GetOtp(state: State + I, challenge: challenge);
            Tried.Add(code);
            if (Code == code) {
                if (CachingProvider != null) CachingProvider.CancelTokens(Type, Tried);
                if (Type == OtpType.HOTP) HotpCounter = State + I + 1;
                return true;
            }
        }
        return false;
    }

    private long GetState(int offset) {
        return (Type == OtpType.HOTP ? HotpCounter : (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TotpTimeOffsetSeconds) / TotpInterval) + offset;
    }

    public string GetOtp(int offset = 0, byte[] challenge = null) {
        return GetOtp(GetState(offset), challenge);
    }

    private string GetOtp(long state, byte[] challenge = null) {
        byte[] Input = BitConverter.GetBytes(state);
        if (BitConverter.IsLittleEndian) Array.Reverse(Input);
        if (challenge != null) {
            Array.Resize(ref Input, Input.Length + challenge.Length);
            Buffer.BlockCopy(challenge, 0, Input, 8, challenge.Length);
        }
        byte[] Hash = HMAC.ComputeHash(Input);
        int offset = Hash[Hash.Length - 1] & 0xf;
        int binary = ((Hash[offset] & 0x7f) << 24) | ((Hash[offset + 1] & 0xff) << 16) |
                        ((Hash[offset + 2] & 0xff) << 8) | (Hash[offset + 3] & 0xff);
        return (binary % (int)Math.Pow(10, CodeLength)).ToString(new string('0', CodeLength));
    }

    public string GetIntegrityValue() {
        return GetOtp(0);
    }

    public static string GetUri(OtpType type, byte[] key, string accountName, string issuer = "", OtpAlgorithm algorithm = OtpAlgorithm.SHA1,
                                        int codeLength = 6, long counter = 0, int period = 30) {
        StringBuilder SB = new StringBuilder();
        SB.AppendFormat("otpauth://{0}/", type.ToString().ToLower());
        if (!string.IsNullOrEmpty(issuer)) SB.AppendFormat("{0}:{1}?issuer={0}&", Uri.EscapeUriString(issuer), Uri.EscapeUriString(accountName));
        else SB.AppendFormat("{0}?", Uri.EscapeUriString(accountName));
        SB.AppendFormat("secret={0}&algorithm={1}&digits={2}&", Base32.Encode(key), algorithm, codeLength);
        if (type == OtpType.HOTP) SB.AppendFormat("counter={0}", counter);
        else SB.AppendFormat("period={0}", period);
        return SB.ToString();
    }

    public string GetUri(string accountName, string issuer = "") {
        return GetUri(Type, HMAC.Key, accountName, issuer = "", (OtpAlgorithm)Enum.Parse(typeof(OtpAlgorithm), HMAC.HashName),
                            CodeLength, HotpCounter, TotpInterval);
    }

    public override string ToString() {
        return GetUri("OtpGenerator");
    }

    public static byte[] GenerateKey(OtpAlgorithm algorithm) {
        return GenerateKey(GetHashLength(algorithm));
    }

    public static byte[] GenerateKey(int length) {
        using (RandomNumberGenerator RNG = RandomNumberGenerator.Create()) {
            byte[] Output = new byte[length];
            RNG.GetBytes(Output);
            return Output;
        }
    }

    public void Dispose() {
        HMAC.Dispose();
    }

    private static HMAC GetHMAC(OtpAlgorithm algorithm, byte[] key) {
        switch (algorithm) {
            case OtpAlgorithm.MD5: return new HMACMD5(key);
            case OtpAlgorithm.SHA1: return new HMACSHA1(key);
            case OtpAlgorithm.SHA256: return new HMACSHA256(key);
            case OtpAlgorithm.SHA512: return new HMACSHA512(key);
        }
        throw new InvalidOperationException();
    }

    private static int GetHashLength(OtpAlgorithm algorithm) {
        switch (algorithm) {
            case OtpAlgorithm.MD5: return 32;
            case OtpAlgorithm.SHA1: return 20;
            case OtpAlgorithm.SHA256: return 32;
            case OtpAlgorithm.SHA512: return 64;
        }
        throw new InvalidOperationException();
    }

}


public enum OtpType {
    HOTP = 0,
    TOTP = 1
}

public enum OtpAlgorithm {
    MD5 = 10,
    SHA1 = 1,
    SHA256 = 2,
    SHA512 = 3
}

public interface ICachingProvider {
    void CancelToken(OtpType type, string token);
    void CancelTokens(OtpType type, IEnumerable<string> tokens);
    bool ValidateToken(string token);
}

public class LocalCachingProvider : ICachingProvider {

    private List<string> Used = new List<string>();

    public void CancelToken(OtpType type, string token) {
        Used.Add(token);
    }

    public void CancelTokens(OtpType type, IEnumerable<string> tokens) {
        Used.AddRange(tokens);
    }

    public bool ValidateToken(string token) {
        return !Used.Contains(token);
    }
}

Borrowing the barcode generator from before...

public static void Main(string[] args) {
    Console.OutputEncoding = Encoding.UTF8;
    Console.WindowWidth = 86;
    Console.WindowHeight = 44;
    StringRenderer Renderer = new StringRenderer() { Block = "  ", Empty = "\u2588\u2588", NewLine = "\n    ", };
    EncodingOptions Options = new EncodingOptions { Height = 0, Width = 0, Margin = 1 };
    using (OtpAuthenticator Authenticator = new OtpAuthenticator(OtpType.TOTP) { CachingProvider = new LocalCachingProvider() }) {
        Console.WriteLine("\n{1}{0}{1}", BarcodeGenerator.Generate(Renderer, Authenticator.GetUri("Test Account", "OTPGenerator"), Options), Renderer.NewLine);
        while (true) {
            string Code = Console.ReadLine();
            if (Authenticator.VerifyOtp(Code)) Console.WriteLine("Code Accepted");
            else Console.WriteLine("Code Invalid");
        }
    }
}

...and scanning the generated barcode with Google Authenticator, we can check it works:

Base32 Encoding and Decoding in C#

Gist available at https://gist.github.com/BravoTango86/2a085185c3b9bd8383a1f956600e515f
/*
 * Derived from https://github.com/google/google-authenticator-android/blob/master/AuthenticatorApp/src/main/java/com/google/android/apps/authenticator/Base32String.java
 * 
 * Copyright (C) 2016 BravoTango86
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;

public static class Base32 {

    private static readonly char[] DIGITS;
    private static readonly int MASK;
    private static readonly int SHIFT;
    private static Dictionary<char, int> CHAR_MAP = new Dictionary<char, int>();
    private const string SEPARATOR = "-";

    static Base32() {
        DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray();
        MASK = DIGITS.Length - 1;
        SHIFT = numberOfTrailingZeros(DIGITS.Length);
        for (int i = 0; i < DIGITS.Length; i++) CHAR_MAP[DIGITS[i]] = i;
    }

    private static int numberOfTrailingZeros(int i) {
        // HD, Figure 5-14
        int y;
        if (i == 0) return 32;
        int n = 31;
        y = i << 16; if (y != 0) { n = n - 16; i = y; }
        y = i << 8; if (y != 0) { n = n - 8; i = y; }
        y = i << 4; if (y != 0) { n = n - 4; i = y; }
        y = i << 2; if (y != 0) { n = n - 2; i = y; }
        return n - (int)((uint)(i << 1) >> 31);
    }

    public static byte[] Decode(string encoded) {
        // Remove whitespace and separators
        encoded = encoded.Trim().Replace(SEPARATOR, "");

        // Remove padding. Note: the padding is used as hint to determine how many
        // bits to decode from the last incomplete chunk (which is commented out
        // below, so this may have been wrong to start with).
        encoded = Regex.Replace(encoded, "[=]*$", "");

        // Canonicalize to all upper case
        encoded = encoded.ToUpper();
        if (encoded.Length == 0) {
            return new byte[0];
        }
        int encodedLength = encoded.Length;
        int outLength = encodedLength * SHIFT / 8;
        byte[] result = new byte[outLength];
        int buffer = 0;
        int next = 0;
        int bitsLeft = 0;
        foreach (char c in encoded.ToCharArray()) {
            if (!CHAR_MAP.ContainsKey(c)) {
                throw new DecodingException("Illegal character: " + c);
            }
            buffer <<= SHIFT;
            buffer |= CHAR_MAP[c] & MASK;
            bitsLeft += SHIFT;
            if (bitsLeft >= 8) {
                result[next++] = (byte)(buffer >> (bitsLeft - 8));
                bitsLeft -= 8;
            }
        }
        // We'll ignore leftover bits for now.
        //
        // if (next != outLength || bitsLeft >= SHIFT) {
        //  throw new DecodingException("Bits left: " + bitsLeft);
        // }
        return result;
    }


    public static string Encode(byte[] data, bool padOutput = false) {
        if (data.Length == 0) {
            return "";
        }

        // SHIFT is the number of bits per output character, so the length of the
        // output is the length of the input multiplied by 8/SHIFT, rounded up.
        if (data.Length >= (1 << 28)) {
            // The computation below will fail, so don't do it.
            throw new ArgumentOutOfRangeException("data");
        }

        int outputLength = (data.Length * 8 + SHIFT - 1) / SHIFT;
        StringBuilder result = new StringBuilder(outputLength);

        int buffer = data[0];
        int next = 1;
        int bitsLeft = 8;
        while (bitsLeft > 0 || next < data.Length) {
            if (bitsLeft < SHIFT) {
                if (next < data.Length) {
                    buffer <<= 8;
                    buffer |= (data[next++] & 0xff);
                    bitsLeft += 8;
                } else {
                    int pad = SHIFT - bitsLeft;
                    buffer <<= pad;
                    bitsLeft += pad;
                }
            }
            int index = MASK & (buffer >> (bitsLeft - SHIFT));
            bitsLeft -= SHIFT;
            result.Append(DIGITS[index]);
        }
        if (padOutput) {
            int padding = 8 - (result.Length % 8);
            if (padding > 0) result.Append(new string('=', padding == 8 ? 0 : padding));
        }
        return result.ToString();
    }

    private class DecodingException : Exception {
        public DecodingException(string message) : base(message) {
        }
    }
}