2026-02-27 07:48:55 +05:45
#!/usr/bin/python3
import base64
from datetime import datetime
import os
2026-02-27 21:59:01 +05:45
import sys
2026-02-27 07:48:55 +05:45
import webbrowser
import requests
import argparse
import jwt
import json
import datetime
import pytz
from http . server import BaseHTTPRequestHandler , HTTPServer
from urllib . parse import urlparse , parse_qsl
from dotenv import load_dotenv
from sshkey_tools . cert import SSHCertificate
import logging
import coloredlogs
import subprocess
import base64
# Load environment variables from the .env file
load_dotenv ( )
# Get the API_URL from the environment variables
2026-02-27 21:59:01 +05:45
SIGN_URL = os . getenv ( " SIGN_URL " , " http://localhost:5000 " )
2026-02-27 07:48:55 +05:45
LISTENER_HOST_NAME = " 127.0.0.1 "
LISTENER_SERVER_PORT = 8250
2026-02-27 21:59:01 +05:45
CACHE_FILE = os . path . expanduser ( ' ~/.gatehouse/token_cache.json ' )
os . makedirs ( os . path . dirname ( CACHE_FILE ) , exist_ok = True )
CERT_FILE_PATH = " /tmp/ssh-cert "
2026-02-27 07:48:55 +05:45
CHALLENGE_FILE_PATH = " /tmp/challenge.txt "
CHALLENGE_SIG_FILE_PATH = " /tmp/challenge.txt.sig "
# Configure logger
logger = logging . getLogger ( __name__ )
coloredlogs . install ( level = ' DEBUG ' , logger = logger , fmt = ' %(asctime)s - %(name)s - %(levelname)s - %(message)s ' )
class MyServer ( BaseHTTPRequestHandler ) :
def do_GET ( self ) :
""" Handle GET requests and process token reception. """
global server_done , token
self . send_response ( 200 )
self . send_header ( " Content-type " , " text/html " )
self . end_headers ( )
self . wfile . write ( bytes ( " <html><head><title>OIDC Workflow Tool</title></head> " , " utf-8 " ) )
self . wfile . write ( bytes ( " <body><p>The token has been received</p> " , " utf-8 " ) )
self . wfile . write ( bytes ( " <p>Window closing in <span id= ' countdown ' >5</span> seconds...</p> " , " utf-8 " ) )
self . wfile . write ( bytes ( " <script>var count = 5; setInterval(function() { count--; document.getElementById( ' countdown ' ).textContent = count; if (count === 0) window.close(); }, 1000);</script> " , " utf-8 " ) )
self . wfile . write ( bytes ( " </body></html> " , " utf-8 " ) )
parsed_url = urlparse ( self . path )
query_data = dict ( parse_qsl ( parsed_url . query ) )
token = query_data . get ( ' token ' )
if token :
server_done = True
logger . info ( " Token received " )
save_token_to_cache ( token )
def log_message ( self , format , * args ) :
""" Log messages using the logger instead of stdout. """
logger . info ( " %s - %s " % ( self . client_address [ 0 ] , format % args ) )
def load_token_from_cache ( ) :
""" Load the token from the cache file. """
if os . path . exists ( CACHE_FILE ) :
with open ( CACHE_FILE , ' r ' ) as f :
data = json . load ( f )
if ' token ' in data :
return data [ ' token ' ]
return None
def save_token_to_cache ( token ) :
""" Save the token to the cache file. """
with open ( CACHE_FILE , ' w ' ) as f :
json . dump ( { ' token ' : token } , f )
def clear_token_cache ( ) :
""" Remove the cached token file. """
if os . path . exists ( CACHE_FILE ) :
os . remove ( CACHE_FILE )
logger . info ( " Cached token removed. " )
else :
logger . info ( " No cached token found. " )
def decode_and_validate_token ( token ) :
""" Decode the JWT and validate its claims. """
try :
decoded_token = jwt . decode ( token , options = { " verify_signature " : False } )
logger . debug ( " debug_jwt - Decoded token ok " )
iat = decoded_token . get ( ' iat ' )
exp = decoded_token . get ( ' exp ' )
if iat is None or exp is None :
raise ValueError ( " Token must contain ' iat ' and ' exp ' claims. " )
iat_utc = datetime . datetime . fromtimestamp ( iat , pytz . UTC ) . isoformat ( )
exp_utc = datetime . datetime . fromtimestamp ( exp , pytz . UTC ) . isoformat ( )
logger . debug ( f " debug_jwt - iat (UTC ISO): { iat_utc } " )
logger . debug ( f " debug_jwt - exp (UTC ISO): { exp_utc } " )
now = datetime . datetime . now ( pytz . UTC )
if datetime . datetime . fromtimestamp ( iat , pytz . UTC ) > now :
logger . debug ( f " debug_jwt - Token ' iat ' is after the current time. " )
if datetime . datetime . fromtimestamp ( exp , pytz . UTC ) < now :
logger . debug ( f " debug_jwt - Token ' exp ' is before the current time. " )
return False # Token has expired
return True # Token is valid
except Exception as e :
2026-02-27 21:59:01 +05:45
logger . debug ( f " Token validation failed: { e } " )
2026-02-27 07:48:55 +05:45
return False
def request_token ( ) :
global server_done , token
server_done = False
logger . info ( " Starting request_token process. " )
# Attempt to load the token from the cache
token = load_token_from_cache ( )
logger . debug ( " Token loaded from cache: %s " , token )
# Validate the cached token, if it exists
2026-02-27 21:59:01 +05:45
if token :
try :
if decode_and_validate_token ( token ) :
logger . info ( " Cached token is valid. Using cached token. " )
return token
except Exception :
pass
# Try opaque token via /auth/me
try :
r = requests . get (
f " { SIGN_URL } /api/v1/auth/me " ,
headers = { " Authorization " : f " Bearer { token } " } ,
timeout = 5 ,
)
if r . status_code == 200 :
logger . info ( " Cached session token is valid. Using cached token. " )
return token
except Exception :
pass
logger . info ( " Cached token is expired or invalid, requesting a new token. " )
token = " "
2026-02-27 07:48:55 +05:45
# Prepare the redirect URL for the token request
redirect_url = f " http:// { LISTENER_HOST_NAME } : { LISTENER_SERVER_PORT } /?token= "
logger . info ( " Redirect URL: %s " , redirect_url )
# Construct the token request URL
2026-02-27 21:59:01 +05:45
token_url = f " { SIGN_URL } /api/v1/token_please?redirect_url= { redirect_url } "
2026-02-27 07:48:55 +05:45
logger . info ( " Token request URL: %s " , token_url )
# Start the web server to handle the token response
logger . debug ( " Starting the HTTP server on %s : %d " , LISTENER_HOST_NAME , LISTENER_SERVER_PORT )
webServer = HTTPServer ( ( LISTENER_HOST_NAME , LISTENER_SERVER_PORT ) , MyServer )
# Open the web browser to initiate the token request
logger . info ( " Opening web browser to request token. " )
webbrowser . open ( token_url , new = 2 )
# Wait for the server to handle the request and receive the token
logger . debug ( " Waiting for the token response... " )
while not server_done :
webServer . handle_request ( )
logger . debug ( " Server handled a request, server_done status: %s " , server_done )
logger . info ( " Token received: %s " , token )
return token
def get_activated_ssh_key ( ) :
""" Retrieve the list of SSH keys and return the first verified key. """
try :
headers = {
' Authorization ' : f ' Bearer { token } ' ,
' Content-Type ' : ' application/json '
}
2026-02-27 21:59:01 +05:45
response = requests . get ( f " { SIGN_URL } /api/v1/ssh/keys " , headers = headers )
2026-02-27 07:48:55 +05:45
if response . status_code == 200 :
2026-02-27 21:59:01 +05:45
keys = response . json ( ) . get ( ' keys ' , [ ] )
2026-02-27 07:48:55 +05:45
verified_keys = [ key for key in keys if key [ ' verified ' ] ]
if not verified_keys :
logger . error ( " No verified SSH keys found for the user. " )
exit ( 1 )
if len ( verified_keys ) > 1 :
2026-02-27 21:59:01 +05:45
# If running interactively, let the user pick; otherwise use the most recently added key
if sys . stdout . isatty ( ) :
print ( " \n Multiple verified SSH keys found. Please choose one: " )
for i , k in enumerate ( verified_keys ) :
print ( f " [ { i + 1 } ] { k [ ' id ' ] [ : 8 ] } ... fingerprint= { k . get ( ' fingerprint ' , ' ? ' ) } name= { k . get ( ' key_comment ' , ' ? ' ) } " )
try :
choice = int ( input ( " Enter number: " ) . strip ( ) ) - 1
if 0 < = choice < len ( verified_keys ) :
return verified_keys [ choice ] [ ' id ' ]
except ( ValueError , EOFError ) :
pass
logger . info ( " Multiple verified SSH keys found; using the most recently added one. " )
verified_keys . sort ( key = lambda k : k . get ( ' created_at ' , ' ' ) , reverse = True )
2026-02-27 07:48:55 +05:45
return verified_keys [ 0 ] [ ' id ' ]
else :
logger . error ( f " Failed to retrieve SSH keys: { response . status_code } - { response . text } " )
exit ( 1 )
except Exception as e :
logger . error ( f " Error while retrieving SSH keys: { e } " )
exit ( 1 )
2026-02-27 21:59:01 +05:45
def request_certificate ( principals = None ) :
2026-02-27 07:48:55 +05:45
CERT_ID = os . getenv ( " CERT_ID " ) or get_activated_ssh_key ( )
2026-02-27 21:59:01 +05:45
if not principals :
env_principals = os . getenv ( " PRINCIPALS " )
if env_principals :
principals = [ p . strip ( ) for p in env_principals . split ( ' , ' ) ]
else :
principals = [ os . getlogin ( ) ]
2026-02-27 07:48:55 +05:45
headers = {
' content-type ' : ' application/json ' ,
" Authorization " : " bearer " + token
}
payload = {
2026-02-27 21:59:01 +05:45
' cert_id ' : CERT_ID ,
' principals ' : principals ,
2026-02-27 07:48:55 +05:45
}
try :
2026-02-27 21:59:01 +05:45
response = requests . post ( f " { SIGN_URL } /api/v1/ssh/sign " , json = payload , headers = headers )
2026-02-27 07:48:55 +05:45
2026-02-27 21:59:01 +05:45
if response . status_code == 201 :
2026-02-27 07:48:55 +05:45
json_result = response . json ( )
with open ( CERT_FILE_PATH , ' w ' ) as f :
f . write ( json_result [ ' certificate ' ] )
logger . info ( f " Certificate signed successfully, located at { CERT_FILE_PATH } " )
2026-02-27 21:59:01 +05:45
logger . info ( f " Valid for principals: { ' , ' . join ( json_result . get ( ' principals ' , principals ) ) } " )
2026-02-27 07:48:55 +05:45
logger . info ( " You can login to your destination server with the following command " )
logger . info ( f " \t ssh user@server -o CertificateFile= { CERT_FILE_PATH } " )
else :
logger . error ( " Error in response from server " )
logger . error ( f " Status code: { response . status_code } " )
logger . error ( f " Response text: { response . text } " )
except Exception as e :
logger . error ( f " Error during certificate signing: { e } " )
def generate_and_sign_challenge ( ssh_key_file , key_id ) :
""" Generate a challenge text, sign it using the SSH key, and return the signature. """
logger . debug ( f " generate_and_sign_challenge - { ssh_key_file } { key_id } " )
#Fetch challenge text from API
try :
global token
if not token :
raise EnvironmentError ( " TOKEN environment variable is not set " )
headers = {
' Authorization ' : f ' Bearer { token } ' ,
" Content-Type " : " application/json " ,
}
# Send the POST request
response = requests . get (
2026-02-27 21:59:01 +05:45
f " { SIGN_URL } /api/v1/ssh/keys/ { key_id } /verify " ,
2026-02-27 07:48:55 +05:45
headers = headers
)
if response . status_code != 200 :
logger . error ( f " Server returned unexpected code { response . status_code } " )
return False
2026-02-27 21:59:01 +05:45
challenge_text = response . json ( ) . get ( ' challenge_text ' , response . json ( ) . get ( ' validationText ' , ' ' ) ) + " \n "
2026-02-27 07:48:55 +05:45
except Exception as e :
logger . error ( f " Unable to fetch SSH Key validation data { e } " )
return False
try :
logger . debug ( f " generate_and_sign_challenge - procesing challenge with text { challenge_text } " )
if os . path . exists ( CHALLENGE_FILE_PATH ) :
os . remove ( CHALLENGE_FILE_PATH )
if os . path . exists ( CHALLENGE_SIG_FILE_PATH ) :
os . remove ( CHALLENGE_SIG_FILE_PATH )
with open ( CHALLENGE_FILE_PATH , ' w ' ) as f :
f . write ( challenge_text )
# Sign the challenge text using the SSH key
result = subprocess . run ( [ " ssh-keygen " , " -Y " , " sign " , " -f " , ssh_key_file , " -n " , " file " , CHALLENGE_FILE_PATH ] , check = True )
logger . debug ( f " generate_and_sign_challenge - { result } " )
# Read the signature
with open ( CHALLENGE_SIG_FILE_PATH , ' rb ' ) as f :
signature = base64 . b64encode ( f . read ( ) ) . decode ( ' utf-8 ' )
submit_signature_validation ( signature , key_id )
return signature
except Exception as e :
logger . error ( f " Unable to sign the challenge reponse { e } " )
def submit_signature_validation ( signature , key_id ) :
try :
# Define the headers and payload
global token
if not token :
raise EnvironmentError ( " TOKEN environment variable is not set " )
headers = {
' Authorization ' : f ' Bearer { token } ' ,
" Content-Type " : " application/json " ,
}
logger . debug ( f " submit_signature_validation - { signature } " )
payload = {
" signature " : signature
}
# Send the POST request
response = requests . post (
2026-02-27 21:59:01 +05:45
f " { SIGN_URL } /api/v1/ssh/keys/ { key_id } /verify " ,
2026-02-27 07:48:55 +05:45
headers = headers ,
json = payload
)
# Print the response
print ( response . status_code )
print ( response . text )
except Exception as e :
logger . error ( f " submit_signature_validation - Unable to submit the challenge response { e } " )
def remove_ssh_key ( key_id = None ) :
"""
Remove an SSH key from the server. If key_id is None, list keys and prompt user to pick one.
"""
global token
if not token :
raise EnvironmentError ( " TOKEN environment variable is not set " )
headers = {
' Authorization ' : f ' Bearer { token } ' ,
' Content-Type ' : ' application/json '
}
# List keys first
2026-02-27 21:59:01 +05:45
response = requests . get ( f " { SIGN_URL } /api/v1/ssh/keys " , headers = headers )
2026-02-27 07:48:55 +05:45
if response . status_code != 200 :
logger . error ( f " Failed to list SSH keys: { response . status_code } - { response . text } " )
exit ( 1 )
2026-02-27 21:59:01 +05:45
keys = response . json ( ) . get ( ' keys ' , [ ] )
2026-02-27 07:48:55 +05:45
if not keys :
logger . info ( " No SSH keys found for your user. " )
return
if key_id :
# Delete specific key
target = next ( ( k for k in keys if k [ ' id ' ] == key_id ) , None )
if not target :
logger . error ( f " Key ID { key_id } not found in your profile. " )
exit ( 1 )
keys_to_delete = [ target ]
else :
# Show all keys and let user pick
print ( " \n Your SSH keys: " )
for i , k in enumerate ( keys ) :
verified = " ✓ verified " if k [ ' verified ' ] else " ✗ unverified "
print ( f " [ { i + 1 } ] { k [ ' id ' ] } { verified } { k . get ( ' description ' , ' ' ) } (added { k [ ' created_at ' ] [ : 10 ] } ) " )
print ( " [a] Delete ALL keys " )
print ( " [q] Quit " )
choice = input ( " \n Enter number to delete (or ' a ' for all, ' q ' to quit): " ) . strip ( ) . lower ( )
if choice == ' q ' :
return
elif choice == ' a ' :
keys_to_delete = keys
else :
try :
idx = int ( choice ) - 1
if idx < 0 or idx > = len ( keys ) :
raise ValueError ( )
keys_to_delete = [ keys [ idx ] ]
except ValueError :
logger . error ( " Invalid selection. " )
exit ( 1 )
for k in keys_to_delete :
2026-02-27 21:59:01 +05:45
del_response = requests . delete ( f " { SIGN_URL } /api/v1/ssh/keys/ { k [ ' id ' ] } " , headers = headers )
2026-02-27 07:48:55 +05:45
if del_response . status_code == 200 :
logger . info ( f " Key { k [ ' id ' ] } removed successfully. " )
else :
logger . error ( f " Failed to remove key { k [ ' id ' ] } : { del_response . status_code } - { del_response . text } " )
def add_ssh_key ( ssh_key_file ) :
"""
Add an SSH key to the server.
Args:
ssh_key_file (file): The SSH key file to be added.
"""
global token
if not token :
raise EnvironmentError ( " TOKEN environment variable is not set " )
headers = {
' Authorization ' : f ' Bearer { token } ' ,
' Content-Type ' : ' application/json '
2026-02-27 21:59:01 +05:45
}
if hasattr ( ssh_key_file , ' read ' ) :
# File object (e.g. argparse.FileType('rb'))
key_bytes = ssh_key_file . read ( )
key_path = ssh_key_file . name
elif isinstance ( ssh_key_file , bytes ) :
key_bytes = ssh_key_file
key_path = None
else :
# String path
key_path = str ( ssh_key_file )
with open ( key_path , ' rb ' ) as f :
key_bytes = f . read ( )
ssh_key = key_bytes . decode ( ' utf-8 ' ) . strip ( )
2026-02-27 07:48:55 +05:45
payload = {
' description ' : ' Added via gatehouse CLI tool ' ,
' key ' : ssh_key
}
2026-02-27 21:59:01 +05:45
response = requests . post ( f " { SIGN_URL } /api/v1/ssh/keys " , json = payload , headers = headers )
2026-02-27 07:48:55 +05:45
2026-02-27 21:59:01 +05:45
if response . status_code == 201 :
ssh_key_id = response . json ( ) [ ' id ' ]
2026-02-27 07:48:55 +05:45
logger . info ( f " SSH key { ssh_key_id } added successfully " )
2026-02-27 21:59:01 +05:45
if key_path :
# Strip .pub suffix to get the private key path for signing
private_key_path = key_path [ : - 4 ] if key_path . endswith ( ' .pub ' ) else key_path
generate_and_sign_challenge ( private_key_path , ssh_key_id )
else :
logger . warning ( " No key file path available — skipping auto-verification. "
" Run with -k <path> to enable automatic key verification. " )
2026-02-27 07:48:55 +05:45
else :
logger . error ( f " Failed to add SSH key: { response . status_code } - { response . text } " )
def checkCert ( ) :
logger . info ( " Running cert check " )
if not os . path . isfile ( CERT_FILE_PATH ) :
logger . warning ( " Certificate does not exist, new certificate required " )
return 1
# Check the current cert first
certificate = SSHCertificate . from_file ( CERT_FILE_PATH )
# Get the current datetime
now = datetime . datetime . now ( )
logger . debug ( certificate
)
# Check if the date is in the past or future
if certificate . get ( " valid_before " ) > now :
# Expiry is in the future
if args . force :
return 0
else :
logger . info ( " You have a valid SSH Certificate with the principals {} expiring at {} , not renewing. Use -f to force renewal " . format ( certificate . get ( " principals " ) , certificate . get ( " valid_before " ) ) )
return 0
else :
logger . warning ( " Certificate is not valid, renewal required " )
return 1
if __name__ == " __main__ " :
parser = argparse . ArgumentParser ( description = ' Sign an SSH key via a web service ' )
parser . add_argument ( " -k " , " --ssh-key " , type = argparse . FileType ( ' rb ' ) , dest = " sshkeyfile " , help = " Add an SSH Public Key to your user profile in gatehouse " )
parser . add_argument ( " -f " , " --force " , action = ' store_true ' , default = False , help = " Force the certificate renewal " )
parser . add_argument ( " -a " , " --add-key " , action = ' store_true ' , default = False , help = " Add SSH key to the server " )
parser . add_argument ( " -c " , " --check-cert " , action = ' store_true ' , default = False , help = " Check the certificate, if it ' s valid exit 0, if it ' s invalid exit 1 " )
parser . add_argument ( " -r " , " --request-cert " , action = ' store_true ' , default = False , help = " Request that gatehouse sign a new certificate for you based on an SSH public key on file in your profile " )
2026-02-27 21:59:01 +05:45
parser . add_argument ( " --principals " , nargs = ' + ' , metavar = ' PRINCIPAL ' , help = " Unix usernames for the certificate (default: current OS user) " )
2026-02-27 07:48:55 +05:45
parser . add_argument ( " --clear-cache " , action = ' store_true ' , default = False , help = " Remove the cached authentication token " )
parser . add_argument ( " --remove-key " , nargs = ' ? ' , const = ' ' , metavar = ' KEY_ID ' , help = " Remove an SSH key from your profile. Omit KEY_ID to pick interactively. " )
2026-02-27 21:59:01 +05:45
parser . add_argument ( " --list-keys " , action = ' store_true ' , default = False , help = " List SSH keys in your profile " )
2026-02-27 07:48:55 +05:45
args = parser . parse_args ( )
2026-02-27 21:59:01 +05:45
if not ( args . check_cert or args . request_cert or args . add_key or args . clear_cache
or args . remove_key is not None or args . list_keys ) :
parser . error ( " At least one of --check-cert, --request-cert, --add-key, --list-keys, --remove-key, or --clear-cache must be provided. " )
2026-02-27 07:48:55 +05:45
# Retrieve SSH key from environment variables if not provided via CLI
ssh_key_file = args . sshkeyfile if args . sshkeyfile else os . getenv ( ' SSH_KEY_FILE ' )
if args . check_cert :
logger . info ( " Only checking certificate " )
exit ( checkCert ( ) )
if args . clear_cache :
clear_token_cache ( )
exit ( 0 )
if args . remove_key is not None :
request_token ( )
remove_ssh_key ( args . remove_key if args . remove_key else None )
exit ( 0 )
2026-02-27 21:59:01 +05:45
if args . list_keys :
request_token ( )
response = requests . get (
f " { SIGN_URL } /api/v1/ssh/keys " ,
headers = { " Authorization " : f " Bearer { token } " } ,
)
if response . status_code == 200 :
data = response . json ( )
keys = data . get ( ' keys ' , [ ] )
if not keys :
print ( " No SSH keys found in your profile. " )
else :
for k in keys :
verified = " ✓ verified " if k . get ( ' verified ' ) else " ✗ unverified "
print ( f " { k [ ' id ' ] } { verified } { k . get ( ' description ' , ' ' ) } (added { k [ ' created_at ' ] [ : 10 ] } ) " )
else :
logger . error ( f " Failed to list SSH keys: { response . status_code } - { response . text } " )
exit ( 0 )
2026-02-27 07:48:55 +05:45
if args . add_key :
request_token ( )
if not ssh_key_file :
logger . error ( " SSH key file is required to add SSH key " )
exit ( 1 )
# If ssh_key_file is retrieved from the environment, it will be a string (file path), so open it
if isinstance ( ssh_key_file , str ) :
with open ( ssh_key_file , ' rb ' ) as f :
ssh_key_file = f . read ( )
add_ssh_key ( ssh_key_file )
exit ( 0 )
if args . request_cert :
if args . force :
request_token ( )
logger . info ( " Forcing renewal of certificate " )
2026-02-27 21:59:01 +05:45
request_certificate ( principals = args . principals )
2026-02-27 07:48:55 +05:45
if checkCert ( ) == 1 :
request_token ( )
2026-02-27 21:59:01 +05:45
request_certificate ( principals = args . principals )
2026-02-27 07:48:55 +05:45
exit ( 0 )