
    `gji                        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 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!d9e!d5dfd:Z%d4e!d9e!d5dfd;Z&d<e
d5e'fd=Z(d>e
d?e
d5dfd@Z)dFdAZ*d<e
dBe!d5dfdCZ+ G dD dE      Z,y)Ga  
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_hermes_dirget_hermes_home)atomic_replace ABCDEFGHJKLMNPQRSTUVWXYZ23456789   i  iX        z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                     | xs dj                         j                         } t        j                  |       }|r|S 	 ddlm} |j                  |       }|r|j                  r|j                  S y# t        $ r Y yw xY w)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_registryr7   allowed_users_env	Exception)r3   env_varr7   entrys       B/root/.hermes/venv/lib/python3.12/site-packages/gateway/pairing.py_allowlist_env_for_platformrB   W   s     B%%'--/H%))(3G?!%%h/U,,***   s   0A/ /	A;:A;rawc                     | j                  d      D cg c]#  }|j                         s|j                         % c}S c c}w )N,)splitr9   )rC   uids     rA   _split_allowlistrH   l   s,    #&99S>ACSYY[CIIKAAAs   ??user_idc                 8   t        |       }|syt        j                  |d      j                         }|syt	        |      }d|v st        |      |v ry|j                  t        |             	 ddlm}  ||dj                  |             y# t        $ r Y y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.
    Nr6   *r   )save_env_valuerE   )rB   osgetenvr9   rH   strappendhermes_cli.configrL   joinr>   )r3   rI   r?   currentidsrL   s         rA   _sync_allowlist_addrU   p   s     *(3Gii$**,G
7
#C
czS\S(JJs7|4w.  	s   .B 	BBc                 p   t        |       }|syt        j                  |d      j                         }|syt	        |      }|D cg c]  }|t        |      k7  s| }}t        |      t        |      k(  ry	 ddlm}m	} |r ||dj                  |             y ||       yc c}w # t        $ r Y yw xY w)zBRemove ``user_id`` from the platform allowlist env var if present.Nr6   r   )rL   remove_env_valuerE   )rB   rM   rN   r9   rH   rO   lenrQ   rL   rW   rR   r>   )	r3   rI   r?   rS   rT   i	remainingrL   rW   s	            rA   _sync_allowlist_remover[      s    )(3Gii$**,G
7
#C5q1G#45I5
9~S!F7CHHY$78W% 6  s$   B$B$8"B) B) )	B54B5pathc                     | j                         r:	 t        j                  | j                  d            }t	        |t
              r|S i S i S # t        j                  t        f$ r i cY S w xY w)Nutf-8encoding)existsjsonloads	read_text
isinstancedictJSONDecodeErrorOSError)r\   datas     rA   _load_json_filerj      se    {{}	::dnngn>?D%dD149r9 I $$g. 	I	s   6A 	A A+*A+
active_diralternate_dirc           	         |j                         r!| j                         |j                         k(  ry| j                  dd       |j                  d      D ]  }|j	                         s| |j
                  z  }t        |      }|s1t        |      }t        |      }|j                  |       ||k7  s^t        |t        j                  |dd              y)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)ra   resolvemkdirglobis_filenamerj   rf   update_secure_writerb   dumps)rk   rl   srcdestmergedrS   befores          rA   _merge_pairing_dirr      s     !Z%7%7%9]=R=R=T%TTD1!!(+ R{{}CHH$ %!$'ggV$

6!% PQR    c                      t               } | dz  }| dz  dz  }t        }|j                         |j                         k(  r|n|}t        ||       y )Nr   	platforms)r   PAIRING_DIRru   r   )homeold_dirnew_diractive	alternates        rA   _migrate_split_pairing_dirsr      sN    DYG[ 9,GF!>>+w/@@gIvy)r   ri   c                 F   | j                   j                  dd       t        j                  t	        | j                         d      \  }}	 t        j                  |dd      5 }|j                  |       |j                          t        j                  |j                                ddd       t        ||        	 t        j                  | d	       y# 1 sw Y   -xY w# t        $ r Y yw xY w# t        $ r' 	 t        j                  |        # t        $ r Y  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.
    Trn   z.tmp)dirsuffixwr^   r_   Ni  )parentrv   tempfilemkstemprO   rM   fdopenwriteflushfsyncfilenor	   chmodrh   BaseExceptionunlink)r\   ri   fdtmp_pathfs        rA   r{   r{      s     	KKdT2##DKK(8HLBYYr31 	!QGGDMGGIHHQXXZ 	! 	x&	HHT5!	! 	!  		 	IIh 	  		sg   C0 $AC)C0 >C! CC0 !	C-*C0 ,C--C0 0	D :DD 	DD DD c            
          e Zd ZdZd(d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d(dedefdZd)d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)dedededee   fdZdededee   fdZd(dedefdZd(d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efd'Z'y)*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 lives under
    ``<HERMES_HOME>/profiles/<name>/pairing/`` (per-profile, used by
    multiplexing gateways so each profile has its own whitelist).
    Without a profile, storage is the global ``<HERMES_HOME>/pairing/``
    directory (backward-compat for the ``hermes pairing`` CLI).
    Nprofilec                     |rddl m}  |       dz  |z  dz  | _        nt        | _        | j                  j	                  dd       |s
t                t        j                         | _        || _	        y )Nr   )r   profilesr   Trn   )
hermes_constantsr   _dirr   rv   r   	threadingRLock_lock_profile)selfr   r   s      rA   __init__zPairingStore.__init__   s`     8')J6@9LDI#DI		t4 () __&
r   r4   c                     | j                   S )zCProfile name this store is scoped to, or None for the global store.)r   r   s    rA   r   zPairingStore.profile  s     }}r   r3   c                 &    | j                   | dz  S )Nz-pending.jsonr   r   r3   s     rA   _pending_pathzPairingStore._pending_path  s    yyhZ}555r   c                 &    | j                   | dz  S )Nz-approved.jsonr   r   s     rA   _approved_pathzPairingStore._approved_path  s    yyhZ~666r   c                      | j                   dz  S )Nz_rate_limits.jsonr   r   s    rA   _rate_limit_pathzPairingStore._rate_limit_path  s    yy...r   r\   c                    |j                         r&	 t        j                  |j                  d            S i S # t        $ r}	 |j                         }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        j                  d	||||       i cY d }~S d }~wt        j                  t        f$ r i cY S w xY w)
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.)ra   rb   rc   rd   PermissionErrorstatst_uidoctst_moderh   hasattrrM   r   loggerwarningrg   )r   r\   est
owner_infoeuids         rA   
_load_jsonzPairingStore._load_json  s    ;;=zz$..'."BCC6 	5 # 1B#-bii[s2::rs?S>T!UJ 1!0J1 (/r9'=rzz|5E
 $
A 	(('2 	s@   $9 	C48A<;C<B
C	B

ACC4C43C4ri   c                 H    t        |t        j                  |dd             y )Nrq   Frr   )r{   rb   r|   )r   r\   ri   s      rA   
_save_jsonzPairingStore._save_json<  s    dDJJtAEJKr   rI   c                 h    t        |xs d      j                         }|dk(  rt        |      xs |S |S )z<Normalize platform-specific user IDs before persisting them.r6   r   )rO   r9   r   )r   r3   rI   raw_user_ids       rA   _normalize_user_idzPairingStore._normalize_user_id?  s7    '-R(..0z!0=LLr   c                     t        |xs d      j                         }|s
t               S || j                  ||      h}|dk(  r|j	                  t        |             |j                  d       |S )z@Return all known equivalent user IDs for auth/rate-limit checks.r6   r   )rO   r9   setr   rz   r   discard)r   r3   rI   r   aliasess        rA   _user_id_aliaseszPairingStore._user_id_aliasesF  se    '-R(..05L 7 7+ NOz!NN2;?@r   leftrightc                 v    | j                  ||      }| j                  ||      }t        |xr	 |xr ||z        S )z;Return True when two user IDs represent the same principal.)r   bool)r   r3   r   r   left_aliasesright_aliasess         rA   _user_ids_matchzPairingStore._user_ids_matchR  s@    ,,Xt<--h>LU]U}8TVVr   c                 |    | j                  | j                  |            }|D ]  }| j                  |||      s y y)z3Check if a user is approved (paired) on a platform.TF)r   r   r   )r   r3   rI   approvedapproved_user_ids        rA   is_approvedzPairingStore.is_approvedZ  sF    ??4#6#6x#@A ( 	##H.>H	 r   c                     g }|r|gn| j                  d      }|D ]P  }| j                  | j                  |            }|j                         D ]  \  }}|j	                  ||d|        R |S )z5List approved users, optionally filtered by platform.r   )r3   rI   )_all_platformsr   r   itemsrP   )r   r3   resultsr   pr   rG   infos           rA   list_approvedzPairingStore.list_approvedb  s    "*XJ0C0CJ0O	 	HAt':':1'=>H%^^- H	TA#FFGH	H r   	user_namec                 X   | j                  | j                  |            }| j                  ||      }|D cg c]  }| j                  |||      r| }}|D ]  }||=  |t	        j                         d||<   | j                  | j                  |      |       t        ||       yc c}w )zAAdd a user to the approved list. Must be called under self._lock.)r   approved_atN)r   r   r   r   timer   rU   )r   r3   rI   r   r   normalized_user_idr   duplicate_idss           rA   _approve_userzPairingStore._approve_userl  s    ??4#6#6x#@A!44XwG %-
 ##H.>@RS 
 

 !. 	+)*	+ #99;(
#$ 	++H5x@
 	H&89#
s   B'c           	      B   | j                  |      }| j                  5  | j                  |      }|D cg c]  }| j                  |||      r| }}|r2|D ]  }||=  | j	                  ||       t        ||       	 ddd       y	 ddd       yc c}w # 1 sw Y   yxY w)z<Remove a user from the approved list. Returns True if found.NTF)r   r   r   r   r   r[   )r   r3   rI   r\   r   r   matching_idss          rA   revokezPairingStore.revoke  s    ""8,ZZ 	t,H )1$''2BGL !L 
 (4 3$ !123h/ 'x9	 	 	  	  s   BB-BBBcodesaltc                 l    t        j                  || j                  d      z         j                         S )z6Hash a pairing code with the given salt using SHA-256.r^   )hashlibsha256encode	hexdigest)r   r   s     rA   
_hash_codezPairingStore._hash_code  s*     ~~dT[[%99:DDFFr   c                    | j                   5  | j                  |       | j                  ||      }| j                  |      r
	 ddd       y| j	                  ||      r
	 ddd       y| j                  | j                  |            }t        |      t        k\  r
	 ddd       ydj                  d t        t              D              }t        j                  d      }| j                  ||      }t        j                   d      }	||j#                         ||t%        j$                         d||	<   | j'                  | j                  |      |       | j)                  ||       |cddd       S # 1 sw Y   yxY w)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.
        Nr6   c              3   N   K   | ]  }t        j                  t                y wN)secretschoiceALPHABET).0_s     rA   	<genexpr>z-PairingStore.generate_code.<locals>.<genexpr>  s     P7>>(3Ps   #%   r   )hashr   rI   r   
created_at)r   _cleanup_expiredr   _is_locked_out_is_rate_limitedr   r   rX   MAX_PENDING_PER_PLATFORMrR   rangeCODE_LENGTHrM   urandomr   r   	token_hexhexr   r   _record_rate_limit)
r   r3   rI   r   r   pendingr   r   	code_hashentry_ids
             rA   generate_codezPairingStore.generate_code  sX    ZZ (	!!(+!%!8!87!K ""8,(	 (	 $$Xw7(	 (	 ood&8&8&BCG7|77(	 (	$ 77PU;=OPPD ::b>Dd3I ((+H "
-&"iik!GH OOD..x8'B ##Hg6Q(	 (	 (	s   6E.E.(3E.$C E..E7c           	      6   | j                   5  | j                  |       |j                         j                         }| j	                  |      r
	 ddd       y| j                  | j                  |            }d}d}|j                         D ]i  \  }}t        |t              sd|vsd|vr 	 t        j                  |d         }| j                  ||      }	t        j                  |	|d         se|}|} n || j!                  |       	 ddd       y||= | j#                  | j                  |      |       | j%                  ||d   |j'                  dd             |d   |j'                  dd      dcddd       S # t        $ r Y w xY w# 1 sw Y   yxY w)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   r   rI   r   r6   )rI   r   )r   r   upperr9   r   r   r   r   re   rf   bytesfromhex
ValueErrorr   r   compare_digest_record_failed_attemptr   r   r;   )
r   r3   r   r	  matched_keymatched_entryr  r@   r   candidate_hashs
             rA   approve_codezPairingStore.approve_code  s    ZZ 3	!!(+::<%%'D ""8,3	 3	 ood&8&8&BCG K M#*==? %!%.&&*= ==v7D "&t!<)).%-H"*K$)M "++H5O3	 3	R $OOD..x8'B xy)A,00bAC )3*..{B?a3	 3	: " ;3	 3	sD   AFAF-F +F1FA!F 	F	FFFFc                    g }| j                   5  |r|gn| j                  d      }|D ]  }| j                  |       | j                  | j	                  |            }|j                         D ]  \  }}t        |t              s|j                  d      }t        |t        t        f      s?t        t        j                         |z
  dz        }	|j                  d      }
t        |
t              r|
dd nd}|j                  |||j                  dd	      |j                  d
d	      |	d        
 	 ddd       |S # 1 sw Y   |S xY w)u  List pending pairing requests, optionally filtered by platform.

        Codes are stored hashed — the ``code`` field is replaced with the
        first 8 hex characters of the hash so admins can distinguish entries
        without revealing the original code. Legacy plaintext-key entries
        (pre-hash format) are shown with a "legacy" placeholder so admins
        can see them age out without crashing on a missing ``hash`` field.
        r	  r   <   r   Nr   legacyrI   r6   r   )r3   r   rI   r   age_minutes)r   r   r   r   r   r   re   rf   r;   intfloatr   rO   rP   )r   r3   r   r   r   r	  r  r   r   age_minhash_valcode_displays               rA   list_pendingzPairingStore.list_pending  s4    ZZ 	&.
D4G4G	4RI %%a(//$*<*<Q*?@&-mmo NHd%dD1 !%,!7J%j3,? !499;#;r"ABG#xx/H3=h3L8BQ<RZLNN$% ,#'88Ir#:%)XXk2%>'.$ 	* +	* s   D$D>>Ec                 (   | j                   5  d}|r|gn| j                  d      }|D ]Q  }| j                  | j                  |            }|t	        |      z  }| j                  | j                  |      i        S 	 ddd       |S # 1 sw Y   S xY w)z2Clear all pending requests. Returns count removed.r   r	  N)r   r   r   r   rX   r   )r   r3   countr   r   r	  s         rA   clear_pendingzPairingStore.clear_pending<  s    ZZ 	;E&.
D4G4G	4RI ;//$*<*<Q*?@W% 2 21 5r:;	; 	; s   A/BBc                     | j                  | j                               }| j                  ||      D ];  }| d| }|j                  |d      }t	        j                         |z
  t
        k  s; y y)z2Check if a user has requested a code too recently.:r   TF)r   r   r   r;   r   RATE_LIMIT_SECONDS)r   r3   rI   limitsaliaskeylast_requests          rA   r  zPairingStore._is_rate_limitedI  sq    !6!6!89**8W= 	EJaw'C!::c1-L		l*.@@		
 r   c                     | j                  | j                               }t        j                         }| j                  ||      D ]  }| d| }|||<    | j	                  | j                         |       y)z7Record the time of a pairing request for rate limiting.r&  N)r   r   r   r   r   )r   r3   rI   r(  nowr)  r*  s          rA   r  zPairingStore._record_rate_limitS  sr    !6!6!89iik**8W= 	EJaw'CF3K	 	--/8r   c                     | j                  | j                               }d| }|j                  |d      }t        j                         |k  S )zBCheck if a platform is in lockout due to failed approval attempts.	_lockout:r   )r   r   r;   r   )r   r3   r(  lockout_keylockout_untils        rA   r   zPairingStore._is_locked_out\  sF    !6!6!89!(,

;2yy{]**r   c           	      `   | j                  | j                               }d| }|j                  |d      dz   }|||<   |t        k\  rGd| }t	        j                         t
        z   ||<   d||<   t        d| dt
         dt         dd	
       | j                  | j                         |       y)zMRecord a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS.z
_failures:r      r/  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   r3   r(  fail_keyfailsr0  s         rA   r  z#PairingStore._record_failed_attemptc  s    !6!6!89z*

8Q'!+ x''%hZ0K"&))+"?F; F8'z1A/AR S.//?AHLN--/8r   c                    | j                  |      }| j                  |      }t        j                         }g }|j                         D ]~  \  }}t	        |t
              s|j                  |       (|j                  d      }t	        |t        t        f      s|j                  |       a||z
  t        kD  sn|j                  |        |r|D ]  }||=  | j                  ||       yy)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).
        r   N)r   r   r   r   re   rf   rP   r;   r  r  CODE_TTL_SECONDSr   )	r   r3   r\   r	  r-  expiredr  r   r   s	            rA   r   zPairingStore._cleanup_expireds  s     !!(+//$'iik%mmo 		)NHddD)x(,/Jj3,7x(j $44x(		) # &H%&OOD'* r   r   c                    g }t         j                         D ]e  }|j                  j                  d| d      s#|j                  j	                  d| dd      }|j                  d      rU|j                  |       g |S )z:List all platforms that have data files of a given suffix.-z.jsonr6   r   )r   iterdirry   endswithreplace
startswithrP   )r   r   r   r   r3   s        rA   r   zPairingStore._all_platforms  sy    	$$& 	/Avv6(%0166>>AfXU*;R@**3/$$X.		/
 r   r   )r6   )(__name__
__module____qualname____doc__r   rO   r   propertyr   r   r   r   r   rf   r   r   r   r   r   r   r   r   listr   r   r   staticmethodr  r   r  r  r!  r  r$  r  r  r   r  r   r    r   rA   r   r      s      & #  6c 6d 67s 7t 7/$ /t  @Lt L4 LD L3   
 
s 
s3x 
W W3 Ws Wt WC # $ c T :c :C :C :QU :.s S T . G GE Gc G G
 =?66&)6696	#6pBS B B BHS D B	c 	S 	 s t 93 9 9 9+s +t +9s 9t 9 + + +4S T r   r   )r4   N)-rE  r   rb   loggingrM   r   r   r   r   pathlibr   typingr   gateway.whatsapp_identityr   r   r   r   r   utilsr	   	getLoggerrB  r   r   r  r:  r'  r5  r  r4  r   r:   rO   rB   rG  rH   rU   r[   rf   rj   r   r   r{   r   rI  r   rA   <module>rP     s.  (    	       =  			8	$ .      0)<(& ( 4	
 " $ " 
 , $ ( $ " 4 $  .!" #$ &% ,# (3- *B# B$ B#   :S 3 4 .$ 4 R4 R R R4* C D 4j jr   