Saturday, April 29, 2017

Authorization of Tomcat clients by Kerberos and LDAP

​​​​​​​​​​Motivation
We are interested in following scenario:
  • Tomcat authenticates clients by Kerberos. So it has a keytab file for a valid Active Directory account, used to decrypt and validate clients' tickets. 
  • It means that the server cannot know passwords of its users.
  • ​So we want to use the same keytab to make the server to bind (authenticate) to domain controller's LDAP and then send queries to it in order to discover, which groups the user belongs to, in order to map them to webapp's roles.​

Configuration
Let's say, our web application runs on a server called website.hosting.org. We want it to authenticate users from Active Directory domain ourcompany.com.

I choose these names to stress that the  site doesn't have to be part of Active Directory in any way - Tomcat process doesn't have to run under domain account, and the computer it runs on doesn't have to be joined to domain at all. But it has to be able to reach domain controllers by LDAP.


Keytab file

First, we need to create Active Directory account for our webapp. Let's call it webapp@ourcompany.com.

Again, it's important to mention that although Tomcat on that PC runs on Windows, this account has nothing to do with the Windows account running the Tomcat's process or with computer's account in the domain (which can be none). It's just some AD account whose keytab is owned by our webapp - so the webapp can use this keytab to decrypt and check Kerberos tickets arriving from clients.

For security purposes, I removed this webapp@ourcompany.com account from the default Domain Users group and added it to another group, ServiceAccounts, set as Primary group. So this account is granted minimal permissions in the domain.

Next, we generate the keytab file for this account by executing Windows ktpass utility (installed by default on domain controllers):
ktpass /princ HTTP/website.hosting.org@OURCOMPANY.COM /mapuser OURCOMPANY\webapp /pass userPassword /ptype KRB5_NT_SRV_INST /crypto RC4-HMAC-NT /out tomcat-keytab​ -setupn

What happens here?
  • We associate this webapp account with Service Principal Name HTTP/website.hosting.org. This SPN will be used by clients to retrieve tickets to our webapp from KDC.
  • The userPassword of the account is encrypted with RC4 and saved to the keytab file, together with the SPN. It will be used by SpnegoAuthenticator to decrypt clients' tickets.
  • The "encryption type" (/crypto option) is set to RC4-HMAC-NT, because our domain OURCOMPANY.COM has both Windows 2003 and Windows 2008 domain controllers. Win2003 DC supports DES and RC4 encryption, while Win2008 by default supports RC4 and AES (and DES can be added per user) - see details here. So the most convenient option is RC4-HMAC-NT- it's supported out-of-the-box by DCs of both generations - but AES256-SHA1​ is the most secure.
    If we do go for the AES256-SHA1 option, then two additional steps have to be done:
    • In "Active Directory Users and Computers" tool, we need to mark in the properties of webapp account the flag "This account supports Kerberos AES 256 bit option".
    • We need to install Java Cryptography Extension package by replacing two files in %JAVA_HOME%/jre/lib/securitylocal_policy.jar & US_export_policy.jar​.
  • The "principal type" (/ptype option) is set to KRB5_NT_SRV_INST to specify that the keytab can be used to accept tickets, not to request them.
  • The keytab file is saved under name tomcat-keytab. We'll move it to our Tomcat /conf folder.
  • The -setupn option prevents ktpass from overwriting userPrincipalName of the webapp account - it stays webapp@ourcompany.com. Otherwise ktpass would change it to the SPN value, HTTP/website.hosting.org, which will create problems.
Verifications:

Contents of the keytab file can be validated by ktab command from JRE:
C:\TomEE\conf> ktab -k tomcat-keytab -l -e
Keytab name: tomcat-keytab
KVNO Principal
---- --------------------------------------------------------------------
   9 HTTP/website.hosting.org@OURCOMPANY.COM (23:RC4 with HMAC)​


SPN association can be checked with Windows setspn command.
Looking up SPNs by account:
C:\> setspn -L webapp
Registered ServicePrincipalNames for CN=webapp,OU=Service_Accounts,DC=ourcompany,DC=com:
        HTTP/website.hosting.org


Looking up account by ​SPN:
C:\> setspn -Q HTTP/website.hosting.org
Checking domain DC=ourcompany,DC=com
CN=webapp,OU=Service_Accounts,DC=ourcompany,DC=com
        HTTP/website.hosting.org
Existing SPN found!

How to check the KVNO number associated with the account - run this commands in Windows PowerShell:
$User = [ADSI]"LDAP://CN=webapp,OU=Service_Accounts,DC=ourcompany,DC=com"
$User.psbase.RefreshCache("msDS-KeyVersionNumber")
$Key = $User.Properties.Item("msDS-KeyVersionNumber")
Write-Host $Key



%CATALINA_HOME%\bin\setenv.bat​:

​@echo off
REM Needed to allow Tomcat to authenticate to Kerberos KDC and request service tickets with its own keytab:
set JAVA_OPTS=%JAVA_OPTS% -Djavax.security.auth.useSubjectCredsOnly=false

Notes:
  • The javax.security.auth.useSubjectCredsOnly=false setting is required, if we want Tomcat to authenticate with its own credentials, instead of the client's password or delegated ticket. Unfortunately, this is Tomcat-wide setting.
 
META-INF/context.xml:

​<?xml version="1.0" encoding="UTF-8"?>
<Context>

    <WatchedResource>WEB-INF/web.xml</WatchedResource>
      <Valve className="org.apache.catalina.authenticator.SpnegoAuthenticator" storeDelegatedCredential="false" alwaysUseSession="true" loginConfigName="SpnegoAuthenticator"​/>​
      <Realm className="org.apache.catalina.realm.JAASRealm" appName="ldaptive" userClassNames="org.ldaptive.jaas.LdapPrincipal" roleClassNames="org.ldaptive.jaas.LdapRole" stripRealmForGss="false"/>
</Context>
Notes about SpnegoAuthenticator:
  • storeDelegatedCredential="false" means that we don't want to use delegation and to store client's ticket for this purpose.
  • alwaysUseSession="true" creates HttpSession object, associates JSESSIONID cookie with it and passes the cookie to the browser. Subsequent requests from the browser will carry this cookie, so they won't pass the whole authentication process again to be served.
  • loginConfigName="SpnegoAuthenticator" defines section in the jaas.conf file (see below), where server-side Kerberos settings are kept.
Notes about JAASRealm:
  • appName="ldaptive" defines section in the jaas.conf file (see below), where login modules used by this Realm are defined with their parameters.
  • userClassNames and RoleClassNames define what kinds of Java objects will represent user's principal and his roles. These objects will be associated with user's Subject by the login modules.
  • stripRealmForGss="false" means that user's name will be in "username@domain.fqdn" format, not just "username". We'll want this in cases where our Active Directory domain has trust relationships​ with other domains (in which the "username" can be associated with another account). The short format doesn't guarantee unique names, the long one does.


%CATALINA_HOME%\conf\jaas.conf:

SpnegoAuthenticator
{
    com.sun.security.auth.module.Krb5LoginModule required
    doNotPrompt=true
    principal="HTTP/website.hosting.org@OURCOMPANY.COM"
    useKeyTab=true
    keyTab="../conf/tomcat-keytab"
    storeKey=true
    isInitiator=false;
};

com.sun.security.jgss.initiate
{
    com.sun.security.auth.module.Krb5LoginModule required
    doNotPrompt="true"
    principal="HTTP/
website.hosting.org@OURCOMPANY.COM"
    useKeyTab="true"
    keyTab="../conf/tomcat-keytab";
};
ldaptive
{
 org.ldaptive.jaas.LdapDnAuthorizationModule required
    javax.security.sasl.server.authentication="true"
    storePass="true"
    ldapUrl="
ldap://dc01.ourcompany.com:3268 ldap://dc02.ourcompany.com:3268"
    connectionStrategy="ACTIVE_PASSIVE"
    bindSaslConfig="{mechanism=GSSAPI}"
    baseDn="
DC=ourcompany,DC=com"
    subtreeSearch="true"
    userFilter="(&(userPrincipalName={user})(objectClass=person))";
 org.ldaptive.jaas.LdapRoleAuthorizationModule required
    javax.security.sasl.server.authentication="true"​
    ldapUrl="
ldap://dc01.ourcompany.com:3268 ldap://dc02.ourcompany.com:3268"
    connectionStrategy="ACTIVE_PASSIVE"
    searchEntryHandlers="org.ldaptive.handler.RecursiveEntryHandler{{searchAttribute=memberOf}{mergeAttributes=cn}}"​
    bindSaslConfig="{mechanism=GSSAPI}"
    baseDn="
DC=ourcompany,DC=com"
    subtreeSearch="true"
    roleFilter="
(&(member={dn})(objectClass=group))"
    roleAttribute="
cn";
};

%CATALINA_HOME%\conf\krb5.ini:

This file is optional. 
The Krb5LoginModule knows to discover automatically the Kerberos realm and KDCs on Windows (in other words, current domain and its controllers), by several methods (see sun.security.krb5.​Config class for details):

To resolve the realm:
  • By reading the environment variable USERDNSDOMAIN​.
  • By getting DNS domain from station's hostname and querying DNS for TXT record ​_kerberos.ourcompany.com.
  • By reading the krb5.ini file.
Once the realm is known, the KDCs can be obtained by:
  • ​Reading the environment variable LOGONSERVER.
  • By quering DNS for SRV records _kerberos._udp.ourcompany.com & _kerberos._tcp.ourcompany.com.
  • By reading the krb5.ini file.
So we need this file only in case the other methods fail. But its presence may speed things up.
Example of such file can be found here or here​.
[libdefaults]  
default_realm = OURCOMPANY.COM
[realms]
OURCOMPANY.COM = {
    kdc = dc01.ourcompany.com
}

[domain_realm]
.ourcompany.com = OURCOMPANY.COM
ourcompany.com  = OURCOMPANY.COM​

WEB-INF/web.xml:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://java.sun.com/xml/ns/javaee"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         version="3.0">
    <display-name>IntegratedAuth</display-name>
    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Entire Application</web-resource-name>
            <url-pattern>/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>
Webapp-Users</role-name>
        </auth-constraint>
    </security-constraint>
    <login-config>
        <auth-method>SPNEGO</auth-method>
        <realm-name></realm-name>
    </login-config>
    <security-role>
        <role-name>
Webapp-Users</role-name>
    </security-role>
    <welcome-file-list>
        <welcome-file>index.html</welcome-file>
        <welcome-file>index.htm</welcome-file>
        <welcome-file>index.jsp</welcome-file>
        <welcome-file>default.html</welcome-file>
        <welcome-file>default.htm</welcome-file>
        <welcome-file>default.jsp</welcome-file>
    </welcome-file-list>
</web-app>
Notes:

  • Webapp-Users is a short ("common") name of the user's group in Active Directory - the full ("distinguished") name can be something like "CN=Webapp-Users,OU=Groups,DC=ourcompany,DC=com". The short name will be returned byLdapRoleAuthorizationModule as one of user's roles (in org.ldaptive.jaas.LdapRole​ object, see above). The roleAttribute="cn"​ parameter of the login module defines that we want to use the common name, not the distinguished name.So here we can use it as name of the security role to limit access to servlets and other web resources. We can also use it inside the servlet's code, in HttpServletRequest.isUserInRole​()​calls and in @ServletSecurity​ annotations (rolesAllowed parameter).

The flow:
Let's some it up. The process is this:
  • A client accesses website.hosting.org and is required to authenticate with SPNEGO ("Integrated Windows authentication"), as defined in the web.xml file. 
  • If a client is logged on under domain account and browser policy allows that, the browser will try to obtain Kerberos ticket for this site. If it's not possible, it may prompt the user for credentials.
  • If the client enters his username in UPN format (username@ourcompany.com), then browsers such as Chrome or IE will try to look up addresses of KDCs (domain controllers) for ourcompany.com, using DNS - even if the client isn't domain user or even if he's on standalone station. See this blog post about more details of this process (russian).
  • If a browser succeeds to find a domain controller and receive a Kerberos ticket for website.hosting.org, then it'll submit the ticket to the webapp.
  • The webapp will decrypt the ticket using its symmetrical key shared with domain controller, which is contained in the tomcat-keytab file, - and check its validity.
  • If the ticket is valid, then the webapp read user's UPN from it and will connect to domain controller by LDAP, authenticating by the same key in the same tomcat-keytab file.
  • It will use the UPN to resolve full Distinguished Name (DN) of the client.
  • Then it will use the DN to obtain list of groups this user is member of. The user should be joined to them directly, nesting groups will not be resolved.
  • These groups will be translated to JAAS roles.
  • If a client belongs to a group, for which <security-constraint> tag in web.xml allows access (in our case, Webapp-Users), he'll be served by the webapp. Happy end.
  • It's also worth to mention that client's UPN can be obtained programmatically inside the webapp by getUserPrincipal(), and his group membership can be validated by isUserInRole() calls, which are part of standard servlet specification.

How to configure split routing in TMG

Note: I know that the whole ForeFront family is no longer supported by Microsoft (alas!). But TMG is a great product, it's still in use and there are companies still selling & supporting it. So I feel that sharing this info still can be useful for someone.


​​​​​​​​​​​​​​The ForeFront TMG doesn't allow out-of-the-box to push routes for specific subnets to VPN clients.
If you configure it to assign IP addresses out of predefined pool, then TMG relies on VPN client that it will:
  • either define TMG to function as default gateway - all non-local traffic will be sent over VPN,
  • or add an entry to routing table based on class (A, B or C) to which the assigned IP address belongs. For instance, if granted address is 10.3.4.5, then Windows VPN client can add route to the whole class A subnet - 10.0.0.0/8 - towards VPN.
​If we don't want either of these options, then the only way is to configure TMG to request addresses for its VPN clients from DHCP.

That's how it's done - it's pretty counter-intuitive:


1. In TMG Management console, go to "Remote Access Policy (VPN)" and then click on "Configure address assignment method".
Specify a pool of addresses, which you want to distribute to the clients:
​​
The very first address of this range - in this case, 10.50.9.10 - will be used as address of DHCP relay agent (the GIADDR field in DHCP request).
This is important, because DHCP server uses GIADDR to recognize, settings from which scope should be used sent with the response (including the static route options).


2. Now we need to launch the "Routing and Remote Access" console, expand the local TMG host node , expand IPv4, right-click General, select "New routing protocol..." and add DHCP relay agent.
We need to configure it with the address of our DHCP server and add "Internal" to its list of interfaces. This Internal interface isn't related to Internal network in TMG Management console, appearing on the screenshot above. It represents VPN clients.



























3. Now let's set up DHCP server itself. We need to define a scope with the same pool of addressses we defined on TMG itself. Now we can play with Scope Options, adding different settings we wish to push to VPN clients - including option 121, "Classless Static Routes":

N.B. The address in "Router" field can be anything, as long as it falls inside the subnet range. It doesn't really matter - when the route is pushed into VPN client's forwarding table, the "Gateway" will be set to "On-link", since its point-to-point connection.


4. And the last thing - configure the Windows VPN client itself.
By default, it's configured to forward all traffic to unknown networks via the VPN connection. To take advantage of split routing, we need to change that:
  • unmark the "Use default gateway on remote network" option - we don't want to add route for 0.0.0.0/0.
  • mark the "Disable class based route addition" - we don't want route for the whole 10.0.0.0/8 subnet.
 

Now what is going to happen after VPN user connects and authenticates is:
  • ​TMG will select some address from its static address pool - let's say, 10.5.9.11.
  • It will send "DHCP Inform" request to the DHCP server - 10.50.80.12. In this request, it will specify two important parameters:
    • requested IP address - 10.5.9.11,
    • address of the DHCP relay agent (the GIADDR field) - which is the first address in the pool, 10.5.9.10.
  • ​The DHCP server will use the GIADDR field to find relevant scope and make sure that the requested address is available. Then it will reply with "DHCP Ack" mess​age, which will carry the options defined to this scope - including the "Classless Static Routes" option.
  • TMG will relay this packet to the client.
  • The client will add the received route to its forwarding table:
    ​​​​​​​
    IPv4 Route Table
    =========================================================================== 
    Active Routes:
    Network Destination        Netmask          Gateway       Interface  Metric 
              0.0.0.0          0.0.0.0      10.30.10.10      10.30.50.25     10 
            10.30.0.0    255.255.192.0         On-link       10.30.50.25    266 
          10.30.10.14  255.255.255.255         On-link       10.30.50.25     11 
          10.30.50.25  255.255.255.255         On-link       10.30.50.25    266 
         10.30.63.255  255.255.255.255         On-link       10.30.50.25    266 
            10.50.0.0      255.255.0.0         On-link        10.50.9.13     11 
            10.50.9.0    255.255.255.0         On-link        10.50.9.13     11 
           10.50.9.13  255.255.255.255         On-link        10.50.9.13    266 
          10.50.9.255  255.255.255.255         On-link        10.50.9.13    266 
        10.50.255.255  255.255.255.255         On-link        10.50.9.13    266​ 
Note the route to 10.50.0.0/16.

The DHCP_Inform_and_Ack.pcap file contains example of packets exchange between the relay agent (TMG) and the DHCP server.

Monday, April 24, 2017

Google Apps as SAML IdP

Ознакомился с возможностями Гугла в качестве Identity Provider по протоколу SAML.
Вкратце: не впечатлён. Понимаю теперь, почему ЖЖ, StackOverflow и прочие, тысячи их, используют oAuth.

Недостатки такие:

  • Identity сайта / приложения, желающего опознавать гугл-юзеров по SAML, криптографически никак не проверяется, запросы не подписываются, да и возможности залить соответствующий ключ Гуглу для проверки нету.
    Единственное, что проверяется - что строка "Entity ID", указанная Гуглу при регистрации приложения, соответствуюет полю <saml:Issuer> в запросе. Есть совпадение - и ладушки.

    То есть в принципе фишинговый сайт может получить имена аккаунтов заходящих на него гугл-юзеров, и ещё немного дополнительной информации (см. ниже про атрибуты). Пароли, конечно, не получит.

    Ответ, правда, подписывается честь по чести. Возможности зашифровать его, впрочем нету, да и нечем - ключ, как сказано выше, указать негде.

  • В отличие от сценария с oAuth, Гугл никак не предупреждает юзера, что собирается передавать информацию о нём третьей стороне, и уж тем более не разъясняет, какую именно.
    Просто получаешь страничку аутентификации, проходишь её и возвращаешься на исходный сайт, без каких-либо комментариев. Нехорошо, товарищи гугловцы.

  • Недостаток, несколько смягчающий два предыдущих: очень убогие возможности по части юзерских атрибутов, которые можно прописать в ответе запрашивающему приложению. Можно указать имя, фамилию, e-mail, номер телефона или адрес (а вот это для фишеров уже интересно!), а из корпоративной информации - название должности, департамент и cost center (что бы это не значило). Фсё. Группы, к которым юзер принадлежит в Google Apps, указать никак нельзя, к глубокому моему удивлению.
    ADFS по этому критерию кроет Гугл, как бык овцу.

  • А да, и касается вся эта бодяга исключительно юзеров G-Suite (Google Apps). Обычные юзеры, с @gmail.com в названии, могут не волноваться.

Недостаток у oAuth про сравнению с SAML, правда, тоже есть: он требует от приложения знать конкретный API, от которого оно будет получать информацию после получения юзерского согласия - гугловский ли, фейсбучный или твиттеровский. SAML более стандартизирован (и оттого сложен, как моя жизнь). Хотя при "феодальной" модели, при которой identity provider'ов для 90% сетевого населения можно пересчитать по пальцам, этот недостаток не очень-то заметный.

А вот что может рассматриваться и как преимущество, и как недостаток, так это то, что в oAuth приложение обретает долгосрочный доступ к юзерским данным (тем, к которым ему разрешили лезть). А вот в SAML этого нет - что оно получило при аутентификации, тем и придётся довольствоваться.

И наконец, однозначное преимущество SAML в том, что он не требует прямой связи между приложением и IdP. Сайт с приложением может сидеть во внутренней сети и никуда из неё не выходить, а IdP - в Интернете (тот же Гугл), и весь обмен между ними будет проходить через юзерский браузер.


А вот чем очень приятно впечатлён, это опен-сорсной библиотекой Keycloak, которую использовал, чтобы встроить SAML  в свою маленькую Java-апликацию. Вставляешь servlet filter - и готово, можешь считать юзерский аккаунт и, положим, департамент, через привычные, милые сердцу вызовы getUserPrincipal() и isUserInRole()Причём, поскольку это стандартный servlet filter, можешь гонять свою апликашку на любом Java-сервере - хошь Tomcat, хошь Glassfish, хошь JBoss.

Saturday, November 26, 2016

Adventures with FortiGate LB

Столкнулся на днях с довольно загадочной ситуацией.

Преамбула
Дано: есть сайт об осьми веб-серверах. Есть перед ними load balancer в виде файерволла FortiGate v5.4.1 - распределяет между ними нагрузку, а заодно переводит HTTPS-трафик в HTTP и инспектирует его. А перед ним находится сеть CDN, которая в свою очередь также шерстит трафик на предмет всяких там SQL-иньекций, блокирует DDoS-атаки, кэширует картинки и прочую статику - в общем, полезная вещь.

Когда юзер открывает сайт, DNS направляет его браузер на прокси CDN, браузер устанавливает с прокси SSL-соединение и посылает ему HTTP-запрос. Если запрос прокси нравится - пересылает его по другому SSL-соединению на LB. Тот прогоняет свои проверки - IPS, антивирус, - и перекидывает запрос на один из восьми серверов. Ответ сервера пересылается обратным путём.

Как видим, всё несложно, но есть дополнительное требование: sessions persistence, так же известное как sticky sessions. Сиречь, начав работать с каким-то сервером, юзер так и должен продолжать с ним работать - все запросы от браузера должны приходить на тот же сервер, разве что тот загнётся.
За это у нас отвечает FortiGate - он этого может добиваться одним из двух способов:

  • HTTP cookie - когда приходит запрос от нового клиента, FortiGate добавляет в ответ новый cookie с названием FGTServer и длинным псевдорандомальным значением. Браузер запоминает этот cookie и добавляет в каждый последующий запрос. По его значению Фортигейт знает, на какой из серверов эти запросы перекидывать.
  • SSL Session ID - когда клиент с сервером устанавливает SSL-соединение, сервер сочиняет некий session identifier и посылает его клиенту. Если TCP-connection, на котором SSL-соединение основано, сломалось, клиент может быстро восстановить общение с сервером, открыв новую TCP-трубу и выслав этот identifier - избежав таким образом необходимости в полном обмене ключами. Это ускоряет SSL и расцепляет его с TCP - одно SSL-соединение может использовать множество TCP-соединений. Подробности можно узнать здесь.
    В данном случае "сервер" - это не веб-сервер, а сам FortiGate. Он запоминает, на какой настоящий сервер он перебросил запрос, пришедший по соединению с новым session ID, и последующие запросы направляет соответственно.
Мы используем первый способ.

А теперь амбула
Выплыла проблема: юзерская сессия ломается. Вот залогинился он на сайт, и всё вроде отлично, но вдруг сайт ему и говорит: а ты кто такой, мил человек? Знать тебя не знаю.

Стало быть, не фурычит наша sessions persistence - попадают запросы от юзера вдруг на новый сервер, а не обслуживаются всё тем же. Открываем браузер, жмём F12, смотрим, что происходит - и впрямь: браузер получает в начале печеньку FGTServer, честно отсылает её - зато Фортигейт в какой-то момент от неё отмахивается, будто её и нету, перекидывает запрос на другой сервак и пришивает к ответу новое значение FGTServer.

Казалось бы, дело ясное - FortiGate виновен, на помойку. Но вот в чём закавыка: происходит это только при обращении через CDN - если браузер общается с Фортигейтом напрямую, то всё прекрасно, cookie при всех запросах сохраняется тот же, сервер - тот же, работает наш persistence!

Что за лабуда, думаю - может, CDN наши cookies срезает каким-то образом? Запускаю сниффер на Фортигейте, смотрю - ничего подобного! Вот он наш FGTServer в запросе, и значение правильное - да Фортигейт запрос на другой сервак перекидывает!

От отсутвия идей перешёл на второй способ, на SSL Session ID - не помогло ровно ничем. Напрямую - работает чудесно, через CDN - ломается. Что за притча?

Может, сервер health check проваливает и LB его помечает как дохлый? Может, мы максимум соединений на сервер перешли? Нет, вроде ничем не подтверждается - да и как объяснить тогда, что напрямую всё летает?


Развязка
А ларчик открылся вот как: оказывается, чёртов CDN запросы от множества разных клиентов мультиплексирует на уровне TCP. То есть приходят к ихнему прокси запросы от сотни юзеров - а тот поддерживает с нашим LB какой-то пул TCP-соединений - скажем, пять - и в каждое пихает запросы от разных юзеров.

Это performance, конечно, улучшает - не нужно для каждого нового юзера новый TCP-handshake устанавливать и отдельный сокет держать: бери да пихай его запросы в свободную TCP-трубу. Да только наш load balancer это с ума сводит - видать, не подумали программисты Fortinet, что такая ситуация возможна. При прямом-то обращении такого не бывает - по каждому TCP-коннекшену запросы только от одного клиента идут, с тем же самым FGTServer cookie - а тут он вдруг раз и меняется.

И уж понятно, почему SSL Session ID не помогает - соответствует-то он не клиенту, а CDN-прокси. Решил прокси пихнуть один запрос от клиента в один сокет, а следующий - в другой, и привет: session IDs разные, LB их отсылает на разные сервера.

Отключили мультиплексирование, заставили прокси на каждого клиента новую TCP-трубу открывать - и всё залетало. Вот такой вот хэппи энд, граждане.

P.S.
Была, впрочем, и альтернатива - почётную функцию load balancing на сам CDN возложить. Но его жадные хозяева хотели за это дополнительную денежку, а нас жаба задавила. Нам грубияны не нужны, мы сами грубияны.

Sunday, June 26, 2016

Публикация CRL на внешнем хостинге

(Унылый апдейт от апреля 2017: ну всё, увы. SmartFile вслед за Дропбоксом ввели обязательный редирект на HTTPS, который нам противопоказан - см. внизу, почему. Придётся искать что-то другое.)

Частенько сталкиваюсь с типичной сисадминской задачкой: надо настроить CA и опубликовать в Интернете подписанный им список отозванных сертификатов (CRL). Это просто небольшой файлик, который надо сделать доступным по HTTP-линку, чтобы затем прописать этот линк в выдаваемых этим CA сертификатах. Любая сторона, желающая проверить, действителен ли ещё сертификат, найдёт в нём поле CDP, содержащее линк, скачает по нему файл, проверит на нём цифровую подпись и затем глянет, содержит ли CRL серийный номер сертификата in question, или нет.

Если есть открытый внешнему миру вебсервер, можно просто выложить CRL на нём. Но вот если его нет, то специально его заводить и пробивать дырку в файерволле ради такой скромной нужды не хочется абсолютно.

Взамен хочется такого:
  • Чтобы можно было выкладывать CRL на каком-то внешнем хостинге, автоматически заливать туда изменения - желательно через какой-нибудь простенький REST-сервис.
  • Чтобы был постоянный линк на опубликованный файл, чтобы он не требовал никакой авторизации или сложных редиректов.
  • Чтобы этот линк содержал наш собственный домен - и для понтов, и для гибкости: чтобы не переделывать массу сертификатов только потому, что сменился хостинг.
  • Чтобы линк был доступен по plain HTTP - не по HTTPS! Что сегодня уже, кстати, нетривиально. Необходимо это потому, что иначе может возникнуть логическая петля: некто хочет проверить сертификат, поэтому идёт скачивать CRL, но для этого нужно проверить сертификат HTTPS-сервера, но для этого нужно скачать CRL, и так далее. Чтобы не допускать этой дурной бесконечности, стандарт требует публиковать CRL по обычному HTTP - что безопасно, поскольку сам CRL защищён цифровой подписью и имеет ограниченное время жизни. Поэтому подсунуть фальшивый не выйдет.
  • Ну и наконец, чтобы всё это удовольствие было на халяву.
Я малость поныкался и нашёл хостинг, всем пунктам удолетворяющий - SmartFile.
Они предоставляют бесплатный девелоперовский аккаунт, позволяющий держать какие-то бешенные гиги - для нашей задачки это более чем излишне, нам-то всего несколько килобайтиков залить. В отличие от платного, этот аккаунт не даёт использовать удобный веб-интерфейс - но зато есть прекрасный REST API. Им и воспользуемся.

Для этого API есть имплементации клиентской части на разных языках, но поскольку я адски ленив, то решил пользоваться обычным cURL - в моём случае, сборкой под Windows, поддерживающей SSL.

Процесс таков:

1. Регистрируемся и заходим в качестве девелопера.

2. Жмём на кнопку "Get API Key" и получаем две длинные строки, которые будут использоваться для аутентификации наших REST-запросов:
ACCESS_KEY = SeKyFDgmdA1ikWdnAuvrWxLX1do4q4
PASSWORD   = eSDBw3WcZskDSzs2Wu3wy4pixe8uIt

SmartFile использует Basic Authentication - схему, которая сама по себе не предоставляет шифрования пароля, но поскольку сами запросы идут по HTTPS, то проблемы в этом нет.

3. Итак, первый запрос - создаём папочку на хостинге. Назовём её, к примеру, PKI:
curl --user %ACCESS_KEY%:%PASSWORD% -F "path=PKI" -X PUT https://app.smartfile.com/api/2/path/oper/mkdir

4. Заливаем в неё наш CRL-файл:
curl --user %ACCESS_KEY%:%PASSWORD% -X POST -F path="@MyOwnCoolCA.crl" https://app.smartfile.com/api/2/path/data/PKI/

5. Заодно там же разместим и сертификат самого CA - его можно прописать в поле AIA выпекаемых им сертификатах, чтобы проверяющие могли подгрузить всю цепочку:
curl --user %ACCESS_KEY%:%PASSWORD% -X POST -F path="@MyOwnCoolCA.crt" https://app.smartfile.com/api/2/path/data/PKI/

6. Файлы залиты, но просто так их ещё не скачаешь - потребуется авторизация. Чтобы сделать их доступными urbi et orbi, надобно создать линк на содержащую их папку PKI:
curl --user %ACCESS_KEY%:%PASSWORD% -X POST -d "name=PKI&path=/PKI&usage_limit=1000000000&read=on&list=on" https://app.smartfile.com/api/2/link/

Параметр "usage_limit" задаёт, сколько раз файл может быть скачан по линку. К сожалению, мне не удалось выяснить, можно ли этот параметр выставить в бесконечность. Но для нашего маленького скромного CRL миллиарда закачек вполне хватит.

В ответ мы получаем линк вида http://file.ac/g743sRnJfgS. На этом работа с API закончена.

7. Осталось лишь прописать в DNS хостнейм, который мы хотим видеть в наших линках. Пускай это будет crl.mydomain.com. Можно создать его как CNAME-алиас для file.ac, или как A-запись для IP-адреса 209.43.40.101 - это не особо существенно.

Всё. На этом этапе у нас есть два живых линка:
  • http://crl.mydomain.com/g743sRnJfgS/MyOwnCoolCA.crl - собственно CRL,
  • http://crl.mydomain.com/g743sRnJfgS/MyOwnCoolCA.crt - сертификат подписавшего его CA.
Их уже можно взять и прописать в конфигурации CA, чтобы они фигурировали в полях CDP и AIA всех сертификатов, этим CA выданных.


Осталось лишь наладить обновления. Можно, конечно, просто регулярно заливать CRL каждые полчаса-час, но это путь какой-то тупой. Хочется, чтобы он заливался лишь тогда, когда CA подписывает новый выпуск - допустим, раз в неделю.

Дальше идёт уже конфигурация под определённую платформу - поскольку я юзаю Microsoft ADCS, то и дальнейшие штучки заточены под Windows. На Линуксе можно придумать что-то своё.

Итак, я хочу, чтобы всякий раз, как файл MyOwnCoolCA.crl меняется, автоматом исполнялся REST-запрос, отправляющий его содержание на хостинг. К счастью, нашёлся скрипт PowerShell, позволяющий добиться этого минимальным расходом ресурсов. Скрипт подписывается на события, происходящие с выбранными файлами в определённой папке, и запускает внешнюю програмку, когда они происходят:

UploadToSmartFile.ps1:
$ACCESS_KEY = "SeKyFDgmdA1ikWdnAuvrWxLX1do4q4"
$PASSWORD   = "eSDBw3WcZskDSzs2Wu3wy4pixe8uIt"

### SET FOLDER TO WATCH + FILES TO WATCH + SUBFOLDERS YES/NO
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = "C:\crl.mydomain.com"
$watcher.Filter = "MyOwnCoolCA.crl"
$watcher.EnableRaisingEvents = $true  

### DEFINE ACTIONS AFTER A EVENT IS DETECTED
$action = 

curl.exe --user ${ACCESS_KEY}:${PASSWORD} -X POST -F path="@$Event.SourceEventArgs.FullPath" https://app.smartfile.com/api/2/path/data/PKI/
}
  
### DECIDE WHICH EVENTS SHOULD BE WATCHED + SET CHECK FREQUENCY  
$created = Register-ObjectEvent $watcher "Created" -Action $action
$changed = Register-ObjectEvent $watcher "Changed" -Action $action
$renamed = Register-ObjectEvent $watcher "Renamed" -Action $action
while ($true) {sleep 5}

(Update: "curl.exe" - это важно; как неожиданно выяснилось, добрые люди из Микрософта определили в новых версиях Пауэршелла команду "curl" как alias на Invoke-WebRequest, поломав мне нафиг скрипт.)

Итак, то что делает скрипт: висит и ждёт, пока в директории C:\crl.mydomain.com будет либо создан файл MyOwnCoolCA.crl, либо изменён, либо какой-нибудь другой файл получит это название. В этом случае скрипт отправляет его содержимое на хостинг.

(N.B.: сама директория, как и скрипт, совершенно не обязана находиться непосредственно на CA - это может быть любой комп. Я обычно предпочитаю располагать её в папке SYSVOL - это гарантирует очень быструю репликацию на все контроллеры в домене. Дело вкуса).

Осталось отконфигурачить автоматическое исполнение скрипта при загрузке компьютера. Можно запускать его как сервис при помощи srvany.exe, но я воспользовался Task Scheduler.

Итак, создаём новый task со следующими параметрами:
  • Triggers: at startup
  • User account: SYSTEM
  • Action: powershell -ExecutionPolicy Bypass -File C:\CA\Script\UploadToSmartFile.ps1
и отменяем остановку скрипта через определённый период времени.
(Bypass требуется, поскольку по дефолту исполнение скриптов PowerShell запрещено).

Теперь после перезагрузки скрипт запустится автоматом и будет ждать, пока CA не подпишет новый CRL - после чего тот разом окажется доступен для клиентов по линку, прописанному в сертификатах.

Всё. Mischief managed.