
    Jj.^                        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 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
d=e d5dfd>Z& G d? d@          Z'dS )Aa  
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)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                    | 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_registryr6   allowed_users_env	Exception)r2   env_varr6   entrys       5/home/rurouni/.hermes/hermes-agent/gateway/pairing.py_allowlist_env_for_platformrA   W   s     B%%''--//H%))(33G ??????!%%h// 	+U, 	+**   4s   *A4 4
B Brawc                 @    d |                      d          D             S )Nc                 ^    g | ]*}|                                 |                                 +S  )r8   ).0uids     r@   
<listcomp>z$_split_allowlist.<locals>.<listcomp>m   s-    AAACSYY[[ACIIKKAAA    ,)split)rB   s    r@   _split_allowlistrL   l   s     AA399S>>AAAArI   user_idc                    t          |           }|sdS t          j        |d                                          }|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.
    Nr5   *r   )save_env_valuerJ   )rA   osgetenvr8   rL   strappendhermes_cli.configrP   joinr=   )r2   rM   r>   currentidsrP   s         r@   _sync_allowlist_addrY   p   s     *(33G i$$**,,G 
7
#
#C
czzS\\S((JJs7||444444w.....    	s   %B/ /
B=<B=c                    t          |           }|sdS t          j        |d                                          }|sdS t	          |          }fd|D             }t          |          t          |          k    rdS 	 ddlm}m} |r! ||d	                    |                     dS  ||           dS # t          $ r Y dS w xY w)zBRemove ``user_id`` from the platform allowlist env var if present.Nr5   c                 :    g | ]}|t                    k    |S rE   )rS   )rF   irM   s     r@   rH   z*_sync_allowlist_remove.<locals>.<listcomp>   s)    555q1G#4#4#4#4#4rI   r   )rP   remove_env_valuerJ   )rA   rQ   rR   r8   rL   lenrU   rP   r]   rV   r=   )r2   rM   r>   rW   rX   	remainingrP   r]   s    `      r@   _sync_allowlist_remover`      s   )(33G i$$**,,G 
7
#
#C5555C555I
9~~S!!FFFFFFFF 	&N7CHHY$7$788888W%%%%%   s    )B8 +B8 8
CCpathdatac                 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.
    Tparentsexist_okz.tmp)dirsuffixwutf-8encodingNi  )parentmkdirtempfilemkstemprS   rQ   fdopenwriteflushfsyncfilenor   chmodOSErrorBaseExceptionunlink)ra   rb   fdtmp_pathfs        r@   _secure_writer}      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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%dS ))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
    c                 n    t                               dd           t          j                    | _        d S )NTrd   )PAIRING_DIRrn   	threadingRLock_lockselfs    r@   __init__zPairingStore.__init__   s0    $666 _&&


rI   r2   r3   c                     t           | dz  S )Nz-pending.jsonr   r   r2   s     r@   _pending_pathzPairingStore._pending_path   s    77777rI   c                     t           | dz  S )Nz-approved.jsonr   r   s     r@   _approved_pathzPairingStore._approved_path   s    88888rI   c                     t           dz  S )Nz_rate_limits.jsonr   r   s    r@   _rate_limit_pathzPairingStore._rate_limit_path   s    000rI   ra   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 )
Nrj   rk   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.)existsjsonloads	read_textPermissionErrorstatst_uidoctst_moderw   hasattrrQ   r   loggerwarningJSONDecodeError)r   ra   est
owner_infoeuids         r@   
_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rb   Nc                 P    t          |t          j        |dd                     d S )N   F)indentensure_ascii)r}   r   dumps)r   ra   rb   s      r@   
_save_jsonzPairingStore._save_json   s)    dDJtAEJJJKKKKKrI   rM   c                 z    t          |pd                                          }|dk    rt          |          p|S |S )z<Normalize platform-specific user IDs before persisting them.r5   r   )rS   r8   r   )r   r2   rM   raw_user_ids       r@   _normalize_user_idzPairingStore._normalize_user_id   sC    '-R((..00z!!0==LLrI   c                    t          |pd                                          }|st                      S ||                     ||          h}|dk    r"|                    t          |                     |                    d           |S )z@Return all known equivalent user IDs for auth/rate-limit checks.r5   r   )rS   r8   setr   updater   discard)r   r2   rM   r   aliasess        r@   _user_id_aliaseszPairingStore._user_id_aliases  s    '-R((..00 	55L 7 7+ N NOz!!NN2;??@@@rI   leftrightc                     |                      ||          }|                      ||          }t          |o|o||z            S )z;Return True when two user IDs represent the same principal.)r   bool)r   r2   r   r   left_aliasesright_aliasess         r@   _user_ids_matchzPairingStore._user_ids_match  sI    ,,Xt<<--h>>LU]U}8TVVVrI   c                     |                      |                     |                    }|D ]}|                     |||          r dS dS )z3Check if a user is approved (paired) on a platform.TF)r   r   r   )r   r2   rM   approvedapproved_user_ids        r@   is_approvedzPairingStore.is_approved  s]    ??4#6#6x#@#@AA ( 	 	##H.>HH tturI   c                     g }|r|gn|                      d          }|D ]^}|                     |                     |                    }|                                D ]\  }}|                    ||d|            _|S )z5List approved users, optionally filtered by platform.r   )r2   rM   )_all_platformsr   r   itemsrT   )r   r2   results	platformspr   rG   infos           r@   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rI   r5   	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 rE   r   )rF   r   normalized_user_idr2   r   s     r@   rH   z.PairingStore._approve_user.<locals>.<listcomp>+  sC     
 
 
 ##H.>@RSS

 
 
rI   )r   approved_atN)r   r   r   timer   rY   )r   r2   rM   r   r   duplicate_idsr   r   s   ``     @r@   _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9rI   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 rE   r   )rF   r   r2   r   rM   s     r@   rH   z'PairingStore.revoke.<locals>.<listcomp>C  sB       $''2BGLL   rI   NTF)r   r   r   r   r`   )r   r2   rM   ra   r   matching_idsr   s   ```    r@   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.rj   )hashlibsha256encode	hexdigest)r   r   s     r@   
_hash_codezPairingStore._hash_codeU  s1     ~dT[[%9%99::DDFFFrI   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.
        Nr5   c              3   H   K   | ]}t          j        t                    V  d S N)secretschoiceALPHABET)rF   _s     r@   	<genexpr>z-PairingStore.generate_code.<locals>.<genexpr>z  s,      PP7>(33PPPPPPrI      r
   )hashr   rM   r   
created_at)r   _cleanup_expiredr   _is_locked_out_is_rate_limitedr   r   r^   MAX_PENDING_PER_PLATFORMrV   rangeCODE_LENGTHrQ   urandomr   r   	token_hexhexr   r   _record_rate_limit)
r   r2   rM   r   r   pendingr   r   	code_hashentry_ids
             r@   generate_codezPairingStore.generate_codeZ  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 ||= |                     |                     |          |           |                     ||d         |                    dd                     |d         |                    dd          d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   r   rM   r   r5   )rM   r   )r   r   upperr8   r   r   r   r   
isinstancedictbytesfromhex
ValueErrorr   r   compare_digest_record_failed_attemptr   r   r:   )
r   r2   r   r   matched_keymatched_entryr   r?   r   candidate_hashs
             r@   approve_codezPairingStore.approve_code  s    Z 3	 3	!!(+++::<<%%''D ""8,, 3	 3	 3	 3	 3	 3	 3	 3	 ood&8&8&B&BCCG K M#*==??  %!%.. &&&*=*= ==v77DD!   H!%t!<!<).%-HH "*K$)ME
 "++H555O3	 3	 3	 3	 3	 3	 3	 3	R $OOD..x88'BBB xy)A,00bAAC C C )3*..{B?? a3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	 3	sE   AG"'A$G" C-,G"-
C:7G"9C::AG"A;G""G&)G&c                    g }| j         5  |r|gn|                     d          }|D ]A}|                     |           |                     |                     |                    }|                                D ]\  }}t          |t                    s|                    d          }t          |t          t          f          sMt          t          j                    |z
  dz            }	|                    d          }
t          |
t                    r
|
dd         nd}|                    |||                    dd	          |                    d
d	          |	d           C	 ddd           n# 1 swxY w Y   |S )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
   legacyrM   r5   r   )r2   r   rM   r   age_minutes)r   r   r   r   r   r   r   r   r:   intfloatr   rS   rT   )r   r2   r   r   r   r   r   r   r   age_minhash_valcode_displays               r@   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#xx//H3=h3L3L#Z8BQB<<RZLNN$% ,#'88Ir#:#:%)XXk2%>%>'.$ $    	 	 	 	 	 	 	 	 	 	 	 	 	 	 	* s   E E77E;>E;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   r   N)r   r   r   r   r^   r   )r   r2   countr   r   r   s         r@   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   r2   rM   limitsaliaskeylast_requests          r@   r   zPairingStore._is_rate_limited  s    !6!6!8!899**8W== 	 	E''''C!::c1--L	l*.@@@tt AurI   c                    |                      |                                           }t          j                    }|                     ||          D ]}| d| }|||<   |                     |                                 |           dS )z7Record the time of a pairing request for rate limiting.r  N)r   r   r   r   r   )r   r2   rM   r  nowr  r  s          r@   r   zPairingStore._record_rate_limit  s    !6!6!8!899ikk**8W== 	 	E''''CF3KK--//88888rI   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   r2   r  lockout_keylockout_untils        r@   r   zPairingStore._is_locked_out  sP    !6!6!8!899,(,,

;22y{{]**rI   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.z
_failures:r      r  z[pairing] Platform z locked out for zs after z failed attemptsT)rs   N)r   r   r:   MAX_FAILED_ATTEMPTSr   LOCKOUT_SECONDSprintr   )r   r2   r  fail_keyfailsr  s         r@   r   z#PairingStore._record_failed_attempt  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8rI   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).
        r   N)r   r   r   r   r   r   rT   r:   r  r  CODE_TTL_SECONDSr   )	r   r2   ra   r   r  expiredr   r   r   s	            r@   r   zPairingStore._cleanup_expired.  s     !!(++//$''ikk%mmoo 		) 		)NHddD)) x(((,//Jj3,77 x(((j $444x((( 	+# & &H%%OOD'*****	+ 	+rI   rh   c                    g }t                                           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.jsonr5   r   )r   iterdirnameendswithreplace
startswithrT   )r   rh   r   r|   r2   s        r@   r   zPairingStore._all_platformsH  s    	$$&& 	/ 	/Av0600011 /6>>*;f*;*;*;R@@**3// /$$X...rI   r   )r5   )&__name__
__module____qualname____doc__r   rS   r   r   r   r   r   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   rE   rI   r@   r   r      s        ' ' '8c 8d 8 8 8 89s 9t 9 9 9 91$ 1 1 1 1t     @Lt L4 LD L L L L3      
 
s 
s3x 
 
 
 
W W3 Ws Wt W W W WC # $     c T    : :c :C :C :QU : : : :.s S T    . G GE Gc G G G \G
 =?6 66&)6696	#6 6 6 6pBS B B B B B BH S D    B	 	c 	S 	 	 	 	 s t    93 9 9 9 9 9 9+s +t + + + +9s 9t 9 9 9 9 + + + + + +4S T      rI   r   )(r0  r   r   loggingrQ   r   ro   r   r   pathlibr   typingr   gateway.whatsapp_identityr   r   hermes_constantsr   utilsr   	getLoggerr-  r   r   r   r$  r  r  r   r  r   r9   rS   rA   r1  rL   rY   r`   r}   r   rE   rI   r@   <module>r:     s   (    				                           , + + + + +            		8	$	$ .      n0)<<(& ( 4	
 " $ " 
 , $ ( $ " 4 $  .!" #$ &%  ,# (3-    *B# B$ B B B B#      :S 3 4    . C D    4R R R R R R R R R RrI   