
    epj                        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	m
Z
 ddlmZ ddlmZmZ ddlmZmZmZ ddlmZ  ej        e          ZdZd	Zd
ZdZd
ZdZdZ edd          Z i ddddddddddddddddd d!d"d#d$d%d&d'd(d)d*d+d,d-d.d/d0d1d2d3iZ!d4e"d5ee"         fd6Z#d7e"d5e$fd8Z%d4e"d5e&fd9Z'd4e"d:e"d5e"fd;Z(d4e"d:e"d5e)e"         fd<Z*d4e"d=e"d>e"d5e&fd?Z+d@e"d5e"fdAZ,d4e"d:e"d5dfdBZ-dC Z.d5e"fdDZ/d4e"d:e"fdEZ0d4e"d:e"d5dfdFZ1d4e"d:e"d5dfdGZ2dHe
d5e3fdIZ4dJe
dKe
d5dfdLZ5dddMdNee
         dOee
         d5dfdPZ6dHe
dQe"d5dfdRZ7 G dS dT          Z8dS )Ua  
DM Pairing System

Code-based approval flow for authorizing new users on messaging platforms.
Instead of static allowlists with user IDs, unknown users receive a one-time
pairing code that the bot owner approves via the CLI.

Security features (based on OWASP + NIST SP 800-63-4 guidance):
  - 8-char codes from 32-char unambiguous alphabet (no 0/O/1/I)
  - Cryptographic randomness via secrets.choice()
  - 1-hour code expiry
  - Max 3 pending codes per platform
  - Rate limiting: 1 request per user per 10 minutes
  - Lockout after 5 failed approval attempts (1 hour)
  - File permissions: chmod 0600 on all data files
  - Codes are never logged to stdout

Storage: ~/.hermes/pairing/
    N)Path)Optional)expand_whatsapp_aliasesnormalize_whatsapp_identifier)get_default_hermes_rootget_hermes_dirget_hermes_home)atomic_replace ABCDEFGHJKLMNPQRSTUVWXYZ23456789   i  iX        platforms/pairingpairingtelegramTELEGRAM_ALLOWED_USERSdiscordDISCORD_ALLOWED_USERSwhatsappWHATSAPP_ALLOWED_USERSwhatsapp_cloudWHATSAPP_CLOUD_ALLOWED_USERSslackSLACK_ALLOWED_USERSsignalSIGNAL_ALLOWED_USERSemailEMAIL_ALLOWED_USERSsmsSMS_ALLOWED_USERS
mattermostMATTERMOST_ALLOWED_USERSmatrixMATRIX_ALLOWED_USERSdingtalkDINGTALK_ALLOWED_USERSfeishuFEISHU_ALLOWED_USERSwecomWECOM_ALLOWED_USERSwecom_callbackWECOM_CALLBACK_ALLOWED_USERSweixinWEIXIN_ALLOWED_USERSbluebubblesBLUEBUBBLES_ALLOWED_USERSqqbotQQ_ALLOWED_USERSyuanbaoYUANBAO_ALLOWED_USERSplatformreturnc                    | pd                                                                 } t                              |           }|r|S 	 ddlm} |                    |           }|r|j        r|j        S n# t          $ r Y nw xY wdS )zReturn the per-platform allowlist env var name, or None.

    Falls back to the platform registry for plugin platforms so a plugin's
    own ``allowed_users_env`` is honored too.
     r   )platform_registryN)lowerstrip_PLATFORM_ALLOWLIST_ENVgetgateway.platform_registryr9   allowed_users_env	Exception)r5   env_varr9   entrys       5/home/thesage/.hermes/hermes-agent/gateway/pairing.py_allowlist_env_for_platformrD   [   s     B%%''--//H%))(33G ??????!%%h// 	+U, 	+**   4s   *A4 4
B Brawc                 @    d |                      d          D             S )Nc                 ^    g | ]*}|                                 |                                 +S  )r;   ).0uids     rC   
<listcomp>z$_split_allowlist.<locals>.<listcomp>q   s-    AAACSYY[[ACIIKKAAA    ,)split)rE   s    rC   _split_allowlistrO   p   s     AA399S>>AAAArL   c                 V    | pd                                                                 dv S )uK   True for Baileys WhatsApp and Meta Cloud — same phone/JID identity rules.r8   >   r   r   )r;   r:   )r5   s    rC    _platform_uses_whatsapp_identityrQ   t   s*    N!!##))++/MMMrL   user_idc                     t          |pd                                          }t          |           rt          |          p|S |S )zHNormalize platform-specific user IDs before persisting / comparing them.r8   )strr;   rQ   r   )r5   rR   raw_user_ids      rC   _normalize_user_idrV   y   sI    gm$$**,,K'11 I,[99H[HrL   c                    t          |pd                                          }|st                      S |t          | |          h}t	          |           r"|                    t          |                     |                    d           |S )zCReturn all known equivalent user IDs for auth / allowlist matching.r8   )rT   r;   setrV   rQ   updater   discard)r5   rR   rU   aliasess       rC   _user_id_aliasesr\      s    gm$$**,,K uu.xEEFG'11 =.{;;<<<OOBNrL   leftrightc                 n    t          | |          }t          | |          }t          |o|o||z            S z;Return True when two user IDs represent the same principal.)r\   bool)r5   r]   r^   left_aliasesright_aliasess        rC   _user_ids_matchrd      s=    #Hd33L$Xu55MQQL=4PRRRrL   rA   c                     	 ddl m}m} 	  ||           pd                                S # |$ r Y nw xY wn# t          $ r Y nw xY wt          j        |           pd                                S )u  Read a platform allowlist env var through the profile secret scope.

    Under multiplexing the process env may hold ANOTHER profile's allowlist
    (first-writer-wins YAML→env bridges), so reads must honor the installed
    scope's verdict — including a scoped miss returning empty rather than
    borrowing the process value.  Unscoped callers (single-profile CLI /
    admin endpoints) keep the legacy ``os.getenv`` read.

    TODO(profile-secrets): the grant mirror below still WRITES through
    ``hermes_cli.config.save_env_value`` / ``remove_env_value``, which target
    the root ``.env`` — those writes need a profile-aware counterpart before
    pairing grants can be mirrored correctly under multiplexing.
    r   )UnscopedSecretError
get_secretr8   )agent.secret_scoperf   rg   r;   r@   osgetenv)rA   rf   rg   s      rC   _read_allowlist_envrk      s    FFFFFFFF	Jw''-244666" 	 	 	D	   Ig$"++---s#   6 * 26 26 
AAc                 P   t          |           }|sdS t          |          }|sdS t          |          }d|v st          |          |v rdS |                    t          |                     	 ddlm}  ||d                    |                     dS # t          $ r Y dS w xY w)u  Add ``user_id`` to the platform allowlist env var IF one is configured.

    Option (i): only materialize the grant into the allowlist when the operator
    already runs an allowlist for this platform. On an open gateway (no
    allowlist) we do nothing — the pairing store remains the grant record and
    the authz union honors it, so we never silently convert an open gateway into
    a locked one on first pairing.
    N*r   )save_env_valuerM   )	rD   rk   rO   rT   appendhermes_cli.configrn   joinr@   )r5   rR   rA   currentidsrn   s         rC   _sync_allowlist_addrt      s     *(33G !'**G 
7
#
#C
czzS\\S((JJs7||444444w.....    	s   0%B 
B%$B%c               #   J  K   	 ddl m}   |             }n# t          $ r Y dS w xY w|dS t          |dd          pi }|                                D ]}||V  	t          |dd          pi }|                                D ]!}|pi                                 D ]}||V  	"dS )zDYield adapters from the in-process GatewayRunner, if one is running.r   )_gateway_runner_refNadapters_profile_adapters)gateway.runrv   r@   getattrvalues)rv   runnerrw   adapterprofile_adaptersmappings         rC   _iter_live_gateway_adaptersr      s	     333333$$&&   ~vz4006BH??$$  MMMv':DAAGR#**,,  2--// 	 	G"	 s    
##c                 @   t          | dd           }|Ft          |dd           }|r3t          |                                                                          S t          | dd           }t          |pd                                                                          S )Nr5   valuenamer8   )rz   rT   r;   r:   )r}   r5   r   r   s       rC   _adapter_platform_namer      s    w
D11H'400 	.u::##%%++---7FD))Dtzr??  ""((***rL   c                 N   | | S t          | t                    r3t          |           }fd|D             }d                    |          S t          | t          t
          f          rfd| D             S t          | t          t          f          rfd| D             S | S )z?Drop alias-equivalent allowlist entries while preserving ``*``.Nc           	      \    g | ](}|d k    st          |t                              &|)S rm   rd   rT   )rI   partr5   rR   s     rC   rK   z,_purge_allowlist_entries.<locals>.<listcomp>   s@     
 
 
s{{/(D#g,,"O"O{ {{rL   rM   c           	          h | ]T}t          |                                          d k    s+t          t          |          t                              R|US r   rT   r;   rd   rI   rB   r5   rR   s     rC   	<setcomp>z+_purge_allowlist_entries.<locals>.<setcomp>   `     
 
 
5zz!!S(("8SZZWFF ) (((rL   c           	          g | ]T}t          |                                          d k    s+t          t          |          t                              R|US r   r   r   s     rC   rK   z,_purge_allowlist_entries.<locals>.<listcomp>   r   rL   )
isinstancerT   rO   rq   rX   	frozensetlisttuple)entriesr5   rR   parts	remainings    ``  rC   _purge_allowlist_entriesr      s   '3 # ))
 
 
 
 
"
 
 
	 xx	"""'C+,, 

 
 
 
 
&
 
 
 	

 'D%=)) 

 
 
 
 
&
 
 
 	

 NrL   c                 j   | pd                                                                 }|r#t          |pd                                           sdS t                      D ]}t	          |          |k    rt          |d          r<	 t          t          |j        pd          ||          |_        n# t          $ r Y nw xY wt          t          |dd          dd          }t          |t                    r=d|v r9	 t          |                    d          ||          |d<   # t          $ r Y w xY wdS )a  Clear revoked principals from in-process adapter allowlist snapshots.

    ``WhatsAppAdapter`` (and Cloud) snapshot ``_allow_from`` at construction.
    Pairing revoke updates ``WHATSAPP_ALLOWED_USERS`` / cloud env, but when the
    revoked principal was the sole entry the env key is removed entirely.
    Intake must not keep authorizing from the stale snapshot until restart.
    r8   N_allow_fromrH   configextra
allow_from)r;   r:   rT   r   r   hasattrr   rX   r   r@   rz   r   dictr=   )r5   rR   platform_namer}   r   s        rC   #_sync_live_adapter_allowlist_remover     sv    ^**,,2244M GMr 2 2 8 8 : : .00  !'**m;;7M** 	&>+1r22M7' '##    488'4HHeT"" 	|u'<'<&>IIl++]G' 'l##    ! s$   *B00
B=<B=:'D""
D/.D/c                     t                     }|sdS t          |          }|sdS t          |          } fd|D             }t          |          t          |          k    rdS 	 ddlm}m} |r  ||d                    |                     n ||           n# t          $ r Y nw xY wt                      dS )a  Remove ``user_id`` (and WhatsApp alias equivalents) from the allowlist.

    Matching must mirror PairingStore / authz WhatsApp alias rules: approve
    mirrors a normalized phone into ``WHATSAPP_ALLOWED_USERS``, while revoke
    is often invoked with a JID or device-suffix form. Exact-string delete
    would leave the allowlist entry and keep the sender authorized.

    Also clears matching entries from any in-process platform adapter
    ``_allow_from`` snapshot so sole-entry revocation is effective without a
    gateway restart.
    Nc           	      \    g | ](}|d k    st          |t                              &|)S r   r   )rI   ir5   rR   s     rC   rK   z*_sync_allowlist_remove.<locals>.<listcomp>8  s@       88?8QGEE8 	
88rL   r   )rn   remove_env_valuerM   )
rD   rk   rO   lenrp   rn   r   rq   r@   r   )r5   rR   rA   rr   rs   r   rn   r   s   ``      rC   _sync_allowlist_remover   $  s     *(33G !'**G 
7
#
#C      I 9~~S!!FFFFFFFF 	&N7CHHY$7$78888W%%%   '':::::s   *5B   
B-,B-pathc                     |                                  r`	 t          j        |                     d                    }t	          |t
                    r|ni S # t          j        t          f$ r i cY S w xY wi S )Nutf-8encoding)existsjsonloads	read_textr   r   JSONDecodeErrorOSError)r   datas     rC   _load_json_filer   J  s~    {{}} 	:dnngn>>??D%dD11944r9$g. 	 	 	III	Is   A A A21A2
active_diralternate_dirc           	         |                                 r*|                                 |                                k    rdS |                     dd           |                    d          D ]}|                                s| |j        z  }t          |          }|s3t          |          }t          |          }|                    |           ||k    r%t          |t          j        |dd                     dS )a  Merge split legacy/new pairing data into the active PairingStore dir.

    Older installs use ``{HERMES_HOME}/pairing`` while newer code/docs may
    write ``{HERMES_HOME}/platforms/pairing``. If both directories exist, the
    gateway must not silently ignore approved users sitting in the inactive
    location; otherwise already-paired Feishu users get asked for a fresh code.
    NTparentsexist_okz*.json   Findentensure_ascii)r   resolvemkdirglobis_filer   r   r   rY   _secure_writer   dumps)r   r   srcdestmergedrr   befores          rC   _merge_pairing_dirr   T  s    !! Z%7%7%9%9]=R=R=T=T%T%TTD111!!(++ R R{{}} 	CH$ %% 	!$''ggV$
6!% P P PQQQR RrL   homeactiver   r   c                     | pt                      } | dz  }| dz  dz  }|pt          }|                                |                                k    r|n|}t          ||           d S )Nr   	platforms)r	   PAIRING_DIRr   r   )r   r   old_dirnew_dir	alternates        rC   _migrate_split_pairing_dirsr   n  st    
 $?$$DYG[ 9,G"{F!>>++w/@/@@@gIvy)))))rL   r   c                 j   | 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          ||            	 t          j        | d	           dS # t          $ r Y dS w xY w# t          $ r( 	 t          j        |           n# t          $ r Y nw xY w w xY w)
u   Write data to file with restrictive permissions (owner read/write only).

    Uses a temp-file + atomic rename so readers always see either the old
    complete file or the new one — never a partial write.
    Tr   z.tmp)dirsuffixwr   r   Ni  )parentr   tempfilemkstemprT   ri   fdopenwriteflushfsyncfilenor
   chmodr   BaseExceptionunlink)r   r   fdtmp_pathfs        rC   r   r   {  s    	KdT222#DK(8(8HHHLBYr3111 	!QGGDMMMGGIIIHQXXZZ   	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	x&&&	HT5!!!!! 	 	 	DD	   	Ih 	 	 	D	ss   	D   AB<0D  <C  D  C D  C/ /
C=9D  <C==D   
D2D D2 
D-*D2,D--D2c            
          e Zd ZdZd2dee         fdZedee         fd            Zdede	fdZ
dede	fd	Zde	fd
Zde	defdZde	deddfdZdededefdZdededee         fdZdedededefdZdededefdZd2dedefdZd3dedededdfdZdededefdZedededefd            Zdededed edef
d!Z	 d3dedededee         fd"Zdededee         fd#Zed$edefd%            Z ded&edee         fd'Z!d2dedefd(Z"d2dede#fd)Z$dededefd*Z%dededdfd+Z&dedefd,Z'deddfd-Z(deddfd.Z)deddfd/Z*d0edefd1Z+dS )4PairingStorea{  
    Manages pairing codes and approved user lists.

    Data files per platform:
      - {platform}-pending.json   : pending pairing requests
      - {platform}-approved.json  : approved (paired) users
      - _rate_limits.json         : rate limit tracking

    When constructed with ``profile="<name>"``, storage resolves from that
    profile's own HERMES_HOME using the same legacy/consolidated layout rules
    as ``hermes -p <name> pairing ...``. This keeps multiplex gateways and
    profile-scoped CLI approvals on one whitelist. Without a profile, storage
    is the global pairing directory for the current HERMES_HOME.
    Nprofilec                 R   |r6t                      }|dk    r|n|dz  |z  }t          dd|          | _        nt          | _        | j                            dd           |rt          || j                   nt                       t          j                    | _        || _	        d S )	Ndefaultprofilesr   r   )r   Tr   r   )
r   r   _dirr   r   r   	threadingRLock_lock_profile)selfr   rootprofile_homes       rC   __init__zPairingStore.__init__  s      	$*,,D i'' J&0 
 '#!  DII $DI	t444 	* (\$)LLLLL ())) _&&
rL   r6   c                     | j         S )zCProfile name this store is scoped to, or None for the global store.)r   r   s    rC   r   zPairingStore.profile  s     }rL   r5   c                     | j         | dz  S )Nz-pending.jsonr   r   r5   s     rC   _pending_pathzPairingStore._pending_path  s    yh55555rL   c                     | j         | dz  S )Nz-approved.jsonr   r   s     rC   _approved_pathzPairingStore._approved_path  s    yh66666rL   c                     | j         dz  S )Nz_rate_limits.jsonr   r   s    rC   _rate_limit_pathzPairingStore._rate_limit_path  s    y...rL   r   c                    |                                 r	 t          j        |                    d                    S # t          $ r}	 |                                }d|j         dt          |j                  dd           }n# t          $ r d}Y nw xY wt          t          d          rt          j                    nd}t                              d	||||           i cY d }~S d }~wt          j        t          f$ r i cY S w xY wi S )
Nr   r   z
owner_uid=z mode=z<stat failed>geteuidzn/aa)  Pairing file %s exists but is not readable as uid=%s (%s; %s). If you ran `docker exec <container> hermes pairing approve ...` as root, re-run with `docker exec -u hermes <container> ...` and chown the existing file to the hermes user, or restart the container so the entrypoint can fix ownership.)r   r   r   r   PermissionErrorstatst_uidoctst_moder   r   ri   r   loggerwarningr   )r   r   est
owner_infoeuids         rC   
_load_jsonzPairingStore._load_json  s4   ;;== 	z$..'."B"BCCC"   1B!Ubi!U!Us2:rss?S!U!UJJ 1 1 1!0JJJ1 (/r9'='=Hrz|||5E
 $
A   						('2   				s@   '> 
D	;BC&BC&BAC& D&DDr   c                 P    t          |t          j        |dd                     d S )Nr   Fr   )r   r   r   )r   r   r   s      rC   
_save_jsonzPairingStore._save_json  s)    dDJtAEJJJKKKKKrL   rR   c                 "    t          ||          S )z<Normalize platform-specific user IDs before persisting them.)rV   r   r5   rR   s      rC   rV   zPairingStore._normalize_user_id  s    !(G444rL   c                 "    t          ||          S )z@Return all known equivalent user IDs for auth/rate-limit checks.)r\   r  s      rC   r\   zPairingStore._user_id_aliases  s    '222rL   r]   r^   c                 $    t          |||          S r`   rd   )r   r5   r]   r^   s       rC   rd   zPairingStore._user_ids_match  s    xu555rL   c                     |                      |                     |                    }|D ]}|                     |||          r dS dS )z3Check if a user is approved (paired) on a platform.TF)r  r   rd   )r   r5   rR   approvedapproved_user_ids        rC   is_approvedzPairingStore.is_approved  s]    ??4#6#6x#@#@AA ( 	 	##H.>HH tturL   c                     g }|r|gn|                      d          }|D ]^}|                     |                     |                    }|                                D ]\  }}|                    ||d|            _|S )z5List approved users, optionally filtered by platform.r  )r5   rR   )_all_platformsr  r   itemsro   )r   r5   resultsr   pr  rJ   infos           rC   list_approvedzPairingStore.list_approved  s    "*OXJJ0C0CJ0O0O	 	H 	HAt':':1'='=>>H%^^-- H H	TA#FFFGGGGHrL   r8   	user_namec                 \                                                                   }                     |           fd|D             }|D ]}||= |t          j                    d|<                                                       |           t                     dS )zAAdd a user to the approved list. Must be called under self._lock.c                 B    g | ]}                     |          |S rH   r  )rI   r  normalized_user_idr5   r   s     rC   rK   z.PairingStore._approve_user.<locals>.<listcomp>  sC     
 
 
 ##H.>@RSS

 
 
rL   )r  approved_atN)r  r   rV   timer  rt   )r   r5   rR   r  r  duplicate_idsr  r"  s   ``     @rC   _approve_userzPairingStore._approve_user  s    ??4#6#6x#@#@AA!44XwGG
 
 
 
 
 
$,
 
 

 !. 	+ 	+)** #9;;(
 (
#$ 	++H55x@@@
 	H&899999rL   c                 <                                    } j        5                       |          } fd|D             }|r<|D ]}||=                      ||           t	                     	 ddd           dS 	 ddd           n# 1 swxY w Y   dS )z<Remove a user from the approved list. Returns True if found.c                 B    g | ]}                     |          |S rH   r  )rI   r  r5   r   rR   s     rC   rK   z'PairingStore.revoke.<locals>.<listcomp>2  sB       $''2BGLL   rL   NTF)r   r   r  r  r   )r   r5   rR   r   r  matching_idsr  s   ```    rC   revokezPairingStore.revoke-  s;   ""8,,Z 	 	t,,H     (0  L
  (4 3 3$ !122h/// 'x999	 	 	 	 	 	 	 		 	 	 	 	 	 	 	 	 	 	 	 	 	 	  us   ABBBcodesaltc                 z    t          j        ||                     d          z                                             S )z6Hash a pairing code with the given salt using SHA-256.r   )hashlibsha256encode	hexdigest)r+  r,  s     rC   
_hash_codezPairingStore._hash_codeD  s1     ~dT[[%9%99::DDFFFrL   pendingmatched_keymatched_entryc                 $   ||= |                      |                     |          |           |                     |           |                     ||d         |                    dd                     |d         |                    dd          dS )zDRemove a pending request and approve its user. Must hold self._lock.rR   r  r8   )rR   r  )r  r   _reset_failed_attemptsr&  r=   )r   r5   r3  r4  r5  s        rC   _finish_approvalzPairingStore._finish_approvalI  s     K **844g>>> 	##H---mI.0A0A+r0R0R	
 	
 	

 %Y/&**;;;
 
 	
rL   c                    | j         5  |                     |           |                     ||          }|                     |          r	 ddd           dS |                     ||          r	 ddd           dS |                     |                     |                    }t          |          t          k    r	 ddd           dS d	                    d t          t                    D                       }t          j        d          }|                     ||          }t          j        d          }	||                                ||t%          j                    d||	<   |                     |                     |          |           |                     ||           |cddd           S # 1 swxY w Y   dS )a  
        Generate a pairing code for a new user.

        Returns the code string, or None if:
          - User is rate-limited (too recent request)
          - Max pending codes reached for this platform
          - User/platform is in lockout due to failed attempts

        The code is NOT stored in plaintext.  Only a salted SHA-256 hash is
        persisted so that reading the pending file does not reveal codes.
        Nr8   c              3   H   K   | ]}t          j        t                    V  d S N)secretschoiceALPHABET)rI   _s     rC   	<genexpr>z-PairingStore.generate_code.<locals>.<genexpr>  s,      PP7>(33PPPPPPrL      r   )hashr,  rR   r  
created_at)r   _cleanup_expiredrV   _is_locked_out_is_rate_limitedr  r   r   MAX_PENDING_PER_PLATFORMrq   rangeCODE_LENGTHri   urandomr2  r<  	token_hexhexr$  r  _record_rate_limit)
r   r5   rR   r  r"  r3  r+  r,  	code_hashentry_ids
             rC   generate_codezPairingStore.generate_codea  sN    Z (	 (	!!(+++!%!8!87!K!K ""8,, (	 (	 (	 (	 (	 (	 (	 (	 $$Xw77 (	 (	 (	 (	 (	 (	 (	 (	 ood&8&8&B&BCCG7||777(	 (	 (	 (	 (	 (	 (	 (	$ 77PPU;=O=OPPPPPD :b>>Dd33I (++H "

-&"ikk! !GH OOD..x88'BBB ##Hg666Q(	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	s&   AF3F3;AF3	CF33F7:F7c                    | j         5  |                     |           |                                                                }|                     |          r	 ddd           dS |                     |                     |                    }d}d}|                                D ]\  }}t          |t                    sd|vsd|vr$	 t                              |d                   }n# t          $ r Y Rw xY w|                     ||          }	t          j        |	|d                   r|}|} n|#|                     |           	 ddd           dS |                     ||||          cddd           S # 1 swxY w Y   dS )u  
        Approve a pairing code. Adds the user to the approved list.

        Returns ``{user_id, user_name}`` on success, ``None`` if the code is
        invalid/expired OR the platform is currently locked out after
        ``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can
        disambiguate with ``_is_locked_out(platform)``.

        Verification: the user-provided code is hashed with each stored
        entry's salt and compared to the stored hash using constant-time
        comparison. Pre-hash entries (legacy plaintext-key format from
        pre-upgrade pending.json files) are silently ignored — they get
        pruned at TTL by ``_cleanup_expired``.
        Nr,  rB  )r   rD  upperr;   rE  r  r   r  r   r   bytesfromhex
ValueErrorr2  r<  compare_digest_record_failed_attemptr8  )
r   r5   r+  r3  r4  r5  rO  rB   r,  candidate_hashs
             rC   approve_codezPairingStore.approve_code  sX    Z )	X )	X!!(+++::<<%%''D ""8,, )	X )	X )	X )	X )	X )	X )	X )	X ood&8&8&B&BCCG K M#*==??  %!%.. &&&*=*= ==v77DD!   H!%t!<!<).%-HH "*K$)ME
 "++H555O)	X )	X )	X )	X )	X )	X )	X )	XR ((7KWWS)	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	X )	XsD   AE>'A$E> C-,E>-
C:7E>9C::AE>E>>FFr   c                     t          | pd                                          } t          |           dk    ot          d | D                       S )av  True when ``value`` has the shape of a ``list_pending`` request id.

        Request ids are ``secrets.token_hex(8)`` (16 lowercase hex chars);
        pairing codes are 8 chars from an unambiguous uppercase alphabet that
        excludes every hex letter's ambiguity partner. The two shapes cannot
        collide, so callers accepting either can dispatch on this.
        r8   rA  c              3      K   | ]}|d v V  	dS )0123456789abcdefABCDEFNrH   )rI   cs     rC   r@  z5PairingStore.looks_like_request_id.<locals>.<genexpr>  s(      'U'U!-E(E'U'U'U'U'U'UrL   )rT   r;   r   all)r   s    rC   looks_like_request_idz"PairingStore.looks_like_request_id  sN     EKR  &&((5zzRUC'U'Uu'U'U'U$U$UUrL   
request_idc                 r   | j         5  |                     |           t          |pd                                                                          }|s	 ddd           dS |                     |                     |                    }|                                D ]~\  }}t          |t                    sd|vsd|vr$t          j        t          |                                          |          r&|                     ||||          c cddd           S 	 ddd           dS # 1 swxY w Y   dS )a  
        Approve a pending pairing request by its server-side request id.

        This is the grant path for authenticated admin surfaces (``hermes
        pairing list``, the dashboard/desktop approve buttons), which show
        pending requests but must never reveal the one-time code DM'd to the
        user. Returns ``{user_id, user_name}`` on success, ``None`` for an
        unknown/expired request id.

        Unlike :meth:`approve_code` this does NOT count a miss toward the
        brute-force lockout, and is not itself gated by one. The lockout
        protects the 8-char code space against guessing over a messaging
        channel; a request id is only ever obtained by an admin already
        authenticated to this store, so a stale id means "the row you clicked
        expired", not an attack. Counting it here let a few GUI clicks on a
        stale list lock the operator out of the CLI's code path too.
        r8   Nr,  rB  )r   rD  rT   r;   r:   r  r   r  r   r   r<  rV  r8  )r   r5   r`  r3  rO  rB   s         rC   approve_requestzPairingStore.approve_request  s   $ Z 	 	!!(+++Z-2..4466<<>>J 		 	 	 	 	 	 	 	 ood&8&8&B&BCCG#*==?? U U%!%.. &&&*=*=)#h--*=*=*?*?LL U007HeTTTT	 	 	 	 	 	 	 	U 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	s   AD,#B-D,D,,D03D0c                 b   g }| j         5  |r|gn|                     d          }|D ]n}|                     |           |                     |                     |                    }|                                D ]\  }}t          |t                    s|                    d          }t          |t          t          f          sNt          t          j                    |z
  dz            }	t          |                    d          t                    o't          |                    d          t                    }
|                    ||
rt          |          nd|                    dd          |                    dd          |	d	           p	 d
d
d
           n# 1 swxY w Y   |S )uz  List pending pairing requests, optionally filtered by platform.

        Codes are stored hashed and are never returned. Each entry exposes a
        server-side ``request_id`` that an authenticated admin surface passes
        to :meth:`approve_request`. Legacy pre-hash entries have no approvable
        id — they report an empty ``request_id`` and age out at TTL.
        r3  rC  <   rB  r,  r8   rR   r  )r5   r`  rR   r  age_minutesN)r   r  rD  r  r   r  r   r   r=   intfloatr$  rT   ro   )r   r5   r  r   r  r3  rO  r  rC  age_min	is_moderns              rC   list_pendingzPairingStore.list_pending  s    Z 	 	&.R

D4G4G	4R4RI  %%a(((//$*<*<Q*?*?@@&-mmoo  NHd%dD11 ! !%,!7!7J%j3,?? ! !49;;#;r"ABBG *488F+;+;S A A !j((#G GI NN$%7@&Hc(mmmb#'88Ir#:#:%)XXk2%>%>'.$ $    	 	 	 	 	 	 	 	 	 	 	 	 	 	 	, s   FF$$F(+F(c                 N   | j         5  d}|r|gn|                     d          }|D ]e}|                     |                     |                    }|t	          |          z  }|                     |                     |          i            f	 ddd           n# 1 swxY w Y   |S )z2Clear all pending requests. Returns count removed.r   r3  N)r   r  r  r   r   r  )r   r5   countr   r  r3  s         rC   clear_pendingzPairingStore.clear_pending#  s    Z 	; 	;E&.R

D4G4G	4R4RI ; ;//$*<*<Q*?*?@@W% 2 21 5 5r::::;	; 	; 	; 	; 	; 	; 	; 	; 	; 	; 	; 	; 	; 	; 	; s   BBB!Bc                    |                      |                                           }|                     ||          D ]A}| d| }|                    |d          }t	          j                    |z
  t
          k     r dS BdS )z2Check if a user has requested a code too recently.:r   TF)r  r   r\   r=   r$  RATE_LIMIT_SECONDS)r   r5   rR   limitsaliaskeylast_requests          rC   rF  zPairingStore._is_rate_limited0  s    !6!6!8!899**8W== 	 	E''''C!::c1--L	l*.@@@tt AurL   c                    |                      |                                           }t          j                    }|                     ||          D ]}| d| }|||<   |                     |                                 |           dS )z7Record the time of a pairing request for rate limiting.ro  N)r  r   r$  r\   r  )r   r5   rR   rq  nowrr  rs  s          rC   rM  zPairingStore._record_rate_limit:  s    !6!6!8!899ikk**8W== 	 	E''''CF3KK--//88888rL   c                     |                      |                                           }d| }|                    |d          }t          j                    |k     S )zBCheck if a platform is in lockout due to failed approval attempts.	_lockout:r   )r  r   r=   r$  )r   r5   rq  lockout_keylockout_untils        rC   rE  zPairingStore._is_locked_outC  sP    !6!6!8!899,(,,

;22y{{]**rL   c           	         |                      |                                           }d| }|                    |d          dz   }|||<   |t          k    rMd| }t	          j                    t
          z   ||<   d||<   t          d| dt
           dt           dd	
           |                     |                                 |           dS )zMRecord a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS.
_failures:r      rx  z[pairing] Platform z locked out for zs after z failed attemptsT)r   N)r  r   r=   MAX_FAILED_ATTEMPTSr$  LOCKOUT_SECONDSprintr  )r   r5   rq  fail_keyfailsry  s         rC   rW  z#PairingStore._record_failed_attemptJ  s   !6!6!8!899***

8Q''!+ x'''0h00K"&)++"?F; F8 A A A/ A A.A A AHLN N N N--//88888rL   c                     |                      |                                           }d| }|                    |          r/d||<   |                     |                                 |           dS dS )a&  Clear the accumulated failed-approval counter after a success.

        Called from the ``approve_code`` success path so that a legitimate
        approval resets the brute-force streak (standard lockout semantics:
        the counter tracks *consecutive* failures, not lifetime ones).
        r|  r   N)r  r   r=   r  )r   r5   rq  r  s       rC   r7  z#PairingStore._reset_failed_attemptsX  s|     !6!6!8!899***::h 	= F8OOD1133V<<<<<	= 	=rL   c                 &   |                      |          }|                     |          }t          j                    }g }|                                D ]\  }}t	          |t
                    s|                    |           0|                    d          }t	          |t          t          f          s|                    |           w||z
  t          k    r|                    |           |r |D ]}||= |                     ||           dS dS )u   Remove expired pending codes.

        Tolerant of malformed / legacy entries — anything without a numeric
        ``created_at`` is treated as expired (it's effectively unusable
        with the new hash-keyed schema anyway).
        rC  N)r   r  r$  r  r   r   ro   r=   rf  rg  CODE_TTL_SECONDSr  )	r   r5   r   r3  rv  expiredrO  r  rC  s	            rC   rD  zPairingStore._cleanup_expiredg  s     !!(++//$''ikk%mmoo 		) 		)NHddD)) x(((,//Jj3,77 x(((j $444x((( 	+# & &H%%OOD'*****	+ 	+rL   r   c                    g }| j                                         D ]i}|j                            d| d          rI|j                            d| dd          }|                    d          s|                    |           j|S )z:List all platforms that have data files of a given suffix.-z.jsonr8   r?  )r   iterdirr   endswithreplace
startswithro   )r   r   r   r   r5   s        rC   r  zPairingStore._all_platforms  s    	""$$ 	/ 	/Av0600011 /6>>*;f*;*;*;R@@**3// /$$X...rL   r;  )r8   ),__name__
__module____qualname____doc__r   rT   r   propertyr   r   r   r   r   r   r  r  rV   rX   r\   ra   rd   r  r   r  r&  r*  staticmethodrS  r2  r8  rP  rY  r_  rb  rj  rf  rm  rF  rM  rE  rW  r7  rD  r  rH   rL   rC   r   r     s                   @ #    X6c 6d 6 6 6 67s 7t 7 7 7 7/$ / / / /t     @Lt L4 LD L L L L53 5 5 5 5 5 53 3s 3s3x 3 3 3 36 63 6s 6t 6 6 6 6C # $     c T    : :c :C :C :QU : : : :.s S T    . G GE Gc G G G \G

&*
9<
MQ
	
 
 
 
2 =?6 66&)6696	#6 6 6 6p8XS 8X 8X 8X 8X 8X 8Xt 	VS 	VT 	V 	V 	V \	V! ! !$ ! ! ! !F S D    B	 	c 	S 	 	 	 	 s t    93 9 9 9 9 9 9+s +t + + + +9s 9t 9 9 9 9=s =t = = = =+ + + + + +4S T      rL   r   )9r  r.  r   loggingri   r<  r   r   r$  pathlibr   typingr   gateway.whatsapp_identityr   r   hermes_constantsr   r   r	   utilsr
   	getLoggerr  r  r>  rI  r  rp  r  rG  r~  r   r<   rT   rD   r   rO   ra   rQ   rV   rX   r\   rd   rk   rt   r   r   r   r   r   r   r   r   r   r   r   rH   rL   rC   <module>r     s!   (    				                                   
 !          		8	$	$ .      n0)<<(& ( 4	
 " $ " 
 , $ ( $ " 4 $  .!" #$ &%  ,# (3-    *B# B$ B B B BNs Nt N N N N
 s s    
s 
S 
SX 
 
 
 
Sc S SS ST S S S S. . . . . .4#      :  *+s + + + + c    4#      >#;S #;3 #;4 #; #; #; #;L$ 4    R4 R R R R R R8  !
* 
* 
*
4.
* TN
* 
	
* 
* 
* 
* C D    4t t t t t t t t t trL   