Skip to content

Instantly share code, notes, and snippets.

@TheFanatr
Last active March 8, 2026 02:58
Show Gist options
  • Select an option

  • Save TheFanatr/7f89ae4db170a32245c38d366af9da36 to your computer and use it in GitHub Desktop.

Select an option

Save TheFanatr/7f89ae4db170a32245c38d366af9da36 to your computer and use it in GitHub Desktop.
decode `otpauth-migration` URI payloads as a list of `otpauth` URIs. compatible with Google Authenticator exports.

otpauth-migration OTP Payload Decoder (to otpauth://totp/ URIs)

the code here was not working so I re-wrote and fixed it. it now works with Google Authenticator exports.

the generated pb2.pyi and pb2.py files for otpauth-migration.proto are generated by mypi-protobuf at build time. that configuration was not ported here. see the original repo if needed.

syntax = "proto3";
package otpauth_migration;
message Payload {
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
ALGORITHM_SHA1 = 1;
ALGORITHM_SHA256 = 2;
ALGORITHM_SHA512 = 3;
ALGORITHM_MD5 = 4;
}
enum DigitCount {
DIGIT_COUNT_UNSPECIFIED = 0;
DIGIT_COUNT_SIX = 1;
DIGIT_COUNT_EIGHT = 2;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
OTP_TYPE_HOTP = 1;
OTP_TYPE_TOTP = 2;
}
message OtpParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
DigitCount digits = 5;
OtpType type = 6;
int64 counter = 7;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}
import sys
from typing import (
Union,
)
from urllib.parse import (
parse_qs,
quote,
unquote,
urlencode,
urlparse,
)
from otpauth_migration_pb2 import Payload
from base64 import (
b32encode,
b64decode,
)
from collections.abc import Mapping
Algorithm: Mapping[int, str] = {
1: 'SHA1',
2: 'SHA256',
3: 'SHA512',
4: 'MD5',
}
DigitCount: Mapping[int, str] = {
1: '6',
2: '8',
}
OtpType: Mapping[int, str] = {
1: 'hotp',
2: 'totp',
}
SCHEME = 'otpauth-migration'
HOSTNAME = 'offline'
PAYLOAD_MARK = 'data'
EXAMPLE_PAYLOAD = 'CjEKCkhlbGxvId6tvu8SGEV4YW1wbGU6YWxpY2VAZ29vZ2xlLmNvbRoHRXhhbXBsZTAC'
EXAMPLE_MIGRATION = f'{SCHEME}://{HOSTNAME}?{PAYLOAD_MARK}={EXAMPLE_PAYLOAD}'
def get_url_params(otp: Payload.OtpParameters) -> str:
params: dict[str, Union[str, int]] = {}
if otp.algorithm:
params.update(algorithm=Algorithm.get(otp.algorithm, ''))
if otp.digits:
params.update(digits=DigitCount.get(otp.digits, ''))
if otp.issuer:
params.update(issuer=otp.issuer)
if otp.secret:
params.update(secret=str(b32encode(otp.secret), 'utf-8').replace('=', ''))
return urlencode(params)
def print_otpauth_migration_payloads(migration_uri: str) -> None:
for payload in parse_qs(urlparse(migration_uri).query)['data']:
migration_payload = Payload()
migration_payload.ParseFromString(b64decode(unquote(payload)))
for otp_item in migration_payload.otp_parameters:
otp_type = OtpType.get(otp_item.type, '')
otp_name = quote(otp_item.name)
otp_params = get_url_params(otp_item)
print(f'otpauth://{otp_type}/{otp_name}?{otp_params}')
if __name__ == '__main__':
print_otpauth_migration_payloads(sys.argv[1])
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: otpauth-migration.proto
"""Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17otpauth-migration.proto\x12\x11otpauth_migration\"\xa7\x05\n\x07Payload\x12@\n\x0eotp_parameters\x18\x01 \x03(\x0b\x32(.otpauth_migration.Payload.OtpParameters\x12\x0f\n\x07version\x18\x02 \x01(\x05\x12\x12\n\nbatch_size\x18\x03 \x01(\x05\x12\x13\n\x0b\x62\x61tch_index\x18\x04 \x01(\x05\x12\x10\n\x08\x62\x61tch_id\x18\x05 \x01(\x05\x1a\xf0\x01\n\rOtpParameters\x12\x0e\n\x06secret\x18\x01 \x01(\x0c\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06issuer\x18\x03 \x01(\t\x12\x37\n\talgorithm\x18\x04 \x01(\x0e\x32$.otpauth_migration.Payload.Algorithm\x12\x35\n\x06\x64igits\x18\x05 \x01(\x0e\x32%.otpauth_migration.Payload.DigitCount\x12\x30\n\x04type\x18\x06 \x01(\x0e\x32\".otpauth_migration.Payload.OtpType\x12\x0f\n\x07\x63ounter\x18\x07 \x01(\x03\"y\n\tAlgorithm\x12\x19\n\x15\x41LGORITHM_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x41LGORITHM_SHA1\x10\x01\x12\x14\n\x10\x41LGORITHM_SHA256\x10\x02\x12\x14\n\x10\x41LGORITHM_SHA512\x10\x03\x12\x11\n\rALGORITHM_MD5\x10\x04\"U\n\nDigitCount\x12\x1b\n\x17\x44IGIT_COUNT_UNSPECIFIED\x10\x00\x12\x13\n\x0f\x44IGIT_COUNT_SIX\x10\x01\x12\x15\n\x11\x44IGIT_COUNT_EIGHT\x10\x02\"I\n\x07OtpType\x12\x18\n\x14OTP_TYPE_UNSPECIFIED\x10\x00\x12\x11\n\rOTP_TYPE_HOTP\x10\x01\x12\x11\n\rOTP_TYPE_TOTP\x10\x02\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'otpauth_migration_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_globals['_PAYLOAD']._serialized_start=47
_globals['_PAYLOAD']._serialized_end=726
_globals['_PAYLOAD_OTPPARAMETERS']._serialized_start=201
_globals['_PAYLOAD_OTPPARAMETERS']._serialized_end=441
_globals['_PAYLOAD_ALGORITHM']._serialized_start=443
_globals['_PAYLOAD_ALGORITHM']._serialized_end=564
_globals['_PAYLOAD_DIGITCOUNT']._serialized_start=566
_globals['_PAYLOAD_DIGITCOUNT']._serialized_end=651
_globals['_PAYLOAD_OTPTYPE']._serialized_start=653
_globals['_PAYLOAD_OTPTYPE']._serialized_end=726
# @@protoc_insertion_point(module_scope)
"""
@generated by mypy-protobuf. Do not edit manually!
isort:skip_file
"""
import builtins
import google.protobuf.descriptor
import google.protobuf.internal.containers
import google.protobuf.internal.enum_type_wrapper
import google.protobuf.message
import typing
import typing_extensions
DESCRIPTOR: google.protobuf.descriptor.FileDescriptor = ...
class Payload(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor = ...
class Algorithm(metaclass=_Algorithm):
V = typing.NewType('V', builtins.int)
ALGORITHM_UNSPECIFIED = Payload.Algorithm.V(0)
ALGORITHM_SHA1 = Payload.Algorithm.V(1)
ALGORITHM_SHA256 = Payload.Algorithm.V(2)
ALGORITHM_SHA512 = Payload.Algorithm.V(3)
ALGORITHM_MD5 = Payload.Algorithm.V(4)
class _Algorithm(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Algorithm.V], builtins.type):
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor = ...
ALGORITHM_UNSPECIFIED = Payload.Algorithm.V(0)
ALGORITHM_SHA1 = Payload.Algorithm.V(1)
ALGORITHM_SHA256 = Payload.Algorithm.V(2)
ALGORITHM_SHA512 = Payload.Algorithm.V(3)
ALGORITHM_MD5 = Payload.Algorithm.V(4)
class DigitCount(metaclass=_DigitCount):
V = typing.NewType('V', builtins.int)
DIGIT_COUNT_UNSPECIFIED = Payload.DigitCount.V(0)
DIGIT_COUNT_SIX = Payload.DigitCount.V(1)
DIGIT_COUNT_EIGHT = Payload.DigitCount.V(2)
class _DigitCount(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[DigitCount.V], builtins.type):
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor = ...
DIGIT_COUNT_UNSPECIFIED = Payload.DigitCount.V(0)
DIGIT_COUNT_SIX = Payload.DigitCount.V(1)
DIGIT_COUNT_EIGHT = Payload.DigitCount.V(2)
class OtpType(metaclass=_OtpType):
V = typing.NewType('V', builtins.int)
OTP_TYPE_UNSPECIFIED = Payload.OtpType.V(0)
OTP_TYPE_HOTP = Payload.OtpType.V(1)
OTP_TYPE_TOTP = Payload.OtpType.V(2)
class _OtpType(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[OtpType.V], builtins.type):
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor = ...
OTP_TYPE_UNSPECIFIED = Payload.OtpType.V(0)
OTP_TYPE_HOTP = Payload.OtpType.V(1)
OTP_TYPE_TOTP = Payload.OtpType.V(2)
class OtpParameters(google.protobuf.message.Message):
DESCRIPTOR: google.protobuf.descriptor.Descriptor = ...
SECRET_FIELD_NUMBER: builtins.int
NAME_FIELD_NUMBER: builtins.int
ISSUER_FIELD_NUMBER: builtins.int
ALGORITHM_FIELD_NUMBER: builtins.int
DIGITS_FIELD_NUMBER: builtins.int
TYPE_FIELD_NUMBER: builtins.int
COUNTER_FIELD_NUMBER: builtins.int
secret: builtins.bytes = ...
name: typing.Text = ...
issuer: typing.Text = ...
algorithm: global___Payload.Algorithm.V = ...
digits: global___Payload.DigitCount.V = ...
type: global___Payload.OtpType.V = ...
counter: builtins.int = ...
def __init__(self,
*,
secret : builtins.bytes = ...,
name : typing.Text = ...,
issuer : typing.Text = ...,
algorithm : global___Payload.Algorithm.V = ...,
digits : global___Payload.DigitCount.V = ...,
type : global___Payload.OtpType.V = ...,
counter : builtins.int = ...,
) -> None: ...
def ClearField(self, field_name: typing_extensions.Literal[u"algorithm",b"algorithm",u"counter",b"counter",u"digits",b"digits",u"issuer",b"issuer",u"name",b"name",u"secret",b"secret",u"type",b"type"]) -> None: ...
OTP_PARAMETERS_FIELD_NUMBER: builtins.int
VERSION_FIELD_NUMBER: builtins.int
BATCH_SIZE_FIELD_NUMBER: builtins.int
BATCH_INDEX_FIELD_NUMBER: builtins.int
BATCH_ID_FIELD_NUMBER: builtins.int
version: builtins.int = ...
batch_size: builtins.int = ...
batch_index: builtins.int = ...
batch_id: builtins.int = ...
@property
def otp_parameters(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___Payload.OtpParameters]: ...
def __init__(self,
*,
otp_parameters : typing.Optional[typing.Iterable[global___Payload.OtpParameters]] = ...,
version : builtins.int = ...,
batch_size : builtins.int = ...,
batch_index : builtins.int = ...,
batch_id : builtins.int = ...,
) -> None: ...
def ClearField(self, field_name: typing_extensions.Literal[u"batch_id",b"batch_id",u"batch_index",b"batch_index",u"batch_size",b"batch_size",u"otp_parameters",b"otp_parameters",u"version",b"version"]) -> None: ...
global___Payload = Payload
[project]
name = "otpauth-migration-decoder"
version = "1.0"
description = "decode otpauth-migration:// URIs to contained otpauth://totp/ URIs"
requires-python = ">=3.14"
dependencies = [
"protobuf>=4.22.1"
]
[dependency-groups]
dev = [
"types-protobuf",
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment