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
- Collect the original secret exactly as typed/scanned (including spaces/case).
-
Normalize preview: remove whitespace, convert to uppercase.
- If characters outside A–Z 2–7 = exist → the secret is malformed.
-
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.
-
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.
- Decode to bytes, import into Crypto (e.g., DataBlob → HMAC key).
- 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;
}
}
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)
Top comments (0)