Hi,
our company has decided to migrate to privacyIDEA for 2FA VPN. We use PIN + TOTP. Our experience with PI is so far really good, but we have encountered a problem along the way on migrating existing users into PI.
We were able to import tokens, totp secrets, but not PINs, for that we didin’t find any viable solution, so we created our own and since this community help us a lot when engineering our solution we would like to, hopefully, help someone too.
As I said we were unable to migrate PINs, PI was not able to do that. Legary solution was pfSense with Radius plugin. From there we exported XML format file with secrets and PINs in readable format. Since we use Postgres container as DB for PI we added pgAdmin. In pgAdmin (if you go with Postgres, if not then in any other) you will find db table Token. In there we were able to update pin_hash column to our pin in argon2 hash and it worked.
Since we have hundreds of employees we created a python script which takes XML exported from pfSence hashes their PINs and created new SQL update file. Then you can just run this UPDATE query and it will associate hashed PINs to its owners. This association is based on column serial. Which in our case is AD username of a user (token owner).
DISCLAIMER: You need to import token secrets first. This code was tailored for Postgres, so it doesn’t have to work with other RDBS. Our name of token (column serial) was unique so were able to associate PIN to owner.
Python script:
import os
import argon2
import xml.etree.ElementTree as ET
# --- Configuration Argon2 ---
# you need to have python and argon2 installed
# RUN: $python -m pip install argon2-cffi
try:
hasher = argon2.PasswordHasher(
time_cost=9,
parallelism=4,
memory_cost=65536
)
except ImportError:
print("Error: Library argon2-cffi is not installed.")
print("You can install it with: pip install argon2-cffi")
exit()
def hash_pin(pin_to_hash):
"""
Hash PIN with Argon2. Salt is generated automaticly and
part of final hash.
"""
try:
hashed_value = hasher.hash(pin_to_hash)
return hashed_value
except Exception as e:
print(f"An error occured while hashing pin: {e}")
return None
# --- Main logic ---
values = []
try:
password_file_path = input("Enter path to your exported configuration: ")
print("Processing file...")
tree = ET.parse(password_file_path)
root = tree.getroot()
for element in root.iter():
serial_element = element.find('varusersusername')
pin_element = element.find('varusersmotppin')
if serial_element is not None and pin_element is not None and serial_element.text and pin_element.text:
serial = serial_element.text.strip()
pin = pin_element.text.strip()
if serial and pin:
print(f"Found user '{serial}'... Hashing PIN.")
hashed_pin = hash_pin(pin)
if hashed_pin:
# Delete apostrofs for secure insert into SQL (' -> '')
safe_serial = serial.replace("'", "''")
values.append(f"('{safe_serial}', '{hashed_pin}')")
except FileNotFoundError:
print(f"ERROR: File in path '{password_file_path}' was not found.")
except ET.ParseError:
print("ERROR: File doesn't have valid XML structure.")
except Exception as e:
print(f"Došlo k neočekávané chybě: {e}")
if values:
print(f"\nSuccessfully finished hashing {len(values)} PINs.")
print("--- Generating SQL UPDATE script ---")
values_string = ",\n".join(f" {v}" for v in values)
# Vytvoříme finální, syntakticky správný SQL příkaz
sql_script = f"""-- Automaticaly generated script for insertion of PINs in PrivacyIDEA
-- Number of users: {len(values)}
UPDATE public.token
SET pin_hash = v.hash
FROM (
VALUES
{values_string}
) AS v(serial, hash)
WHERE public.token.serial = v.serial;
-- CHECK: Check that number of rows is same as number of users exported.
"""
print(sql_script)
try:
with open("update_pins.sql", "w", encoding="utf-8") as f:
f.write(sql_script)
print("\nSQL script was successfully saved into the file named 'update_pins.sql'.")
except Exception as e:
print(f"\nERROR: Was not able to save generated SQL file: {e}")
else:
print("\nDidn't find any PINs, file was not saved.")