DEV Community

HarmonyOS
HarmonyOS

Posted on

Base32 Missing = Padding Causes TOTP (HMAC) Mismatch in HarmonyOS

Read the original article:Base32 Missing = Padding Causes TOTP (HMAC) Mismatch in HarmonyOS

Problem Description

TOTP codes generated on the app never match external authenticators for the same secret. Some user-entered secrets decode "fine" or intermittently fail, but the resulting codes are always incorrect.

Background Knowledge

  • TOTP (RFC 6238) uses HOTP (RFC 4226) → HMAC over an 8-byte counter using the raw secret bytes.
  • Secrets are typically Base32 (RFC 4648). Many issuers omit padding (=) or add spaces/lowercase for readability.
  • Base32 decoding expects input length to be a multiple of 8 characters; valid unpadded remainders are 2, 4, 5, 7 (others are invalid). Missing padding must be restored before decoding.

Troubleshooting Process

  1. Collect the original secret exactly as typed/scanned (including spaces/case).
  2. Normalize preview: remove whitespace, convert to uppercase.
    • If characters outside A–Z 2–7 = exist → the secret is malformed.
  3. Check length remainder: len % 8 ∈ {0,2,4,5,7} is potentially valid.
    • If the remainder is 1,3,6 → reject; do not try to decode.
  4. Padding audit:
    • If the remainder is 0 and the secret ends with = in the middle (e.g., A===B) → invalid placement.
    • If remainder ∈ {2,4,5,7} → compute how many = to append to reach next multiple of 8.
  5. Decode to bytes, import into Crypto (e.g., DataBlob → HMAC key).
  6. Recompute TOTP with UTC epoch seconds, correct step (30s), and dynamic truncation.

Analysis Conclusion

Mismatch arises because the app decodes an incorrect byte array when the Base32 input is missing/incorrect padding (or not normalized).

This produces a different HMAC digest from the issuer's reference implementation, so the displayed TOTP never matches.

Solution

  • Normalize input: strip spaces, toUpperCase().
  • Validate alphabet: ^[A-Z2-7=]+$
  • Restore padding only when len % 8 ∈ {2,4,5,7} by appending = to reach a multiple of 8.
    • Remainder→Padding: 2→6, 4→4, 5→3, 7→1.
  • Reject inputs with remainder 1,3,6, mid-string = padding, or illegal characters.
  • Decode to Uint8Array and import as HMAC key (SHA-1 unless issuer specifies SHA-256/512).
  • Ensure counter is big-endian and truncation masks with 0x7fffffff before modulo 10^digits.
export class Base32 {
  /**
   * only uppercase A–Z, digits 2–7, and '=' padding allowed, RFC4648 standard
   */
  static isValid(input: string): boolean {
    const regex = /^[A-Z2-7=]+$/;
    return regex.test(input);
  }

  /**
   * Takes in a Base32 string and decodes it back to a Uint8Array
   */
  static decode(base32: string): Uint8Array {
    if (!base32 || base32.length === 0) {
      return new Uint8Array(0);
    }

    // Normalize: remove spaces, trim edges, convert to uppercase
    base32 = base32.trim().replace(/\s+/g, '').toUpperCase();

    // Add missing padding ('=') if necessary
    base32 = Base32.pad(base32);

    // Reject if invalid alphabet characters remain
    if (!Base32.isValid(base32)) {
      throw new Error('Invalid Base32 characters');
    }

    const decodeMap = Base32.getDecodeMap();
    let length = base32.indexOf('=');
    if (length === -1) {
      length = base32.length; // no '=' found, decode entire string
    }

    let i = 0;
    const count = (length >> 3) << 3; // largest multiple of 8 <= length
    const bytes: number[] = [];

    // Main decoding loop: process chunks of 8 Base32 chars into 5 bytes
    while (i < count) {
      const v1 = decodeMap.get(base32[i++]) ?? 0;
      const v2 = decodeMap.get(base32[i++]) ?? 0;
      const v3 = decodeMap.get(base32[i++]) ?? 0;
      const v4 = decodeMap.get(base32[i++]) ?? 0;
      const v5 = decodeMap.get(base32[i++]) ?? 0;
      const v6 = decodeMap.get(base32[i++]) ?? 0;
      const v7 = decodeMap.get(base32[i++]) ?? 0;
      const v8 = decodeMap.get(base32[i++]) ?? 0;

      bytes.push(((v1 << 3) | (v2 >> 2)) & 0xff);
      bytes.push(((v2 << 6) | (v3 << 1) | (v4 >> 4)) & 0xff);
      bytes.push(((v4 << 4) | (v5 >> 1)) & 0xff);
      bytes.push(((v5 << 7) | (v6 << 2) | (v7 >> 3)) & 0xff);
      bytes.push(((v7 << 5) | v8) & 0xff);
    }

    // Handle remaining Base32 chars (< 8) at the end
    const remain = length - count;
    if (remain === 2) {
      const v1 = decodeMap.get(base32[i++]) ?? 0;
      const v2 = decodeMap.get(base32[i++]) ?? 0;
      bytes.push(((v1 << 3) | (v2 >> 2)) & 0xff);
    } else if (remain === 4) {
      const v1 = decodeMap.get(base32[i++]) ?? 0;
      const v2 = decodeMap.get(base32[i++]) ?? 0;
      const v3 = decodeMap.get(base32[i++]) ?? 0;
      const v4 = decodeMap.get(base32[i++]) ?? 0;
      bytes.push(((v1 << 3) | (v2 >> 2)) & 0xff);
      bytes.push(((v2 << 6) | (v3 << 1) | (v4 >> 4)) & 0xff);
    } else if (remain === 5) {
      const v1 = decodeMap.get(base32[i++]) ?? 0;
      const v2 = decodeMap.get(base32[i++]) ?? 0;
      const v3 = decodeMap.get(base32[i++]) ?? 0;
      const v4 = decodeMap.get(base32[i++]) ?? 0;
      const v5 = decodeMap.get(base32[i++]) ?? 0;
      bytes.push(((v1 << 3) | (v2 >> 2)) & 0xff);
      bytes.push(((v2 << 6) | (v3 << 1) | (v4 >> 4)) & 0xff);
      bytes.push(((v4 << 4) | (v5 >> 1)) & 0xff);
    } else if (remain === 7) {
      const v1 = decodeMap.get(base32[i++]) ?? 0;
      const v2 = decodeMap.get(base32[i++]) ?? 0;
      const v3 = decodeMap.get(base32[i++]) ?? 0;
      const v4 = decodeMap.get(base32[i++]) ?? 0;
      const v5 = decodeMap.get(base32[i++]) ?? 0;
      const v6 = decodeMap.get(base32[i++]) ?? 0;
      const v7 = decodeMap.get(base32[i++]) ?? 0;
      bytes.push(((v1 << 3) | (v2 >> 2)) & 0xff);
      bytes.push(((v2 << 6) | (v3 << 1) | (v4 >> 4)) & 0xff);
      bytes.push(((v4 << 4) | (v5 >> 1)) & 0xff);
      bytes.push(((v5 << 7) | (v6 << 2) | (v7 >> 3)) & 0xff);
    }

    return new Uint8Array(bytes);
  }

  /**
   * Add padding ('=') until length is multiple of 8
   */
  private static pad(input: string): string {
    const neededPadding = (8 - (input.length % 8)) % 8;
    return input + '='.repeat(neededPadding);
  }

  /**
   * Build lookup map from Base32 alphabet to numeric values
   */
  private static getDecodeMap(): Map<string, number> {
    const map = new Map<string, number>();
    const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    for (let i = 0; i < alphabet.length; i++) {
      map.set(alphabet.charAt(i), i);
    }
    return map;
  }
}
Enter fullscreen mode Exit fullscreen mode

Verification Result

  • Known test vectors (same secret & timestamp) produce the same TOTP as external authenticators.
  • Edge cases (no padding, lowercase, spaced secrets) decode to the same bytes after normalization/padding rules.
  • Invalid shapes (remainders 1/3/6, bad = placement) are reliably rejected with clear error messages.

Related Documents or Links

HarmonyOS (ArkTS) – CryptoArchitectureKit (HMAC/MAC usage)

Written by Bilal Basboz

Top comments (0)