Skip to content

Instantly share code, notes, and snippets.

@mainframed
Last active January 26, 2026 09:27
Show Gist options
  • Select an option

  • Save mainframed/d1ad035b7da1a7264cea1c9727baebd3 to your computer and use it in GitHub Desktop.

Select an option

Save mainframed/d1ad035b7da1a7264cea1c9727baebd3 to your computer and use it in GitHub Desktop.
ltpa_generate.py
#!/usr/bin/env python3
"""
LTPA Token Generator - Generates IBM WebSphere LTPA v1 and LTPA2 tokens
Based on analysis of IBM LTPA token format and cryptographic implementation.
References:
- http://tech.srij.it/2012/04/how-to-decrypt-ltpa-cookie-and-view.html
- IBM WebSphere LTPA documentation
- WebSphere {xor} password decoder by Jeroen Zomer
Usage:
python ltpa_token_generator.py -k ltpa.keys -p <password> -u <username> [-v] [--version 1|2]
"""
import argparse
import base64
import hashlib
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
def decode_websphere_password(encoded_password: str, verbose: bool = False) -> str:
"""
Decode a WebSphere {xor} encoded password.
WebSphere uses a simple XOR encoding with underscore (ASCII 95) followed by Base64.
Based on: WebSphere password decoder by Jeroen Zomer (jzomer@strelitzia.net)
Args:
encoded_password: Password string, optionally prefixed with {xor}
verbose: Print debug information
Returns:
Decoded plaintext password
"""
password = encoded_password
# Strip {xor} prefix if present (case-insensitive)
if password.upper().startswith('{XOR}'):
password = password[5:]
if verbose:
print(f"[DEBUG] Detected {{xor}} encoded password")
else:
# Not encoded, return as-is
return encoded_password
# Base64 decode
try:
decoded_bytes = base64.b64decode(password)
except Exception as e:
raise ValueError(f"Failed to Base64 decode password: {e}")
# XOR each byte with underscore (ASCII 95)
plaintext = ''.join(chr(b ^ 95) for b in decoded_bytes)
if verbose:
print(f"[DEBUG] Decoded {{xor}} password: {'*' * len(plaintext)} ({len(plaintext)} chars)")
return plaintext
def encode_websphere_password(plaintext: str) -> str:
"""
Encode a password using WebSphere {xor} encoding.
Args:
plaintext: Plain text password
Returns:
Encoded password with {xor} prefix
"""
# XOR each character with underscore (ASCII 95)
xored = bytes(ord(c) ^ 95 for c in plaintext)
# Base64 encode
encoded = base64.b64encode(xored).decode('utf-8')
return '{xor}' + encoded
# Cryptography imports
from Crypto.Cipher import DES3, AES
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA1
from Crypto.Util.Padding import pad, unpad
class LTPAKeysFile:
"""Parser for IBM LTPA keys file"""
def __init__(self, filepath: str, password: str, verbose: bool = False):
self.filepath = filepath
self.verbose = verbose
# Decode password if it's {xor} encoded
self.password = decode_websphere_password(password, verbose=verbose)
# Parsed values from keys file
self.version: str = ""
self.realm: str = ""
self.encrypted_3des_key: bytes = b""
self.encrypted_private_key: bytes = b""
self.public_key_bytes: bytes = b""
self.creation_date: str = ""
self.creation_host: str = ""
# Decrypted keys
self.des3_key: bytes = b""
self.aes_key: bytes = b""
self.private_key: Optional[RSA.RsaKey] = None
self.public_key: Optional[RSA.RsaKey] = None
self._parse_keys_file()
self._decrypt_keys()
def _log(self, message: str):
"""Print verbose log message"""
if self.verbose:
print(f"[DEBUG] {message}")
def _parse_keys_file(self):
"""Parse the ltpa.keys file"""
self._log(f"Parsing keys file: {self.filepath}")
with open(self.filepath, 'r') as f:
for line in f:
line = line.strip()
if line.startswith('#') or '=' not in line:
continue
# Handle escaped colons in values
key, value = line.split('=', 1)
value = value.replace('\\:', ':')
if key == "com.ibm.websphere.ltpa.version":
self.version = value
self._log(f"LTPA Version: {value}")
elif key == "com.ibm.websphere.ltpa.Realm":
self.realm = value
self._log(f"Realm: {value}")
elif key == "com.ibm.websphere.ltpa.3DESKey":
self.encrypted_3des_key = base64.b64decode(value)
self._log(f"3DES Key (encrypted): {len(self.encrypted_3des_key)} bytes")
elif key == "com.ibm.websphere.ltpa.PrivateKey":
self.encrypted_private_key = base64.b64decode(value)
self._log(f"Private Key (encrypted): {len(self.encrypted_private_key)} bytes")
elif key == "com.ibm.websphere.ltpa.PublicKey":
self.public_key_bytes = base64.b64decode(value)
self._log(f"Public Key: {len(self.public_key_bytes)} bytes")
elif key == "com.ibm.websphere.CreationDate":
self.creation_date = value
self._log(f"Creation Date: {value}")
elif key == "com.ibm.websphere.CreationHost":
self.creation_host = value
self._log(f"Creation Host: {value}")
def _derive_password_key(self) -> bytes:
"""
Derive 3DES key from password using SHA-1
The password is hashed with SHA-1 (20 bytes), then padded to 24 bytes for 3DES
"""
sha1_hash = hashlib.sha1(self.password.encode('utf-8')).digest()
# Pad to 24 bytes (3DES key size) with zeros
key_3des = sha1_hash + b'\x00' * 4
self._log(f"Password-derived 3DES key: {len(key_3des)} bytes")
return key_3des
def _decrypt_keys(self):
"""Decrypt the 3DES key and private key using the password"""
password_key = self._derive_password_key()
# Decrypt the 3DES key using password-derived key (ECB mode)
cipher = DES3.new(password_key, DES3.MODE_ECB)
self.des3_key = unpad(cipher.decrypt(self.encrypted_3des_key), DES3.block_size)
self._log(f"Decrypted 3DES key: {len(self.des3_key)} bytes")
# Derive AES key from 3DES key for LTPA2
# AES key is first 16 bytes of SHA-1 hash of the 3DES key
self.aes_key = hashlib.sha1(self.des3_key).digest()[:16]
self._log(f"Derived AES key: {len(self.aes_key)} bytes")
# Decrypt and parse the private key first (it contains all we need)
self._decrypt_private_key(password_key)
# Parse public key (optional, for verification)
try:
self._parse_public_key()
except Exception as e:
self._log(f"Note: Could not parse public key separately: {e}")
# That's OK - we already have n and e from the private key
def _decrypt_private_key(self, password_key: bytes):
"""Decrypt and parse the RSA private key"""
cipher = DES3.new(password_key, DES3.MODE_ECB)
decrypted = unpad(cipher.decrypt(self.encrypted_private_key), DES3.block_size)
self._log(f"Decrypted private key data: {len(decrypted)} bytes")
# Parse the IBM private key format
# Format: shared_key_length(4) + shared_key + private_exponent_length(4) + private_exponent
try:
self.private_key = self._parse_ibm_private_key(decrypted)
self._log(f"RSA Private key parsed successfully, size: {self.private_key.size_in_bits()} bits")
except Exception as e:
self._log(f"Warning: Could not parse private key: {e}")
def _parse_ibm_private_key(self, data: bytes) -> RSA.RsaKey:
"""
Parse IBM's custom private key format.
Based on oniyi-ltpa (https://github.com/benkroeger/oniyi-ltpa):
The private key buffer structure is:
- 4 bytes: left-pad (unused)
- variable: private exponent (d)
- 3 bytes: public exponent (e)
- 65 bytes: prime1 (p)
- 65 bytes: prime2 (q)
The private exponent length = total_length - 4 - 3 - 65 - 65
"""
self._log(f"Private key raw data: {len(data)} bytes")
self._log(f"Private key hex (first 20): {data[:20].hex()}")
self._log(f"Private key hex (last 20): {data[-20:].hex()}")
total_len = len(data)
# Fixed sizes at the end
PRIME_SIZE = 65 # bytes for p and q
EXPONENT_SIZE = 3 # bytes for public exponent e
LEFT_PAD = 4 # unused bytes at start
# Calculate private exponent length
d_len = total_len - LEFT_PAD - EXPONENT_SIZE - (2 * PRIME_SIZE)
self._log(f"Calculated private exponent length: {d_len} bytes")
if d_len <= 0:
raise ValueError(f"Invalid private key structure: calculated d_len={d_len}")
offset = LEFT_PAD
# Read private exponent (d)
d = int.from_bytes(data[offset:offset + d_len], 'big')
offset += d_len
self._log(f"Private exponent (d): {d.bit_length()} bits")
# Read public exponent (e) - 3 bytes
e = int.from_bytes(data[offset:offset + EXPONENT_SIZE], 'big')
offset += EXPONENT_SIZE
self._log(f"Public exponent (e): {e}")
# Read prime1 (p) - 65 bytes
p = int.from_bytes(data[offset:offset + PRIME_SIZE], 'big')
offset += PRIME_SIZE
self._log(f"Prime p: {p.bit_length()} bits")
# Read prime2 (q) - 65 bytes
q = int.from_bytes(data[offset:offset + PRIME_SIZE], 'big')
self._log(f"Prime q: {q.bit_length()} bits")
# Calculate modulus n = p * q
n = p * q
self._log(f"Calculated modulus n: {n.bit_length()} bits")
# Verify: d * e ≡ 1 (mod φ(n)) where φ(n) = (p-1)(q-1)
phi_n = (p - 1) * (q - 1)
check = (d * e) % phi_n
self._log(f"Verification d*e mod φ(n) = {check} (should be 1)")
# Construct the RSA private key with all components
# RSA.construct expects: (n, e, d) or (n, e, d, p, q)
return RSA.construct((n, e, d, p, q))
def _parse_public_key(self):
"""
Parse IBM's public key format.
The public key format appears to be similar to the private key:
- Some header bytes
- 3 bytes: public exponent (e)
- remaining: modulus (n)
However, since we already have n and e from parsing the private key,
this is mainly for verification.
"""
data = self.public_key_bytes
self._log(f"Public key raw data: {len(data)} bytes")
self._log(f"Public key hex dump (first 20 bytes): {data[:20].hex()}")
self._log(f"Public key hex dump (last 10 bytes): {data[-10:].hex()}")
# If we already have a private key parsed, we can use its components
if self.private_key is not None:
self._log("Using n and e from already-parsed private key")
self.public_key = RSA.construct((self.private_key.n, self.private_key.e))
self._log(f"RSA Public key constructed, size: {self.public_key.size_in_bits()} bits")
return
# Otherwise try to parse the public key directly
# The format seems to be: 4-byte header + 3-byte exponent + modulus
# But let's be flexible
total_len = len(data)
# Try format: skip first few bytes, last 3 bytes might be exponent or first 3 after header
# Based on private key format, let's try: header(4) + e(3) + n(remaining)
HEADER_SIZE = 4
EXPONENT_SIZE = 3
if total_len > HEADER_SIZE + EXPONENT_SIZE:
offset = HEADER_SIZE
# Read exponent (e) - 3 bytes
e = int.from_bytes(data[offset:offset + EXPONENT_SIZE], 'big')
offset += EXPONENT_SIZE
self._log(f"Public key exponent (e) attempt: {e}")
# Check if e is reasonable (common values: 3, 17, 65537)
if e in [3, 17, 65537] or (e > 2 and e % 2 == 1):
# Read modulus
n = int.from_bytes(data[offset:], 'big')
self._log(f"Public key modulus (n): {n.bit_length()} bits")
self.public_key = RSA.construct((n, e))
self._log(f"RSA Public key parsed, size: {self.public_key.size_in_bits()} bits")
else:
raise ValueError(f"Unexpected public exponent value: {e}")
class LTPATokenGenerator:
"""Generate LTPA v1 and LTPA2 tokens"""
def __init__(self, keys: LTPAKeysFile, verbose: bool = False):
self.keys = keys
self.verbose = verbose
def _log(self, message: str):
"""Print verbose log message"""
if self.verbose:
print(f"[DEBUG] {message}")
def generate_ltpa1_token(self, username: str, expiration_time: Optional[datetime] = None,
token_lifetime_minutes: int = 120) -> str:
"""
Generate an LTPA v1 token
Format: userInfo%expiration%signature
- userInfo: u:realm/username
- expiration: Unix timestamp in milliseconds
- signature: Base64-encoded signature
The entire token is then encrypted with 3DES and Base64 encoded
"""
# Calculate expiration
if expiration_time is None:
expiration_time = datetime.now() + timedelta(minutes=token_lifetime_minutes)
expiration_ms = int(expiration_time.timestamp() * 1000)
# Build user info string
user_info = f"u:{self.keys.realm}/{username}"
self._log(f"User info: {user_info}")
self._log(f"Expiration: {expiration_time} ({expiration_ms})")
# Create the token body (without signature)
token_body = f"{user_info}%{expiration_ms}"
# Create signature
signature = self._sign_token(token_body.encode('utf-8'))
signature_b64 = base64.b64encode(signature).decode('utf-8')
self._log(f"Signature (base64): {signature_b64[:50]}...")
# Complete token
full_token = f"{token_body}%{signature_b64}"
self._log(f"Full token (plaintext): {full_token[:80]}...")
# Encrypt with 3DES (ECB mode for LTPA v1)
# Pad the 3DES key to 24 bytes if needed
des3_key = self.keys.des3_key
if len(des3_key) < 24:
des3_key = des3_key + b'\x00' * (24 - len(des3_key))
cipher = DES3.new(des3_key[:24], DES3.MODE_ECB)
encrypted = cipher.encrypt(pad(full_token.encode('utf-8'), DES3.block_size))
# Base64 encode
token = base64.b64encode(encrypted).decode('utf-8')
self._log(f"Final LTPA v1 token length: {len(token)}")
return token
def generate_ltpa2_token(self, username: str, expiration_time: Optional[datetime] = None,
token_lifetime_minutes: int = 120,
attributes: Optional[dict] = None) -> str:
"""
Generate an LTPA2 token
LTPA2 format is more complex with attribute pairs:
expire:timestamp$u:user:realm/username$attributes%signature
Encrypted with AES-128-CBC
"""
# Calculate expiration
if expiration_time is None:
expiration_time = datetime.now() + timedelta(minutes=token_lifetime_minutes)
expiration_ms = int(expiration_time.timestamp() * 1000)
# Build LTPA2 token body
# Format: expire:<timestamp>$u:user:<realm>/<username>
user_data = f"u:user:{self.keys.realm}/{username}"
token_body = f"expire:{expiration_ms}${user_data}"
# Add any additional attributes
if attributes:
for key, value in attributes.items():
token_body += f"${key}:{value}"
self._log(f"LTPA2 token body: {token_body}")
# Create signature over the token body
signature = self._sign_token(token_body.encode('utf-8'))
signature_b64 = base64.b64encode(signature).decode('utf-8')
self._log(f"LTPA2 Signature (base64): {signature_b64[:50]}...")
# Complete token with signature
full_token = f"{token_body}%{signature_b64}"
self._log(f"LTPA2 full token (plaintext): {full_token[:100]}...")
# Encrypt with AES-128-CBC
# IV is the first 16 bytes of the AES key
iv = self.keys.aes_key[:16]
cipher = AES.new(self.keys.aes_key, AES.MODE_CBC, iv)
encrypted = cipher.encrypt(pad(full_token.encode('utf-8'), AES.block_size))
# Base64 encode
token = base64.b64encode(encrypted).decode('utf-8')
self._log(f"Final LTPA2 token length: {len(token)}")
return token
def _sign_token(self, data: bytes) -> bytes:
"""Sign token data with RSA private key using SHA-1"""
if self.keys.private_key is None:
raise ValueError("Private key not available for signing")
h = SHA1.new(data)
signature = pkcs1_15.new(self.keys.private_key).sign(h)
return signature
def decode_ltpa1_token(self, token: str) -> dict:
"""Decode and decrypt an LTPA v1 token (useful for verification)"""
# Base64 decode
encrypted = base64.b64decode(token)
# Decrypt with 3DES
des3_key = self.keys.des3_key
if len(des3_key) < 24:
des3_key = des3_key + b'\x00' * (24 - len(des3_key))
cipher = DES3.new(des3_key[:24], DES3.MODE_ECB)
decrypted = unpad(cipher.decrypt(encrypted), DES3.block_size)
plaintext = decrypted.decode('utf-8')
# Parse token
parts = plaintext.split('%')
return {
'user_info': parts[0] if len(parts) > 0 else '',
'expiration': int(parts[1]) if len(parts) > 1 else 0,
'expiration_date': datetime.fromtimestamp(int(parts[1]) / 1000) if len(parts) > 1 else None,
'signature': parts[2] if len(parts) > 2 else '',
'raw': plaintext
}
def decode_ltpa2_token(self, token: str) -> dict:
"""Decode and decrypt an LTPA2 token (useful for verification)"""
# Base64 decode
encrypted = base64.b64decode(token)
# Decrypt with AES-128-CBC
iv = self.keys.aes_key[:16]
cipher = AES.new(self.keys.aes_key, AES.MODE_CBC, iv)
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
plaintext = decrypted.decode('utf-8')
# Parse token - split on % to separate body from signature
parts = plaintext.split('%')
body = parts[0] if len(parts) > 0 else ''
signature = parts[1] if len(parts) > 1 else ''
# Parse attributes from body
attributes = {}
for attr in body.split('$'):
if ':' in attr:
key, value = attr.split(':', 1)
attributes[key] = value
expiration_ms = int(attributes.get('expire', 0))
return {
'attributes': attributes,
'expiration': expiration_ms,
'expiration_date': datetime.fromtimestamp(expiration_ms / 1000) if expiration_ms else None,
'signature': signature,
'raw': plaintext
}
def parse_datetime(dt_string: str) -> datetime:
"""Parse a datetime string in various formats"""
formats = [
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%d %H:%M',
'%Y-%m-%d',
]
for fmt in formats:
try:
return datetime.strptime(dt_string, fmt)
except ValueError:
continue
raise ValueError(f"Could not parse datetime: {dt_string}. "
f"Use format: YYYY-MM-DD HH:MM:SS")
def main():
parser = argparse.ArgumentParser(
description='Generate IBM WebSphere LTPA v1 and LTPA2 tokens',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Generate LTPA v1 token
%(prog)s -k ltpa.keys -p mypassword -u jsmith
# Generate LTPA2 token
%(prog)s -k ltpa.keys -p mypassword -u jsmith --version 2
# Generate both tokens with custom expiration
%(prog)s -k ltpa.keys -p mypassword -u jsmith --version both --expiry "2024-12-31 23:59:59"
# Generate with verbose output and custom lifetime
%(prog)s -k ltpa.keys -p mypassword -u jsmith -v --lifetime 480
# Use a WebSphere {xor} encoded password (auto-detected)
%(prog)s -k ltpa.keys -p "{xor}CDo9Hgw=" -u jsmith -v
# Decode an existing token
%(prog)s -k ltpa.keys -p mypassword --decode "base64tokenhere" --version 1
"""
)
parser.add_argument('-k', '--keys', required=True,
help='Path to ltpa.keys file')
parser.add_argument('-p', '--password', required=True,
help='Password used to encrypt the LTPA keys. Supports {xor} encoded passwords (auto-detected)')
parser.add_argument('-u', '--user',
help='Username for the token (e.g., jsmith or uid=jsmith,ou=users,dc=example,dc=com)')
parser.add_argument('--version', choices=['1', '2', 'both'], default='both',
help='LTPA version to generate (default: both)')
parser.add_argument('--expiry', type=str,
help='Token expiration time (format: YYYY-MM-DD HH:MM:SS). Default: current time + lifetime')
parser.add_argument('--lifetime', type=int, default=120,
help='Token lifetime in minutes (default: 120). Ignored if --expiry is set')
parser.add_argument('--time-offset', type=int, default=0,
help='Time offset in seconds to add/subtract from current time (for server sync)')
parser.add_argument('-v', '--verbose', action='store_true',
help='Enable verbose/debug output')
parser.add_argument('--decode', type=str,
help='Decode an existing token instead of generating one')
parser.add_argument('--realm', type=str,
help='Override realm from keys file')
args = parser.parse_args()
# Validate arguments
if not args.decode and not args.user:
parser.error("--user is required when generating tokens")
# Check keys file exists
keys_path = Path(args.keys)
if not keys_path.exists():
print(f"Error: Keys file not found: {args.keys}", file=sys.stderr)
sys.exit(1)
try:
# Check if password is {xor} encoded and show decoded value
is_xor_encoded = args.password.upper().startswith('{XOR}')
if is_xor_encoded:
decoded_password = decode_websphere_password(args.password, verbose=False)
print(f"Detected {{xor}} encoded password")
print(f"Decoded password: {decoded_password}")
print()
# Parse and decrypt keys
if args.verbose:
print("=" * 60)
print("LTPA Token Generator")
print("=" * 60)
keys = LTPAKeysFile(args.keys, args.password, verbose=args.verbose)
# Override realm if specified
if args.realm:
keys.realm = args.realm
if args.verbose:
print(f"[DEBUG] Realm overridden to: {args.realm}")
generator = LTPATokenGenerator(keys, verbose=args.verbose)
# Decode mode
if args.decode:
if args.verbose:
print("\n" + "-" * 60)
print("Decoding token...")
print("-" * 60)
if args.version == '1':
decoded = generator.decode_ltpa1_token(args.decode)
print("\nDecoded LTPA v1 Token:")
else:
# Try LTPA2 first, fall back to v1
try:
decoded = generator.decode_ltpa2_token(args.decode)
print("\nDecoded LTPA2 Token:")
except Exception:
decoded = generator.decode_ltpa1_token(args.decode)
print("\nDecoded LTPA v1 Token:")
for key, value in decoded.items():
if key != 'raw':
print(f" {key}: {value}")
if args.verbose:
print(f" raw: {decoded['raw']}")
sys.exit(0)
# Calculate expiration time
if args.expiry:
expiration = parse_datetime(args.expiry)
else:
base_time = datetime.now()
if args.time_offset:
base_time = datetime.fromtimestamp(base_time.timestamp() + args.time_offset)
expiration = base_time + timedelta(minutes=args.lifetime)
if args.verbose:
print("\n" + "-" * 60)
print("Token Generation Parameters")
print("-" * 60)
print(f" User: {args.user}")
print(f" Realm: {keys.realm}")
print(f" Expiration: {expiration}")
print(f" Time offset: {args.time_offset} seconds")
# Generate tokens
print("\n" + "=" * 60)
if args.version in ['1', 'both']:
token_v1 = generator.generate_ltpa1_token(args.user, expiration)
print("LtpaToken (v1):")
print(token_v1)
if args.verbose:
print("\nVerification - decoding generated v1 token:")
decoded = generator.decode_ltpa1_token(token_v1)
for key, value in decoded.items():
if key != 'raw':
print(f" {key}: {value}")
if args.version in ['2', 'both']:
if args.version == 'both':
print("\n" + "-" * 60)
token_v2 = generator.generate_ltpa2_token(args.user, expiration)
print("LtpaToken2 (v2):")
print(token_v2)
if args.verbose:
print("\nVerification - decoding generated v2 token:")
decoded = generator.decode_ltpa2_token(token_v2)
for key, value in decoded.items():
if key != 'raw':
print(f" {key}: {value}")
print("=" * 60)
# Print cookie usage hint
print("\nUsage as cookies:")
if args.version in ['1', 'both']:
print(f" Cookie: LtpaToken={token_v1}")
if args.version in ['2', 'both']:
print(f" Cookie: LtpaToken2={token_v2}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
if args.verbose:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment