
    *j=W                     |   U d Z ddlZddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlmZmZ  ej        e          ZdZdefdZ eh d          ZdZd	Zed
z  Zdeeef         fdZdeeef         ddfdZ e            Zedz  Z dZ!dZ"deee#e         f         fdZ$deee#e         f         ddfdZ% e$            Z& e
j'                    Z(dede)fdZ*deddfdZ+dede,fdZ-da.e,dz  e/d<   da0e,dz  e/d<   de,fdZ1de,fdZ2ddde,dz  defdZ3 e
j'                    Z4d a5e)e/d!<   da6edz  e/d"<   d9d#Z7dedz  fd$Z8de)fd%Z9de)fd&Z:de)fd'Z;de)fd(Z<d)ede)fd*Z=defd+Z>d, Z?d-ede)fd.Z@d-ededz  fd/ZAd-ededz  fd0ZBd-ed1ede)fd2ZCd9d3ZDdedz  fd4ZEde)fd5ZFd:de)fd6ZGd9d7ZHd9d8ZIdS );z
Hermes Web UI -- optional authentication.
Off by default. Enable by setting HERMES_WEBUI_PASSWORD, configuring a
password in Settings, or registering passkeys and then going passwordless.
    N)	STATE_DIRload_settingsi ' returnc                  \   t          j        dd                                          } |                                 r!t	          |           }d|cxk    rdk    rn n|S t                      }|                    d          }t          |t                    rd|cxk    rdk    rn n|S t          S )a  Resolve session TTL from env > settings > default.

    Priority mirrors get_password_hash(): HERMES_WEBUI_SESSION_TTL env var
    first, then settings.json, falling back to ``SESSION_TTL`` (30 days).
    Clamped to [60s, 1 year] to prevent runaway cookies or self-lockout.
    HERMES_WEBUI_SESSION_TTL <   i3session_ttl_seconds)	osgetenvstripisdigitintr   get
isinstanceSESSION_TTL)env_vvalsvs       +/Users/bertmahoney/hermes-webui/api/auth.py_resolve_session_ttlr      s     I0"55;;==E}} %jj#########JA	#$$A!S bA444444444    >   /login/sw.js/health/favicon.ico/manifest.json/api/auth/login/api/auth/status/manifest.webmanifest/session/manifest.json/api/auth/passkey/login/api/auth/passkey/options/session/manifest.webmanifesthermes_sessionzX-Hermes-CSRF-Tokenz.sessions.jsonc                     	 t                                           rt          j        t                               d                    } t          | t                    st          d          t          j                    fd| 	                                D             S n2# t          $ r%}t                              d|           Y d}~nd}~ww xY wi S )zLoad persisted sessions from STATE_DIR, pruning expired entries.

    Returns an empty dict on any read or parse error so startup is never
    blocked by a corrupt or missing sessions file.
    utf-8encodingu)   malformed sessions file — expected dictc                     i | ]?\  }}t          |t                    r%t          |t          t          f          r	|k    <||@S  )r   strr   float.0texpnows      r   
<dictcomp>z"_load_sessions.<locals>.<dictcomp>K   sa     [ [ [vq#!!S))[.8sEl.K.K[PSVYPYPY sPYPYPYr   z0Failed to load sessions file, starting fresh: %sN)_SESSIONS_FILEexistsjsonloads	read_textr   dict
ValueErrortimeitems	Exceptionloggerdebug)dataer3   s     @r   _load_sessionsrC   ?   s    	L  "" 	[:n666HHIIDdD)) N !LMMM)++C[ [ [ [ [ [ [ [	[  L L LGKKKKKKKKLIs   BB! !
C+CCsessionsc                 (   	 t          j        dd           t          j        t           d          \  }}	 t	          j        |dd          5 }t          j        | |           ddd           n# 1 swxY w Y   t	          j        |d	           t	          j	        |t                     dS # t          $ r( 	 t	          j        |           n# t          $ r Y nw xY w w xY w# t          $ r&}t                              d
|           Y d}~dS d}~ww xY w)zAtomically persist sessions to STATE_DIR/.sessions.json (0600).

    Uses a temp file + os.replace() so a crash mid-write never leaves a
    truncated file.  Mirrors the same pattern as .signing_key persistence.
    Tparentsexist_okz.sessions.tmpdirsuffixwr(   r)   N  zFailed to persist sessions: %s)r   mkdirtempfilemkstempr   fdopenr7   dumpchmodreplacer5   r>   unlinkOSErrorr?   r@   )rD   fdtmpfrB   s        r   _save_sessionsrZ   R   sy   :t4444"yIIIC
	2sW555 '	(A&&&' ' ' ' ' ' ' ' ' ' ' ' ' ' 'HS%   JsN+++++ 	 	 		#   	  : : :5q999999999:sp   4C! B, A0$B, 0A44B, 7A482B, ,
C7CC
CCCCC! !
D+DDz.login_attempts.json   r	   c                  &   	 t                                           rt          j        t                               d                    } t          | t                    st          d          t          j                    i }| 	                                D ]E\  }}t          |t                    rt          |t                    s0fd|D             }|r|||<   F|S n2# t          $ r%}t                              d|           Y d}~nd}~ww xY wi S )zFLoad persisted login attempts from STATE_DIR, pruning expired entries.r(   r)   u/   malformed login-attempts file — expected dictc                     g | ]H}t          |t          t          f          r*t          |          z
  t          k     9t          |          IS r,   )r   r   r.   _LOGIN_WINDOWr0   r1   r3   s     r   
<listcomp>z(_load_login_attempts.<locals>.<listcomp>   sV       !!c5\22 8;U1XX~7U7U !HH7U7U7Ur   z6Failed to load login attempts file, starting fresh: %sN)_LOGIN_ATTEMPTS_FILEr6   r7   r8   r9   r   r:   r;   r<   r=   r-   listr>   r?   r@   )rA   attemptsip	raw_timesfreshrB   r3   s         @r   _load_login_attemptsrg   s   sE   R&&(( 	:2<<g<NNOODdD)) T !RSSS)++C/1H!% 	) 	)I!"c** *Y2M2M    &  
  )#(HRLO!	"  R R RMqQQQQQQQQRIs   CC 
D)D		Drc   c                 H   	 t           j                            dd           t          j        t           j        d          \  }}	 t          j        |dd          5 }t          j        | |           ddd           n# 1 swxY w Y   t          j	        |d	           t          j
        |t                      dS # t          $ r( 	 t          j        |           n# t          $ r Y nw xY w w xY w# t          $ r&}t                              d
|           Y d}~dS d}~ww xY w)zKAtomically persist login attempts to STATE_DIR/.login_attempts.json (0600).TrF   z.login_attempts.tmprI   rL   r(   r)   NrM   z$Failed to persist login attempts: %s)ra   parentrN   rO   rP   r   rQ   r7   rR   rS   rT   r>   rU   rV   r?   r@   )rc   rW   rX   rY   rB   s        r   _save_login_attemptsrj      s   @#))$)FFF"';'BK`aaaC
	2sW555 '	(A&&&' ' ' ' ' ' ' ' ' ' ' ' ' ' 'HS%   Js011111 	 	 		#   	  @ @ @;Q?????????@sr   AC1 B< B 4B<  BB< B2B< <
C.CC.
C)&C.(C))C..C1 1
D!;DD!rd   c                 f   t           5  t          j                    t                              | g           }fd|D             }|r|t          | <   nt                              | d           t          t                     t          |          t          k     cddd           S # 1 swxY w Y   dS )z@Return True if the IP is allowed to attempt login (thread-safe).c                 0    g | ]}|z
  t           k     |S r,   )r^   r_   s     r   r`   z%_check_login_rate.<locals>.<listcomp>   s'    CCC!37]+B+BA+B+B+Br   N)_LOGIN_ATTEMPTS_LOCKr<   _login_attemptsr   poprj   len_LOGIN_MAX_ATTEMPTS)rd   rc   r3   s     @r   _check_login_raterr      s    	 
3 
3ikk"&&r2..CCCCxCCC 	*"*OBD)))_---8}}22
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3 
3s   BB&&B*-B*c                    t           5  t          j                    }t                              | g           }|                    |           |t          | <   t          t                     ddd           dS # 1 swxY w Y   dS )z7Record a login attempt for rate limiting (thread-safe).N)rm   r<   rn   r   appendrj   )rd   r3   rc   s      r   _record_login_attemptru      s    	 . .ikk"&&r2..&_---. . . . . . . . . . . . . . . . . .s   A"A77A;>A;filenamec                     t           | z  }	 |                                r1|                                }t          |          dk    r
|dd         S n+# t          $ r t
                              d|            Y nw xY wt          j        d          }	 t          j	        dd           |
                    |           |                    d           n+# t          $ r t
                              d|            Y nw xY w|S )zLLoad a 32-byte key from STATE_DIR, generating and persisting one if missing.    NzFailed to read key %sTrF   rM   zFailed to persist key %s)r   r6   
read_bytesrp   rV   r?   r@   secretstoken_bytesrN   write_bytesrS   )rv   key_filerawkeys       r   	_load_keyr      s   8#H8?? 	 %%''C3xx2~~3B3x 8 8 8,h777778

b
!
!C;t4444S!!!u ; ; ;/:::::;Js%   AA %A:9A:A C %C;:C;_PBKDF2_KEY_CACHE_SIGNING_KEY_CACHEc                  <    t           t          d          a t           S )Nz.pbkdf2_key)r   r   r,   r   r   _pbkdf2_keyr      s     %m44r   c                  <    t           t          d          a t           S )Nz.signing_key)r   r   r,   r   r   _signing_keyr      s    !&~66r   saltr   c                    |t                      }t          j        d|                                 |d          }|                                S )a   PBKDF2-SHA256 with 600k iterations (OWASP recommendation).
    Salt is the persisted PBKDF2 key, which is secret and unique per
    installation. This keeps the stored hash format a plain hex string
    (no format change to settings.json) while replacing the predictable
    STATE_DIR-derived salt from the original implementation.

    The *salt* parameter exists solely to support transparent migration
    of password hashes that were computed with a different key (e.g. the
    old `.signing_key`). Normal callers should never pass it.
    Nsha256i'	 )r   hashlibpbkdf2_hmacencodehex)passwordr   dks      r   _hash_passwordr      s?     |}}		Xx'8'8$	H	HB6688Or   F_AUTH_HASH_COMPUTED_AUTH_HASH_CACHEc                  N    t           5  dadaddd           dS # 1 swxY w Y   dS )zInvalidate the in-process password hash cache so the next call to
    get_password_hash() re-reads from settings.json or the env var.FN)_AUTH_HASH_LOCKr   r   r,   r   r   _invalidate_password_hash_cacher      sz     
    #                                   s   c                  X   t           rt          S t          5  t           rt          cddd           S t          j        dd                                          } | rt          |           }n#t                                          d          pd}|ada |cddd           S # 1 swxY w Y   dS )u:  Return the active password hash, or None if auth is disabled.
    Priority: env var > settings.json.

    The hash is computed once and cached for the lifetime of the process.
    PBKDF2-600k takes ~1 s and is called on nearly every HTTP request via
    check_auth → is_auth_enabled, so caching avoids wasting a full second
    of CPU per request after the first one.

    Thread-safe: double-checked locking ensures that under a burst of
    concurrent requests only one thread computes PBKDF2, while the fast
    path (after initialisation) requires zero locks.
    NHERMES_WEBUI_PASSWORDr   password_hashT)	r   r   r   r   r   r   r   r   r   )env_pwresults     r   get_password_hashr     s       	    	$#	        2B77==?? 	B#F++FF"__((99ATF!"                 s   BA!BB#&B#c                  "    t                      duS )z7True if a password is configured (env var or settings).N)r   r,   r   r   is_password_auth_enabledr   &  s    d**r   c                     t          j        dd          } | r(|                                                                 dv S 	 ddlm}  |            }t          |t                    ri|                    d          }t          |t                    r|S t          |t                    r(|                                                                dv S n# t          $ r Y nw xY wdS )a  Return True if the passkey/WebAuthn surface is enabled for this deployment.

    Passkey support is opt-in default-off behind a feature flag so deployments
    that don't want the WebAuthn surface (or whose RP-ID setup isn't ready for
    non-localhost hosts) can disable it entirely with no UI surface, no
    endpoints, no credential storage. To enable:

      - Set ``HERMES_WEBUI_PASSKEY=1`` in the environment, OR
      - Set ``webui_passkey_enabled: true`` in the per-profile config.yaml

    With the flag off, ``are_passkeys_enabled()`` always returns False even if
    credentials were registered in the past, and ``/login`` shows password-only.
    HERMES_WEBUI_PASSKEYr   >   1onyestruer   )
get_configwebui_passkey_enabledF)r   r   r   lower
api.configr   r   r:   r   boolr-   r>   )	env_valuer   cfgr~   s       r   _passkey_feature_flag_enabledr   +  s     	0"55I G  &&((,FFF))))))jllc4   	I''122C#t$$ 
#s## Iyy{{((**.HHH   5s   AC <C 
CCc                      t                      sdS 	 ddlm}   |             S # t          $ r&}t                              d|           Y d}~dS d}~ww xY w)z_True if the passkey feature flag is on AND at least one local passkey credential is registered.Fr   )passkeys_availablez*Failed to inspect passkey availability: %sN)r   api.passkeysr   r>   r?   r@   )r   excs     r   are_passkeys_enabledr   K  s|    (** u333333!!###   A3GGGuuuuus   " 
AAAc                  :    t                      pt                      S )z9True if password auth or passkey-only auth is configured.)r   r   r,   r   r   is_auth_enabledr   X  s    #%%?)=)?)??r   plainc                 (   t                      }|sdS t          j        t          |           |          rdS t	                      }t                      }||k    r9t          j        t          | |          |          rddlm}  |d| i           dS dS )aV  Verify a plaintext password against the stored hash.

    Supports transparent migration of password hashes that were computed
    with the old `.signing_key` salt.  When the two keys differ and the
    legacy-salted hash matches, the password is transparently re-hashed
    with the current `.pbkdf2_key` and persisted to settings.json.
    FTr   r   )save_settings_set_password)r   hmaccompare_digestr   r   r   r   r   )r   expectedlegacy_saltcurrent_saltr   s        r   verify_passwordr   ]  s     !""H u>%00(;; t ..K==Ll""~e+FFFQQ 	000000M?E2333 45r   c                  T   t          j        d          } t          j                    t                      z   t          | <   t          t                     t          j        t                      | 	                                t          j                                                  }|  d| S )z7Create a new auth session. Returns signed cookie value.rx   .)rz   	token_hexr<   r   	_sessionsrZ   r   newr   r   r   r   	hexdigest)tokensigs     r   create_sessionr   |  s}    b!!Ey{{%9%;%;;Ie9
(<>>5<<>>7>
B
B
L
L
N
NCcr   c                      t          j                     fdt                                          D             } | r6| D ]}t                              |d           t	          t                     dS dS )zFRemove all expired session entries to prevent unbounded memory growth.c                 &    g | ]\  }}|k    |S r,   r,   r/   s      r   r`   z+_prune_expired_sessions.<locals>.<listcomp>  s"    >>>VQC#IIqIIIr   N)r<   r   r=   ro   rZ   )expiredr   r3   s     @r   _prune_expired_sessionsr     s}    
)++C>>>>y00>>>G " 	' 	'EMM%&&&&y!!!!!" "r   cookie_valuec                 6   | rd| vrdS t                       |                     dd          \  }}t          j        t	                      |                                t          j                                                  }t          j	        ||          p/t          |          dk    ot          j	        ||dd                   }|sdS t                              |          }|rt          j                    |k    rt                              |d           dS dS )zFVerify a signed session cookie. Returns True if valid and not expired.r   F   rx   NT)r   rsplitr   r   r   r   r   r   r   r   rp   r   r   r<   ro   )r   r   r   full_sigvalidexpirys         r   verify_sessionr     s    3l22u$$S!,,JE3xGGQQSSH X.. CBB4.sHSbSMBB 
  u]]5!!F TY[[6))eT"""u4r   c                 L    | rd| vrdS |                      dd          \  }}|pdS )zDReturn the raw server-side session token from a signed cookie value.r   Nr   )r   )r   r   _sigs      r    _session_token_from_cookie_valuer     s<     3l22t%%c1--KE4=Dr   c                     t          |           }|sdS t          j        t                      d|                                 t
          j                                                  S )a  Return the CSRF token bound to an authenticated WebUI session.

    The browser can read this token from the authenticated shell and echoes it
    in ``X-Hermes-CSRF-Token`` on unsafe API requests. The token is derived
    from the HttpOnly session cookie's server-side token, so it automatically
    rotates on login and is invalidated when the auth session expires or logs
    out. Callers must still verify the auth session before trusting it.
    Nzcsrf:)r   r   r   r   r   r   r   r   r   r   s     r   csrf_token_for_sessionr     sV     -\::E t8LNNOEOO$:$:$<$<gnMMWWYYYr   
csrf_tokenc                     | r|rt          |           sdS t          |           }t          |o!t          j        t          |          |                    S )z@Verify a submitted CSRF token against the authenticated session.F)r   r   r   r   r   r-   )r   r   r   s      r   verify_csrf_tokenr     sX     z 1M1M u%l33HKT0Z(KKLLLr   c                     | rZd| v rX|                      dd          d         }|t          v r5t                              |d           t          t                     dS dS dS dS )zRemove a session token.r   r   r   N)r   r   ro   rZ   r   s     r   invalidate_sessionr     sw     &|++##C++A.IMM%&&&9%%%%%	& &++r   c                 *   | j                             dd          }|sdS t          j                                        }	 |                    |           n# t          j        j        $ r Y dS w xY w|                    t                    }|r|j        ndS )z1Extract the auth cookie from the request headers.Cookier   N)	headersr   httpcookiesSimpleCookieloadCookieErrorCOOKIE_NAMEvalue)handlercookie_headercookiemorsels       r   parse_cookier     s    O''"55M t\&&((FM""""<#   ttZZ$$F!+6<<t+s   A A-,A-c                 b   t                      sdS |j        t          v s4|j                            d          s|j                            d          rdS t	          |           }|rt          |          rdS |j                            d          rd}|                     d           |                     dd           |                     d	t          t          |                               | 
                                 | j                            |           n|                     d
           ddlm} |j        pd}|j        r|d|j        z   z  }|                    |d          }|                     dd|z              |                     d	d           | 
                                 dS )zCheck if request is authorized. Returns True if OK.
    If not authorized, sends 401 (API) or 302 redirect (page) and returns False.Tz/static/z/session/static/z/api/s#   {"error":"Authentication required"}i  zContent-Typezapplication/jsonzContent-Lengthi.  r   N/?)safeLocationzlogin?next=0F)r   pathPUBLIC_PATHS
startswithr   r   send_responsesend_headerr-   rp   end_headerswfilewriteurllib.parseparsequeryquote)r   parsed
cookie_valbody	_urlparse_path_with_query_nexts          r   
check_authr    s     t{l""fk&<&<Z&H&H"FKLbLbcuLvLv"tg&&J nZ00 t{g&& %5c"""N,>???,c#d))nn===D!!!!c"""( 	)(((((!;-#< 	3fl 22  0s;;J(=>>>,c2225r   c                    t          j        dd                                                                          }|dv rdS |dv rdS | 9t	          | j        dd          dS | j                            d	d          d
k    rdS dS )u  Return True if cookies should carry the Secure flag.

    Behaviour is overridable via HERMES_WEBUI_SECURE env var for
    reverse-proxy setups where TLS terminates at a frontend proxy
    (nginx, Cloudflare, etc.) and Python only sees plain HTTP.
    1/true/yes → force Secure on; 0/false/no → force Secure off.
    When unset, fall back to heuristics: direct TLS socket (getpeercert)
    or X-Forwarded-Proto header from the request.

    .. warning::
       The ``X-Forwarded-Proto`` header is only trustworthy when a
       reverse proxy (nginx, Cloudflare, etc.) is deployed in front
       of the application.  Without a proxy, any client can forge the
       header and cause the Secure flag to be set on plain HTTP.
    HERMES_WEBUI_SECUREr   )r   r   r   T)r   falsenoFNgetpeercertzX-Forwarded-Protohttps)r   r   r   r   getattrrequestr   r   )r   envs     r   _is_secure_contextr    s      ))2
.
.
4
4
6
6
<
<
>
>C
"""t
"""u7?M488D4?2B777BB45r   c                    t           j                                        }||t          <   d|t                   d<   d|t                   d<   d|t                   d<   t	          t                                |t                   d<   t          |           rd|t                   d<   |                     d	|t                                                              d
S )z$Set the auth cookie on the response.ThttponlyLaxsamesiter   r   max-agesecure
Set-CookieN)	r   r   r   r   r-   r   r  r   OutputString)r   r   r   s      r   set_auth_cookier  .  s    \&&((F&F;&*F;
#&+F;
#"%F;%()=)?)?%@%@F;	"'"" -(,{H%f[&9&F&F&H&HIIIIIr   c                    t           j                                        }d|t          <   d|t                   d<   d|t                   d<   d|t                   d<   |                     d|t                                                              d	S )
z&Clear the auth cookie on the response.r   Tr  r   r   r   r  r  N)r   r   r   r   r   r  )r   r   s     r   clear_auth_cookier  ;  sy    \&&((FF;&*F;
#"%F;%(F;	"f[&9&F&F&H&HIIIIIr   )r   N)N)J__doc__r   r   http.cookiesr   r7   loggingr   rz   rO   	threadingr<   r   r   r   	getLogger__name__r?   r   r   r   	frozensetr   r   CSRF_HEADER_NAMEr5   r:   r-   r.   rC   rZ   r   ra   rq   r^   rb   rg   rj   rn   Lockrm   r   rr   ru   bytesr   r   __annotations__r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r  r  r  r  r,   r   r   <module>r&     sg    
         				        / / / / / / / /		8	$	$ c    ( y      ( --S%Z(    &:T#u*- :$ : : : :2 N	 !#99  d3U#34    2@4T%[(8#9 @d @ @ @ @( '&((%y~'' 3# 3$ 3 3 3 3.c .d . . . .     ( #' 54< & & &#' EDL ' ' 'U    e     6:   edl c    " !).""! T ! ! !# #* # # #       !3: ! ! ! !H+$ + + + +
t    @
d 
 
 
 
@ @ @ @ @
3 4    >    " " "     ,3 3:    Z Zt Z Z Z ZMC MS MT M M M M& & & &,S4Z , , , ,34 3 3 3 3l     :
J 
J 
J 
JJ J J J J Jr   