1111- user.store_api_key: Langflow Store API keys
1212- variable.value: All encrypted variable values
1313- folder.auth_settings: MCP oauth_client_secret and api_key fields
14+ - sso_config.client_secret_encrypted: SSO/OIDC client secrets
1415
1516Usage:
1617 uv run python scripts/migrate_secret_key.py --help
2021
2122import argparse
2223import base64
24+ import binascii
2325import json
2426import os
2527import platform
2931from datetime import datetime , timezone
3032from pathlib import Path
3133
34+ from cryptography .exceptions import InvalidTag
3235from cryptography .fernet import Fernet , InvalidToken
36+ from cryptography .hazmat .primitives import hashes
37+ from cryptography .hazmat .primitives .ciphers .aead import AESGCM
38+ from cryptography .hazmat .primitives .kdf .hkdf import HKDF
3339from platformdirs import user_cache_dir
34- from sqlalchemy import create_engine , text
40+ from sqlalchemy import create_engine , inspect , text
3541
3642MINIMUM_KEY_LENGTH = 32
3743SENSITIVE_AUTH_FIELDS = ["oauth_client_secret" , "api_key" ]
3844# Must match langflow.services.variable.constants.CREDENTIAL_TYPE
3945CREDENTIAL_TYPE = "Credential"
46+ SSO_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm"
47+ SSO_AAD = SSO_ENVELOPE_HEADER .encode ()
48+ SSO_HKDF_SALT = b"langflow/sso/client-secret/hkdf-salt/v1"
49+ SSO_HKDF_INFO = b"langflow/sso/client-secret/encryption"
50+ SSO_NONCE_BYTES = 12
51+ SSO_TAG_BYTES = 16
52+ SSO_ENVELOPE_PARTS = 6
53+ SSO_HEADER_PARTS = 4
54+ SSO_BASE64URL_ALPHABET = frozenset ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" )
4055
4156
4257def get_default_config_dir () -> Path :
@@ -140,6 +155,62 @@ def migrate_value(encrypted: str, old_key: str, new_key: str) -> str | None:
140155 return None
141156
142157
158+ def _derive_sso_key (master_key : str ) -> bytes :
159+ return HKDF (
160+ algorithm = hashes .SHA256 (),
161+ length = 32 ,
162+ salt = SSO_HKDF_SALT ,
163+ info = SSO_HKDF_INFO ,
164+ ).derive (master_key .encode ())
165+
166+
167+ def _decode_sso_envelope (envelope : str ) -> tuple [bytes , bytes ]:
168+ parts = envelope .split (":" )
169+ if len (parts ) != SSO_ENVELOPE_PARTS or ":" .join (parts [:SSO_HEADER_PARTS ]) != SSO_ENVELOPE_HEADER :
170+ msg = "Unsupported SSO client-secret envelope"
171+ raise ValueError (msg )
172+ decoded_payloads : list [bytes ] = []
173+ for value in parts [4 :]:
174+ if not value or any (character not in SSO_BASE64URL_ALPHABET for character in value ):
175+ msg = "Invalid base64url data in SSO client-secret envelope"
176+ raise ValueError (msg )
177+ try :
178+ decoded_payloads .append (base64 .b64decode (value + "=" * (- len (value ) % 4 ), altchars = b"-_" , validate = True ))
179+ except (binascii .Error , ValueError ) as exc :
180+ msg = "Invalid base64url data in SSO client-secret envelope"
181+ raise ValueError (msg ) from exc
182+ nonce , ciphertext = decoded_payloads
183+ if len (nonce ) != SSO_NONCE_BYTES or len (ciphertext ) < SSO_TAG_BYTES :
184+ msg = "Invalid SSO client-secret envelope payload"
185+ raise ValueError (msg )
186+ return nonce , ciphertext
187+
188+
189+ def decrypt_sso_secret_with_key (envelope : str , key : str ) -> str :
190+ """Decrypt an SSO client-secret envelope with an explicit master key."""
191+ nonce , ciphertext = _decode_sso_envelope (envelope )
192+ plaintext = AESGCM (_derive_sso_key (key )).decrypt (nonce , ciphertext , SSO_AAD )
193+ return plaintext .decode ()
194+
195+
196+ def encrypt_sso_secret_with_key (plaintext : str , key : str ) -> str :
197+ """Encrypt an SSO client secret using the application's current envelope format."""
198+ nonce = os .urandom (SSO_NONCE_BYTES )
199+ ciphertext = AESGCM (_derive_sso_key (key )).encrypt (nonce , plaintext .encode (), SSO_AAD )
200+ encoded_nonce = base64 .urlsafe_b64encode (nonce ).rstrip (b"=" ).decode ()
201+ encoded_ciphertext = base64 .urlsafe_b64encode (ciphertext ).rstrip (b"=" ).decode ()
202+ return f"{ SSO_ENVELOPE_HEADER } :{ encoded_nonce } :{ encoded_ciphertext } "
203+
204+
205+ def migrate_sso_secret (envelope : str , old_key : str , new_key : str ) -> str | None :
206+ """Rewrap an SSO client-secret envelope under a replacement master key."""
207+ try :
208+ plaintext = decrypt_sso_secret_with_key (envelope , old_key )
209+ return encrypt_sso_secret_with_key (plaintext , new_key )
210+ except (InvalidTag , UnicodeDecodeError , ValueError ):
211+ return None
212+
213+
143214def migrate_auth_settings (auth_settings : dict , old_key : str , new_key : str ) -> tuple [dict , list [str ]]:
144215 """Re-encrypt sensitive fields in auth_settings dict.
145216
@@ -208,6 +279,17 @@ def verify_migration(conn, new_key: str) -> tuple[int, int]:
208279 except (InvalidToken , json .JSONDecodeError ):
209280 failed += 1
210281
282+ if inspect (conn ).has_table ("sso_config" ):
283+ configs = conn .execute (
284+ text ("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL LIMIT 3" )
285+ ).fetchall ()
286+ for _ , encrypted_secret in configs :
287+ try :
288+ decrypt_sso_secret_with_key (encrypted_secret , new_key )
289+ verified += 1
290+ except (InvalidTag , UnicodeDecodeError , ValueError ):
291+ failed += 1
292+
211293 return verified , failed
212294
213295
@@ -364,9 +446,38 @@ def migrate(
364446 total_migrated += migrated
365447 total_failed += failed
366448
449+ # Migrate sso_config.client_secret_encrypted when the optional SSO schema exists.
450+ print ("\n 4. Migrating SSO client secrets..." )
451+ migrated , failed = 0 , 0
452+ if inspect (conn ).has_table ("sso_config" ):
453+ configs = conn .execute (
454+ text ("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL" )
455+ ).fetchall ()
456+ for config_id , encrypted_secret in configs :
457+ new_encrypted = migrate_sso_secret (encrypted_secret , old_key , new_key )
458+ if new_encrypted :
459+ if not dry_run :
460+ conn .execute (
461+ text ("UPDATE sso_config SET client_secret_encrypted = :secret WHERE id = :id" ),
462+ {"secret" : new_encrypted , "id" : config_id },
463+ )
464+ migrated += 1
465+ else :
466+ failed += 1
467+ print (f" Warning: Could not decrypt SSO config { config_id } " )
468+ print (f" { 'Would migrate' if dry_run else 'Migrated' } : { migrated } , Failed: { failed } " )
469+ total_migrated += migrated
470+ total_failed += failed
471+
472+ if total_failed > 0 and not dry_run :
473+ print (f"\n ERROR: { total_failed } values could not be migrated." )
474+ print ("Rolling back all database changes; the secret key was not changed." )
475+ conn .rollback ()
476+ sys .exit (1 )
477+
367478 # Verify migrated data can be decrypted with new key
368479 if total_migrated > 0 :
369- print ("\n 4 . Verifying migration..." )
480+ print ("\n 5 . Verifying migration..." )
370481 verified , verify_failed = verify_migration (conn , new_key )
371482 if verify_failed > 0 :
372483 print (f" ERROR: { verify_failed } records failed verification!" )
@@ -386,12 +497,12 @@ def migrate(
386497 if not dry_run :
387498 backup_file = config_dir / f"secret_key.backup.{ datetime .now (timezone .utc ).strftime ('%Y%m%d_%H%M%S' )} "
388499 write_secret_key_to_file (config_dir , old_key , backup_file .name )
389- print (f"\n 5 . Backed up old key to: { backup_file } " )
500+ print (f"\n 6 . Backed up old key to: { backup_file } " )
390501 write_secret_key_to_file (config_dir , new_key )
391- print (f"6 . Saved new secret key to: { config_dir / 'secret_key' } " )
502+ print (f"7 . Saved new secret key to: { config_dir / 'secret_key' } " )
392503 else :
393- print ("\n 5 . [DRY RUN] Would backup old key" )
394- print (f"6 . [DRY RUN] Would save new key to: { config_dir / 'secret_key' } " )
504+ print ("\n 6 . [DRY RUN] Would backup old key" )
505+ print (f"7 . [DRY RUN] Would save new key to: { config_dir / 'secret_key' } " )
395506
396507 # Summary
397508 print ("\n " + "=" * 50 )
0 commit comments