From e503663b7268f97a6842657afc3febfea943600c Mon Sep 17 00:00:00 2001 From: "Kevin P. Dyer" Date: Thu, 8 Jan 2026 16:57:11 -0800 Subject: [PATCH] Add 9 more FPE examples for common data types Examples for: - Credit card numbers (13-19 digits, various formats) - Phone numbers (with country code preservation) - Social Security Numbers (SSN) - Dates of birth (various formats) - ZIP/postal codes (US and Canadian) - Bank account numbers and IBANs - IP addresses (IPv4 and IPv6) - License plate numbers - Medical record numbers (MRN) - Usernames Each example includes encrypt/decrypt functions with format preservation. --- examples/bank_account.py | 143 +++++++++++++++++++++++++++++++++++++ examples/credit_card.py | 75 +++++++++++++++++++ examples/date_of_birth.py | 85 ++++++++++++++++++++++ examples/ip_address.py | 133 ++++++++++++++++++++++++++++++++++ examples/license_plate.py | 123 +++++++++++++++++++++++++++++++ examples/medical_record.py | 132 ++++++++++++++++++++++++++++++++++ examples/phone_number.py | 97 +++++++++++++++++++++++++ examples/ssn.py | 71 ++++++++++++++++++ examples/username.py | 87 ++++++++++++++++++++++ examples/zip_code.py | 113 +++++++++++++++++++++++++++++ 10 files changed, 1059 insertions(+) create mode 100644 examples/bank_account.py create mode 100644 examples/credit_card.py create mode 100644 examples/date_of_birth.py create mode 100644 examples/ip_address.py create mode 100644 examples/license_plate.py create mode 100644 examples/medical_record.py create mode 100644 examples/phone_number.py create mode 100644 examples/ssn.py create mode 100644 examples/username.py create mode 100644 examples/zip_code.py diff --git a/examples/bank_account.py b/examples/bank_account.py new file mode 100644 index 0000000..2396d45 --- /dev/null +++ b/examples/bank_account.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of bank account numbers. + +Encrypts bank account and routing numbers while preserving: +- Numeric format +- Length +- Common formatting (spaces, dashes) +""" + +import ffx + + +def encrypt_account_number(account: str, ffx_obj) -> str: + """Encrypt a bank account number. + + Args: + account: Account number (digits only or with formatting) + ffx_obj: FFX encrypter configured with radix=10 + + Returns: + Encrypted account number with same length + """ + digits = ''.join(c for c in account if c.isdigit()) + + if len(digits) < 4: + raise ValueError("Account number too short") + + plain = ffx.FFXInteger(digits, radix=10, blocksize=len(digits)) + encrypted = ffx_obj.encrypt(0, plain) + + return str(encrypted).zfill(len(digits)) + + +def decrypt_account_number(encrypted: str, ffx_obj) -> str: + """Decrypt a bank account number.""" + cipher = ffx.FFXInteger(encrypted, radix=10, blocksize=len(encrypted)) + decrypted = ffx_obj.decrypt(0, cipher) + return str(decrypted).zfill(len(encrypted)) + + +def encrypt_routing_number(routing: str, ffx_obj) -> str: + """Encrypt a 9-digit ABA routing number.""" + digits = ''.join(c for c in routing if c.isdigit()) + + if len(digits) != 9: + raise ValueError("Routing number must be 9 digits") + + plain = ffx.FFXInteger(digits, radix=10, blocksize=9) + encrypted = ffx_obj.encrypt(0, plain) + + return str(encrypted).zfill(9) + + +def decrypt_routing_number(encrypted: str, ffx_obj) -> str: + """Decrypt a routing number.""" + cipher = ffx.FFXInteger(encrypted, radix=10, blocksize=9) + decrypted = ffx_obj.decrypt(0, cipher) + return str(decrypted).zfill(9) + + +def encrypt_iban(iban: str, ffx_obj_alpha, ffx_obj_num) -> str: + """Encrypt an IBAN (International Bank Account Number). + + Preserves the country code (first 2 letters) and encrypts the rest. + """ + clean = iban.upper().replace(' ', '') + country = clean[:2] # Preserve country code + rest = clean[2:] + + # Separate letters and digits + result = country + for char in rest: + if char.isdigit(): + plain = ffx.FFXInteger(char, radix=10, blocksize=1) + enc = ffx_obj_num.encrypt(0, plain) + result += str(enc) + elif char.isalpha(): + plain = ffx.FFXInteger(char.lower(), radix=36, blocksize=1) + enc = ffx_obj_alpha.encrypt(0, plain) + result += str(enc).upper() + + # Format with spaces every 4 characters + return ' '.join(result[i:i+4] for i in range(0, len(result), 4)) + + +def decrypt_iban(encrypted: str, ffx_obj_alpha, ffx_obj_num) -> str: + """Decrypt an IBAN.""" + clean = encrypted.upper().replace(' ', '') + country = clean[:2] + rest = clean[2:] + + result = country + for char in rest: + if char.isdigit(): + cipher = ffx.FFXInteger(char, radix=10, blocksize=1) + dec = ffx_obj_num.decrypt(0, cipher) + result += str(dec) + elif char.isalpha(): + cipher = ffx.FFXInteger(char.lower(), radix=36, blocksize=1) + dec = ffx_obj_alpha.decrypt(0, cipher) + result += str(dec).upper() + + return ' '.join(result[i:i+4] for i in range(0, len(result), 4)) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_num = ffx.new(key.to_bytes(16), radix=10) + ffx_alpha = ffx.new(key.to_bytes(16), radix=36) + + print("Bank Account Format-Preserving Encryption") + print("=" * 60) + + # Account numbers + accounts = ["1234567890", "9876543210123", "00001111222233"] + print("\n--- Account Numbers ---") + for acct in accounts: + encrypted = encrypt_account_number(acct, ffx_num) + decrypted = decrypt_account_number(encrypted, ffx_num) + print(f"Original: {acct:20} → Encrypted: {encrypted:20} → Verified: {'✓' if acct == decrypted else '✗'}") + + # Routing numbers + routings = ["021000021", "121042882", "322271627"] + print("\n--- Routing Numbers ---") + for routing in routings: + encrypted = encrypt_routing_number(routing, ffx_num) + decrypted = decrypt_routing_number(encrypted, ffx_num) + print(f"Original: {routing} → Encrypted: {encrypted} → Verified: {'✓' if routing == decrypted else '✗'}") + + # IBANs + ibans = ["DE89 3704 0044 0532 0130 00", "GB82 WEST 1234 5698 7654 32"] + print("\n--- IBANs ---") + for iban in ibans: + encrypted = encrypt_iban(iban, ffx_alpha, ffx_num) + decrypted = decrypt_iban(encrypted, ffx_alpha, ffx_num) + print(f"Original: {iban}") + print(f"Encrypted: {encrypted}") + print(f"Verified: {'✓' if iban == decrypted else '✗'}") + print() + + +if __name__ == "__main__": + main() diff --git a/examples/credit_card.py b/examples/credit_card.py new file mode 100644 index 0000000..912ac69 --- /dev/null +++ b/examples/credit_card.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of credit card numbers. + +Encrypts 16-digit credit card numbers while preserving: +- The format (groups of 4 digits) +- The length (16 digits) +- Numeric-only output +""" + +import ffx + + +def encrypt_credit_card(card_number: str, ffx_obj) -> str: + """Encrypt a credit card number, preserving format. + + Args: + card_number: Card number (13-19 digits, with or without dashes/spaces) + ffx_obj: FFX encrypter configured with radix=10 + + Returns: + Encrypted card number with same length + """ + # Remove formatting, keep only digits + digits = ''.join(c for c in card_number if c.isdigit()) + + if len(digits) < 13 or len(digits) > 19: + raise ValueError(f"Credit card must be 13-19 digits, got {len(digits)}") + + plain = ffx.FFXInteger(digits, radix=10, blocksize=len(digits)) + encrypted = ffx_obj.encrypt(0, plain) + + # Return with standard formatting (groups of 4) + result = str(encrypted).zfill(len(digits)) + return '-'.join(result[i:i+4] for i in range(0, len(result), 4)) + + +def decrypt_credit_card(encrypted_card: str, ffx_obj) -> str: + """Decrypt a credit card number.""" + digits = ''.join(c for c in encrypted_card if c.isdigit()) + + cipher = ffx.FFXInteger(digits, radix=10, blocksize=len(digits)) + decrypted = ffx_obj.decrypt(0, cipher) + + result = str(decrypted).zfill(len(digits)) + return '-'.join(result[i:i+4] for i in range(0, len(result), 4)) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_obj = ffx.new(key.to_bytes(16), radix=10) + + cards = [ + "4111-1111-1111-1111", # Test Visa + "5500-0000-0000-0004", # Test Mastercard + "3400-000000-00009", # Test Amex (will be reformatted) + "6011-0000-0000-0004", # Test Discover + ] + + print("Credit Card Format-Preserving Encryption") + print("=" * 50) + + for card in cards: + encrypted = encrypt_credit_card(card, ffx_obj) + decrypted = decrypt_credit_card(encrypted, ffx_obj) + original_digits = ''.join(c for c in card if c.isdigit()) + decrypted_digits = ''.join(c for c in decrypted if c.isdigit()) + + print(f"\nOriginal: {card}") + print(f"Encrypted: {encrypted}") + print(f"Decrypted: {decrypted}") + print(f"Verified: {'✓' if original_digits == decrypted_digits else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/date_of_birth.py b/examples/date_of_birth.py new file mode 100644 index 0000000..5422927 --- /dev/null +++ b/examples/date_of_birth.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of dates. + +Encrypts dates while preserving: +- The format (YYYY-MM-DD, MM/DD/YYYY, etc.) +- Valid-looking date structure +- Numeric content + +Note: Encrypted dates may not be calendar-valid dates, but will +have the same format and numeric structure. +""" + +import re + +import ffx + + +def encrypt_date(date: str, ffx_obj) -> str: + """Encrypt a date, preserving format. + + Args: + date: Date string in any format (YYYY-MM-DD, MM/DD/YYYY, etc.) + ffx_obj: FFX encrypter configured with radix=10 + + Returns: + Encrypted date with same format + """ + # Split into digit groups and separators + parts = re.split(r'(\d+)', date) + + result = [] + for part in parts: + if part.isdigit(): + plain = ffx.FFXInteger(part, radix=10, blocksize=len(part)) + encrypted = ffx_obj.encrypt(0, plain) + result.append(str(encrypted).zfill(len(part))) + else: + result.append(part) + + return ''.join(result) + + +def decrypt_date(encrypted_date: str, ffx_obj) -> str: + """Decrypt a date.""" + parts = re.split(r'(\d+)', encrypted_date) + + result = [] + for part in parts: + if part.isdigit(): + cipher = ffx.FFXInteger(part, radix=10, blocksize=len(part)) + decrypted = ffx_obj.decrypt(0, cipher) + result.append(str(decrypted).zfill(len(part))) + else: + result.append(part) + + return ''.join(result) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_obj = ffx.new(key.to_bytes(16), radix=10) + + dates = [ + "1990-05-15", # ISO format + "05/15/1990", # US format + "15.05.1990", # European format + "2000-01-01", # Y2K + "12/31/1999", # Pre-Y2K + ] + + print("Date Format-Preserving Encryption") + print("=" * 50) + + for date in dates: + encrypted = encrypt_date(date, ffx_obj) + decrypted = decrypt_date(encrypted, ffx_obj) + + print(f"\nOriginal: {date}") + print(f"Encrypted: {encrypted}") + print(f"Decrypted: {decrypted}") + print(f"Verified: {'✓' if date == decrypted else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/ip_address.py b/examples/ip_address.py new file mode 100644 index 0000000..4b3d1e7 --- /dev/null +++ b/examples/ip_address.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of IP addresses. + +Encrypts IP addresses while preserving: +- IPv4 format (XXX.XXX.XXX.XXX) +- IPv6 format (XXXX:XXXX:...) +- Dot/colon separators +""" + +import ffx + + +def encrypt_ipv4(ip: str, ffx_obj) -> str: + """Encrypt an IPv4 address, preserving format. + + Each octet is encrypted separately to maintain valid-looking output. + Note: Encrypted octets may exceed 255. + + Args: + ip: IPv4 address (e.g., "192.168.1.1") + ffx_obj: FFX encrypter configured with radix=10 + + Returns: + Encrypted IPv4-like address + """ + octets = ip.split('.') + if len(octets) != 4: + raise ValueError("Invalid IPv4 address") + + encrypted_octets = [] + for octet in octets: + # Pad to 3 digits for consistent encryption + padded = octet.zfill(3) + plain = ffx.FFXInteger(padded, radix=10, blocksize=3) + encrypted = ffx_obj.encrypt(0, plain) + # Keep as 3 digits, strip leading zeros for display + encrypted_octets.append(str(int(str(encrypted).zfill(3)))) + + return '.'.join(encrypted_octets) + + +def decrypt_ipv4(encrypted_ip: str, ffx_obj) -> str: + """Decrypt an IPv4 address.""" + octets = encrypted_ip.split('.') + + decrypted_octets = [] + for octet in octets: + padded = octet.zfill(3) + cipher = ffx.FFXInteger(padded, radix=10, blocksize=3) + decrypted = ffx_obj.decrypt(0, cipher) + decrypted_octets.append(str(int(str(decrypted).zfill(3)))) + + return '.'.join(decrypted_octets) + + +def encrypt_ipv6(ip: str, ffx_obj) -> str: + """Encrypt an IPv6 address, preserving format. + + Args: + ip: IPv6 address (e.g., "2001:0db8:85a3:0000:0000:8a2e:0370:7334") + ffx_obj: FFX encrypter configured with radix=16 + + Returns: + Encrypted IPv6 address + """ + # Handle :: shorthand by expanding + if '::' in ip: + parts = ip.split('::') + left = parts[0].split(':') if parts[0] else [] + right = parts[1].split(':') if parts[1] else [] + missing = 8 - len(left) - len(right) + groups = left + ['0000'] * missing + right + else: + groups = ip.split(':') + + # Normalize to 4-digit groups + groups = [g.zfill(4) for g in groups] + + encrypted_groups = [] + for group in groups: + plain = ffx.FFXInteger(group.lower(), radix=16, blocksize=4) + encrypted = ffx_obj.encrypt(0, plain) + encrypted_groups.append(str(encrypted).zfill(4)) + + return ':'.join(encrypted_groups) + + +def decrypt_ipv6(encrypted_ip: str, ffx_obj) -> str: + """Decrypt an IPv6 address.""" + groups = encrypted_ip.split(':') + + decrypted_groups = [] + for group in groups: + cipher = ffx.FFXInteger(group.lower(), radix=16, blocksize=4) + decrypted = ffx_obj.decrypt(0, cipher) + decrypted_groups.append(str(decrypted).zfill(4)) + + return ':'.join(decrypted_groups) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_decimal = ffx.new(key.to_bytes(16), radix=10) + ffx_hex = ffx.new(key.to_bytes(16), radix=16) + + print("IP Address Format-Preserving Encryption") + print("=" * 60) + + # IPv4 addresses + ipv4_addrs = ["192.168.1.1", "10.0.0.1", "172.16.254.1", "8.8.8.8"] + print("\n--- IPv4 Addresses ---") + for ip in ipv4_addrs: + encrypted = encrypt_ipv4(ip, ffx_decimal) + decrypted = decrypt_ipv4(encrypted, ffx_decimal) + print(f"Original: {ip:15} → Encrypted: {encrypted:15} → Verified: {'✓' if ip == decrypted else '✗'}") + + # IPv6 addresses + ipv6_addrs = [ + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "fe80:0000:0000:0000:0000:0000:0000:0001", + ] + print("\n--- IPv6 Addresses ---") + for ip in ipv6_addrs: + encrypted = encrypt_ipv6(ip, ffx_hex) + decrypted = decrypt_ipv6(encrypted, ffx_hex) + print(f"Original: {ip}") + print(f"Encrypted: {encrypted}") + print(f"Verified: {'✓' if ip.lower() == decrypted.lower() else '✗'}") + print() + + +if __name__ == "__main__": + main() diff --git a/examples/license_plate.py b/examples/license_plate.py new file mode 100644 index 0000000..9a4a836 --- /dev/null +++ b/examples/license_plate.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of license plate numbers. + +Encrypts license plates while preserving: +- Alphanumeric format +- Length and structure +- Regional formatting (US, EU, etc.) +""" + +import re + +import ffx + + +def encrypt_license_plate(plate: str, ffx_obj) -> str: + """Encrypt a license plate, preserving format. + + Encrypts alphanumeric characters separately to maintain format. + Spaces and dashes are preserved. + + Args: + plate: License plate string + ffx_obj: FFX encrypter configured with radix=36 + + Returns: + Encrypted license plate + """ + result = [] + + for char in plate.upper(): + if char.isalnum(): + plain = ffx.FFXInteger(char.lower(), radix=36, blocksize=1) + encrypted = ffx_obj.encrypt(0, plain) + result.append(str(encrypted).upper()) + else: + # Preserve spaces, dashes, etc. + result.append(char) + + return ''.join(result) + + +def decrypt_license_plate(encrypted_plate: str, ffx_obj) -> str: + """Decrypt a license plate.""" + result = [] + + for char in encrypted_plate.upper(): + if char.isalnum(): + cipher = ffx.FFXInteger(char.lower(), radix=36, blocksize=1) + decrypted = ffx_obj.decrypt(0, cipher) + result.append(str(decrypted).upper()) + else: + result.append(char) + + return ''.join(result) + + +def encrypt_plate_segments(plate: str, ffx_obj) -> str: + """Encrypt a license plate by segments for better security. + + Groups consecutive letters and digits are encrypted together. + """ + # Split into alphanumeric segments and separators + parts = re.split(r'([^A-Za-z0-9]+)', plate.upper()) + + result = [] + for part in parts: + if part and part[0].isalnum(): + plain = ffx.FFXInteger(part.lower(), radix=36, blocksize=len(part)) + encrypted = ffx_obj.encrypt(0, plain) + result.append(str(encrypted).upper().zfill(len(part))) + else: + result.append(part) + + return ''.join(result) + + +def decrypt_plate_segments(encrypted_plate: str, ffx_obj) -> str: + """Decrypt a license plate encrypted by segments.""" + parts = re.split(r'([^A-Za-z0-9]+)', encrypted_plate.upper()) + + result = [] + for part in parts: + if part and part[0].isalnum(): + cipher = ffx.FFXInteger(part.lower(), radix=36, blocksize=len(part)) + decrypted = ffx_obj.decrypt(0, cipher) + result.append(str(decrypted).upper().zfill(len(part))) + else: + result.append(part) + + return ''.join(result) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_obj = ffx.new(key.to_bytes(16), radix=36) + + print("License Plate Format-Preserving Encryption") + print("=" * 60) + + plates = [ + "ABC-1234", # US style + "7ABC123", # California style + "AB12 CDE", # UK style + "W 123 ABC", # German style + "1ABC234", # Another US style + "AA-123-AA", # French style + ] + + print("\n--- Character-by-character encryption ---") + for plate in plates: + encrypted = encrypt_license_plate(plate, ffx_obj) + decrypted = decrypt_license_plate(encrypted, ffx_obj) + print(f"Original: {plate:12} → Encrypted: {encrypted:12} → Verified: {'✓' if plate.upper() == decrypted else '✗'}") + + print("\n--- Segment-based encryption (more secure) ---") + for plate in plates: + encrypted = encrypt_plate_segments(plate, ffx_obj) + decrypted = decrypt_plate_segments(encrypted, ffx_obj) + print(f"Original: {plate:12} → Encrypted: {encrypted:12} → Verified: {'✓' if plate.upper() == decrypted else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/medical_record.py b/examples/medical_record.py new file mode 100644 index 0000000..7dd20aa --- /dev/null +++ b/examples/medical_record.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of medical record numbers (MRN). + +Encrypts MRNs while preserving: +- Alphanumeric format +- Length and structure +- Prefix codes (optionally preserved) +""" + +import re + +import ffx + + +def encrypt_mrn(mrn: str, ffx_obj_alpha, ffx_obj_num, preserve_prefix: bool = False) -> str: + """Encrypt a Medical Record Number. + + Args: + mrn: Medical record number (alphanumeric) + ffx_obj_alpha: FFX encrypter for letters (radix=36) + ffx_obj_num: FFX encrypter for digits (radix=10) + preserve_prefix: If True, keeps first 2-3 letter prefix unchanged + + Returns: + Encrypted MRN with same format + """ + # Find prefix (letters at start) + match = re.match(r'^([A-Za-z]+)', mrn) + prefix = match.group(1) if match else "" + + if preserve_prefix and len(prefix) >= 2: + rest = mrn[len(prefix):] + prefix_out = prefix.upper() + else: + rest = mrn + prefix_out = "" + if prefix: + plain = ffx.FFXInteger(prefix.lower(), radix=36, blocksize=len(prefix)) + encrypted = ffx_obj_alpha.encrypt(0, plain) + prefix_out = str(encrypted).upper() + rest = mrn[len(prefix):] + + # Encrypt remaining digits + digits = ''.join(c for c in rest if c.isdigit()) + if digits: + plain = ffx.FFXInteger(digits, radix=10, blocksize=len(digits)) + encrypted = ffx_obj_num.encrypt(0, plain) + encrypted_digits = str(encrypted).zfill(len(digits)) + else: + encrypted_digits = "" + + return prefix_out + encrypted_digits + + +def decrypt_mrn(encrypted_mrn: str, ffx_obj_alpha, ffx_obj_num, preserve_prefix: bool = False) -> str: + """Decrypt a Medical Record Number.""" + match = re.match(r'^([A-Za-z]+)', encrypted_mrn) + prefix = match.group(1) if match else "" + + if preserve_prefix and len(prefix) >= 2: + rest = encrypted_mrn[len(prefix):] + prefix_out = prefix.upper() + else: + rest = encrypted_mrn + prefix_out = "" + if prefix: + cipher = ffx.FFXInteger(prefix.lower(), radix=36, blocksize=len(prefix)) + decrypted = ffx_obj_alpha.decrypt(0, cipher) + prefix_out = str(decrypted).upper() + rest = encrypted_mrn[len(prefix):] + + digits = ''.join(c for c in rest if c.isdigit()) + if digits: + cipher = ffx.FFXInteger(digits, radix=10, blocksize=len(digits)) + decrypted = ffx_obj_num.decrypt(0, cipher) + decrypted_digits = str(decrypted).zfill(len(digits)) + else: + decrypted_digits = "" + + return prefix_out + decrypted_digits + + +def encrypt_mrn_full(mrn: str, ffx_obj) -> str: + """Encrypt entire MRN as alphanumeric string.""" + clean = mrn.upper() + # Only encrypt alphanumeric + if not clean.isalnum(): + raise ValueError("MRN must be alphanumeric") + + plain = ffx.FFXInteger(clean.lower(), radix=36, blocksize=len(clean)) + encrypted = ffx_obj.encrypt(0, plain) + return str(encrypted).upper().zfill(len(clean)) + + +def decrypt_mrn_full(encrypted_mrn: str, ffx_obj) -> str: + """Decrypt entire MRN.""" + cipher = ffx.FFXInteger(encrypted_mrn.lower(), radix=36, blocksize=len(encrypted_mrn)) + decrypted = ffx_obj.decrypt(0, cipher) + return str(decrypted).upper().zfill(len(encrypted_mrn)) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_num = ffx.new(key.to_bytes(16), radix=10) + ffx_alpha = ffx.new(key.to_bytes(16), radix=36) + + print("Medical Record Number Format-Preserving Encryption") + print("=" * 60) + + mrns = [ + "MRN12345678", + "PAT00987654", + "A1234567", + "HOS123456789", + "12345678", # Numeric only + ] + + print("\n--- Full alphanumeric encryption ---") + for mrn in mrns: + encrypted = encrypt_mrn_full(mrn, ffx_alpha) + decrypted = decrypt_mrn_full(encrypted, ffx_alpha) + print(f"Original: {mrn:15} → Encrypted: {encrypted:15} → Verified: {'✓' if mrn.upper() == decrypted else '✗'}") + + print("\n--- Prefix-preserving encryption ---") + for mrn in mrns: + encrypted = encrypt_mrn(mrn, ffx_alpha, ffx_num, preserve_prefix=True) + decrypted = decrypt_mrn(encrypted, ffx_alpha, ffx_num, preserve_prefix=True) + print(f"Original: {mrn:15} → Encrypted: {encrypted:15} → Verified: {'✓' if mrn.upper() == decrypted else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/phone_number.py b/examples/phone_number.py new file mode 100644 index 0000000..87f9091 --- /dev/null +++ b/examples/phone_number.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of phone numbers. + +Encrypts phone numbers while preserving: +- The format (parentheses, dashes, spaces) +- Numeric length +- Country code (optionally preserved or encrypted) +""" + +import re + +import ffx + + +def encrypt_phone(phone: str, ffx_obj, preserve_country_code: bool = True) -> str: + """Encrypt a phone number, preserving format. + + Args: + phone: Phone number in any format + ffx_obj: FFX encrypter configured with radix=10 + preserve_country_code: If True, keeps +1, +44, etc unchanged + + Returns: + Encrypted phone number with same format + """ + # Find all digit sequences and their positions + parts = re.split(r'(\d+)', phone) + + result = [] + first_digits = True + + for part in parts: + if part.isdigit(): + if first_digits and preserve_country_code and len(part) <= 2: + # Preserve short country codes + result.append(part) + else: + # Encrypt digit sequences + plain = ffx.FFXInteger(part, radix=10, blocksize=len(part)) + encrypted = ffx_obj.encrypt(0, plain) + result.append(str(encrypted).zfill(len(part))) + first_digits = False + else: + result.append(part) + + return ''.join(result) + + +def decrypt_phone(encrypted_phone: str, ffx_obj, preserve_country_code: bool = True) -> str: + """Decrypt a phone number.""" + parts = re.split(r'(\d+)', encrypted_phone) + + result = [] + first_digits = True + + for part in parts: + if part.isdigit(): + if first_digits and preserve_country_code and len(part) <= 2: + result.append(part) + else: + cipher = ffx.FFXInteger(part, radix=10, blocksize=len(part)) + decrypted = ffx_obj.decrypt(0, cipher) + result.append(str(decrypted).zfill(len(part))) + first_digits = False + else: + result.append(part) + + return ''.join(result) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_obj = ffx.new(key.to_bytes(16), radix=10) + + phones = [ + "(555) 123-4567", + "+1 (800) 555-0199", + "+44 20 7946 0958", + "555.867.5309", + "+1-888-555-1234", + ] + + print("Phone Number Format-Preserving Encryption") + print("=" * 50) + + for phone in phones: + encrypted = encrypt_phone(phone, ffx_obj) + decrypted = decrypt_phone(encrypted, ffx_obj) + + print(f"\nOriginal: {phone}") + print(f"Encrypted: {encrypted}") + print(f"Decrypted: {decrypted}") + print(f"Verified: {'✓' if phone == decrypted else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/ssn.py b/examples/ssn.py new file mode 100644 index 0000000..d083837 --- /dev/null +++ b/examples/ssn.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of Social Security Numbers. + +Encrypts SSNs while preserving: +- The XXX-XX-XXXX format +- 9-digit length +- Numeric-only content +""" + +import ffx + + +def encrypt_ssn(ssn: str, ffx_obj) -> str: + """Encrypt a Social Security Number, preserving format. + + Args: + ssn: SSN in XXX-XX-XXXX format (dashes optional) + ffx_obj: FFX encrypter configured with radix=10 + + Returns: + Encrypted SSN in XXX-XX-XXXX format + """ + digits = ''.join(c for c in ssn if c.isdigit()) + + if len(digits) != 9: + raise ValueError(f"SSN must be 9 digits, got {len(digits)}") + + plain = ffx.FFXInteger(digits, radix=10, blocksize=9) + encrypted = ffx_obj.encrypt(0, plain) + + result = str(encrypted).zfill(9) + return f"{result[0:3]}-{result[3:5]}-{result[5:9]}" + + +def decrypt_ssn(encrypted_ssn: str, ffx_obj) -> str: + """Decrypt a Social Security Number.""" + digits = ''.join(c for c in encrypted_ssn if c.isdigit()) + + cipher = ffx.FFXInteger(digits, radix=10, blocksize=9) + decrypted = ffx_obj.decrypt(0, cipher) + + result = str(decrypted).zfill(9) + return f"{result[0:3]}-{result[3:5]}-{result[5:9]}" + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_obj = ffx.new(key.to_bytes(16), radix=10) + + ssns = [ + "123-45-6789", + "987-65-4321", + "555-12-3456", + "000-00-0001", + ] + + print("SSN Format-Preserving Encryption") + print("=" * 50) + + for ssn in ssns: + encrypted = encrypt_ssn(ssn, ffx_obj) + decrypted = decrypt_ssn(encrypted, ffx_obj) + + print(f"\nOriginal: {ssn}") + print(f"Encrypted: {encrypted}") + print(f"Decrypted: {decrypted}") + print(f"Verified: {'✓' if ssn == decrypted else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/username.py b/examples/username.py new file mode 100644 index 0000000..0352575 --- /dev/null +++ b/examples/username.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of usernames. + +Encrypts usernames while preserving: +- Alphanumeric characters +- Allowed special characters (underscore, dot, dash) +- Length and format +""" + +import re + +import ffx + + +def encrypt_username(username: str, ffx_obj) -> str: + """Encrypt a username, preserving format. + + Encrypts alphanumeric segments, preserves underscores, dots, dashes. + + Args: + username: Username string (lowercase alphanumeric + _.-) + ffx_obj: FFX encrypter configured with radix=36 + + Returns: + Encrypted username with same format + """ + # Split into alphanumeric segments and separators + parts = re.split(r'([^a-z0-9]+)', username.lower()) + + result = [] + for part in parts: + if part and part[0].isalnum(): + plain = ffx.FFXInteger(part, radix=36, blocksize=len(part)) + encrypted = ffx_obj.encrypt(0, plain) + result.append(str(encrypted).zfill(len(part))) + else: + # Keep separators (_, ., -) + result.append(part) + + return ''.join(result) + + +def decrypt_username(encrypted_username: str, ffx_obj) -> str: + """Decrypt a username.""" + parts = re.split(r'([^a-z0-9]+)', encrypted_username.lower()) + + result = [] + for part in parts: + if part and part[0].isalnum(): + cipher = ffx.FFXInteger(part, radix=36, blocksize=len(part)) + decrypted = ffx_obj.decrypt(0, cipher) + result.append(str(decrypted).zfill(len(part))) + else: + result.append(part) + + return ''.join(result) + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_obj = ffx.new(key.to_bytes(16), radix=36) + + print("Username Format-Preserving Encryption") + print("=" * 50) + + usernames = [ + "john_doe", + "alice.smith", + "bob-jones123", + "user2024", + "admin_test.account", + "x", + "anonymous", + ] + + for username in usernames: + encrypted = encrypt_username(username, ffx_obj) + decrypted = decrypt_username(encrypted, ffx_obj) + + print(f"\nOriginal: {username}") + print(f"Encrypted: {encrypted}") + print(f"Decrypted: {decrypted}") + print(f"Verified: {'✓' if username.lower() == decrypted else '✗'}") + + +if __name__ == "__main__": + main() diff --git a/examples/zip_code.py b/examples/zip_code.py new file mode 100644 index 0000000..361fd23 --- /dev/null +++ b/examples/zip_code.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Example: Format-preserving encryption of ZIP/postal codes. + +Encrypts postal codes while preserving: +- US 5-digit ZIP codes +- US ZIP+4 codes (XXXXX-XXXX) +- Canadian postal codes (A1A 1A1) +- UK postcodes (alphanumeric) +""" + +import re + +import ffx + + +def encrypt_us_zip(zip_code: str, ffx_obj) -> str: + """Encrypt a US ZIP code (5-digit or ZIP+4).""" + digits = ''.join(c for c in zip_code if c.isdigit()) + + if len(digits) == 5: + plain = ffx.FFXInteger(digits, radix=10, blocksize=5) + encrypted = ffx_obj.encrypt(0, plain) + return str(encrypted).zfill(5) + elif len(digits) == 9: + plain = ffx.FFXInteger(digits, radix=10, blocksize=9) + encrypted = ffx_obj.encrypt(0, plain) + result = str(encrypted).zfill(9) + return f"{result[0:5]}-{result[5:9]}" + else: + raise ValueError(f"US ZIP must be 5 or 9 digits, got {len(digits)}") + + +def decrypt_us_zip(encrypted_zip: str, ffx_obj) -> str: + """Decrypt a US ZIP code.""" + digits = ''.join(c for c in encrypted_zip if c.isdigit()) + + if len(digits) == 5: + cipher = ffx.FFXInteger(digits, radix=10, blocksize=5) + decrypted = ffx_obj.decrypt(0, cipher) + return str(decrypted).zfill(5) + elif len(digits) == 9: + cipher = ffx.FFXInteger(digits, radix=10, blocksize=9) + decrypted = ffx_obj.decrypt(0, cipher) + result = str(decrypted).zfill(9) + return f"{result[0:5]}-{result[5:9]}" + else: + raise ValueError(f"Invalid ZIP format") + + +def encrypt_canadian_postal(postal: str, ffx_obj_alpha, ffx_obj_num) -> str: + """Encrypt a Canadian postal code (A1A 1A1 format).""" + clean = postal.upper().replace(' ', '') + if len(clean) != 6: + raise ValueError("Canadian postal code must be 6 characters") + + # Encrypt letters (positions 0, 2, 4) and digits (positions 1, 3, 5) separately + letters = clean[0] + clean[2] + clean[4] + digits = clean[1] + clean[3] + clean[5] + + plain_letters = ffx.FFXInteger(letters.lower(), radix=36, blocksize=3) + plain_digits = ffx.FFXInteger(digits, radix=10, blocksize=3) + + enc_letters = str(ffx_obj_alpha.encrypt(0, plain_letters)).upper() + enc_digits = str(ffx_obj_num.encrypt(0, plain_digits)).zfill(3) + + return f"{enc_letters[0]}{enc_digits[0]}{enc_letters[1]} {enc_digits[1]}{enc_letters[2]}{enc_digits[2]}" + + +def decrypt_canadian_postal(encrypted: str, ffx_obj_alpha, ffx_obj_num) -> str: + """Decrypt a Canadian postal code.""" + clean = encrypted.upper().replace(' ', '') + + letters = clean[0] + clean[2] + clean[4] + digits = clean[1] + clean[3] + clean[5] + + cipher_letters = ffx.FFXInteger(letters.lower(), radix=36, blocksize=3) + cipher_digits = ffx.FFXInteger(digits, radix=10, blocksize=3) + + dec_letters = str(ffx_obj_alpha.decrypt(0, cipher_letters)).upper() + dec_digits = str(ffx_obj_num.decrypt(0, cipher_digits)).zfill(3) + + return f"{dec_letters[0]}{dec_digits[0]}{dec_letters[1]} {dec_digits[1]}{dec_letters[2]}{dec_digits[2]}" + + +def main(): + key = ffx.FFXInteger('2b7e151628aed2a6abf7158809cf4f3c', radix=16, blocksize=32) + ffx_num = ffx.new(key.to_bytes(16), radix=10) + ffx_alpha = ffx.new(key.to_bytes(16), radix=36) + + print("ZIP/Postal Code Format-Preserving Encryption") + print("=" * 50) + + # US ZIP codes + us_zips = ["90210", "10001", "12345-6789", "00501-0001"] + print("\n--- US ZIP Codes ---") + for zip_code in us_zips: + encrypted = encrypt_us_zip(zip_code, ffx_num) + decrypted = decrypt_us_zip(encrypted, ffx_num) + original = ''.join(c for c in zip_code if c.isdigit()) + dec_digits = ''.join(c for c in decrypted if c.isdigit()) + print(f"Original: {zip_code:15} → Encrypted: {encrypted:15} → Verified: {'✓' if original == dec_digits else '✗'}") + + # Canadian postal codes + canadian = ["K1A 0B1", "V6B 4Y8", "M5V 3L9"] + print("\n--- Canadian Postal Codes ---") + for postal in canadian: + encrypted = encrypt_canadian_postal(postal, ffx_alpha, ffx_num) + decrypted = decrypt_canadian_postal(encrypted, ffx_alpha, ffx_num) + print(f"Original: {postal:15} → Encrypted: {encrypted:15} → Verified: {'✓' if postal == decrypted else '✗'}") + + +if __name__ == "__main__": + main()