All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.commons.ssl.SSL Maven / Gradle / Ivy

/*
 * $HeadURL: http://juliusdavies.ca/svn/not-yet-commons-ssl/tags/commons-ssl-0.3.9/src/java/org/apache/commons/ssl/SSL.java $
 * $Revision: 121 $
 * $Date: 2007-11-14 05:26:57 +0000 (Wed, 14 Nov 2007) $
 *
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * .
 *
 */

package org.apache.commons.ssl;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.security.GeneralSecurityException;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import java.util.SortedSet;
import java.util.TreeSet;

/**
 * Not thread-safe.  (But who would ever share this thing across multiple
 * threads???)
 *
 * @author Credit Union Central of British Columbia
 * @author www.cucbc.com
 * @author [email protected]
 * @since May 1, 2006
 */
public class SSL {
    private final static String[] KNOWN_PROTOCOLS =
        {"TLSv1", "SSLv3", "SSLv2", "SSLv2Hello"};

    // SUPPORTED_CIPHERS_ARRAY is initialized in the static constructor.
    private final static String[] SUPPORTED_CIPHERS;

    public final static SortedSet KNOWN_PROTOCOLS_SET;
    public final static SortedSet SUPPORTED_CIPHERS_SET;

    // RC4
    public final static String SSL_RSA_WITH_RC4_128_SHA = "SSL_RSA_WITH_RC4_128_SHA";

    // 3DES
    public final static String SSL_RSA_WITH_3DES_EDE_CBC_SHA = "SSL_RSA_WITH_3DES_EDE_CBC_SHA";
    public final static String SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA = "SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA";
    public final static String SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA = "SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA";

    // AES-128
    public final static String TLS_RSA_WITH_AES_128_CBC_SHA = "TLS_RSA_WITH_AES_128_CBC_SHA";
    public final static String TLS_DHE_RSA_WITH_AES_128_CBC_SHA = "TLS_DHE_RSA_WITH_AES_128_CBC_SHA";
    public final static String TLS_DHE_DSS_WITH_AES_128_CBC_SHA = "TLS_DHE_DSS_WITH_AES_128_CBC_SHA";

    // AES-256
    public final static String TLS_RSA_WITH_AES_256_CBC_SHA = "TLS_RSA_WITH_AES_256_CBC_SHA";
    public final static String TLS_DHE_RSA_WITH_AES_256_CBC_SHA = "TLS_DHE_RSA_WITH_AES_256_CBC_SHA";
    public final static String TLS_DHE_DSS_WITH_AES_256_CBC_SHA = "TLS_DHE_DSS_WITH_AES_256_CBC_SHA";

    static {
        TreeSet ts = new TreeSet(Collections.reverseOrder());
        ts.addAll(Arrays.asList(KNOWN_PROTOCOLS));
        KNOWN_PROTOCOLS_SET = Collections.unmodifiableSortedSet(ts);

        // SSLSocketFactory.getDefault() sometimes blocks on FileInputStream
        // reads of "/dev/random" (Linux only?).  You might find you system
        // stuck here.  Move the mouse around a little!
        SSLSocketFactory s = (SSLSocketFactory) SSLSocketFactory.getDefault();
        ts = new TreeSet();
        SUPPORTED_CIPHERS = s.getSupportedCipherSuites();
        Arrays.sort(SUPPORTED_CIPHERS);
        ts.addAll(Arrays.asList(SUPPORTED_CIPHERS));
        SUPPORTED_CIPHERS_SET = Collections.unmodifiableSortedSet(ts);
    }

    private Object sslContext = null;
    private int initCount = 0;
    private SSLSocketFactory socketFactory = null;
    private SSLServerSocketFactory serverSocketFactory = null;
    private HostnameVerifier hostnameVerifier = HostnameVerifier.DEFAULT;
    private boolean checkHostname = true;
    private final ArrayList allowedNames = new ArrayList();
    private boolean checkCRL = true;
    private boolean checkExpiry = true;
    private boolean useClientMode = false;
    private boolean useClientModeDefault = true;
    private int soTimeout = 24 * 60 * 60 * 1000; // default: one day
    private int connectTimeout = 60 * 60 * 1000; // default: one hour
    private TrustChain trustChain = null;
    private KeyMaterial keyMaterial = null;
    private String[] enabledCiphers = null;
    private String[] enabledProtocols = null;
    private String defaultProtocol = "TLS";
    private X509Certificate[] currentServerChain;
    private X509Certificate[] currentClientChain;
    private boolean wantClientAuth = true;
    private boolean needClientAuth = false;
    private SSLWrapperFactory sslWrapperFactory = SSLWrapperFactory.NO_WRAP;

    protected final boolean usingSystemProperties;

    public SSL()
        throws GeneralSecurityException, IOException {
        boolean usingSysProps = false;
        Properties props = System.getProperties();
        boolean ksSet = props.containsKey("javax.net.ssl.keyStore");
        boolean tsSet = props.containsKey("javax.net.ssl.trustStore");
        if (ksSet) {
            String path = System.getProperty("javax.net.ssl.keyStore");
            String pwd = System.getProperty("javax.net.ssl.keyStorePassword");
            pwd = pwd != null ? pwd : ""; // JSSE default is "".
            File f = new File(path);
            if (f.exists()) {
                KeyMaterial km = new KeyMaterial(path, pwd.toCharArray());
                setKeyMaterial(km);
                usingSysProps = true;
            }
        }
        boolean trustMaterialSet = false;
        if (tsSet) {
            String path = System.getProperty("javax.net.ssl.trustStore");
            String pwd = System.getProperty("javax.net.ssl.trustStorePassword");
            boolean pwdWasNull = pwd == null;
            pwd = pwdWasNull ? "" : pwd; // JSSE default is "".
            File f = new File(path);
            if (f.exists()) {
                TrustMaterial tm;
                try {
                    tm = new TrustMaterial(path, pwd.toCharArray());
                }
                catch (GeneralSecurityException gse) {
                    // Probably a bad password.  If we're using the default password,
                    // let's try and survive this setback.
                    if (pwdWasNull) {
                        tm = new TrustMaterial(path);
                    } else {
                        throw gse;
                    }
                }

                setTrustMaterial(tm);
                usingSysProps = true;
                trustMaterialSet = true;
            }
        }

        /*
            No default trust material was set.  We'll use the JSSE standard way
            where we test for "JSSE_CACERTS" first, and then fall back on
            "CACERTS".  We could just leave TrustMaterial null, but then our
            setCheckCRL() and setCheckExpiry() features won't work.  We need a
            non-null TrustMaterial object in order to intercept and decorate
            the JVM's default TrustManager.
          */
        if (!trustMaterialSet) {
            setTrustMaterial(TrustMaterial.DEFAULT);
        }
        this.usingSystemProperties = usingSysProps;

        // By default we only use the strong ciphers (128 bit and higher).
        // Consumers can call "useDefaultJavaCiphers()" to get the 40 and 56 bit
        // ciphers back that Java normally has turned on.
        useStrongCiphers();
        dirtyAndReloadIfYoung();
    }

    private void dirty() {
        this.sslContext = null;
        this.socketFactory = null;
        this.serverSocketFactory = null;
    }

    private void dirtyAndReloadIfYoung()
        throws NoSuchAlgorithmException, KeyStoreException,
        KeyManagementException, IOException, CertificateException {
        dirty();
        if (initCount >= 0 && initCount <= 5) {
            // The first five init's we do early (before any sockets are
            // created) in the hope that will trigger any explosions nice
            // and early, with the correct exception type.

            // After the first five init's, we revert to a regular
            // dirty / init pattern, and the "init" happens very late:
            // just before the socket is created.  If badness happens, a
            // wrapping RuntimeException will be thrown.
            init();
        }
    }

    public SSLContext getSSLContext()
        throws GeneralSecurityException, IOException

    {
        Object obj = getSSLContextAsObject();
        if (JavaImpl.isJava13()) {
            try {
                return (SSLContext) obj;
            }
            catch (ClassCastException cce) {
                throw new ClassCastException("When using Java13 SSL, you must call SSL.getSSLContextAsObject() - " + cce);
            }
        }
        return (SSLContext) obj;
    }

    /**
     * @return com.sun.net.ssl.SSLContext or javax.net.ssl.SSLContext depending
     *         on the JSSE implementation we're using.
     * @throws GeneralSecurityException problem creating SSLContext
     * @throws IOException              problem creating SSLContext
     */
    public Object getSSLContextAsObject()
        throws GeneralSecurityException, IOException

    {
        if (sslContext == null) {
            init();
        }
        return sslContext;
    }

    public void addTrustMaterial(TrustChain trustChain)
        throws NoSuchAlgorithmException, KeyStoreException,
        KeyManagementException, IOException, CertificateException {
        if (this.trustChain == null || trustChain == TrustMaterial.TRUST_ALL) {
            this.trustChain = trustChain;
        } else {
            this.trustChain.addTrustMaterial(trustChain);
        }
        dirtyAndReloadIfYoung();
    }

    public void setTrustMaterial(TrustChain trustChain)
        throws NoSuchAlgorithmException, KeyStoreException,
        KeyManagementException, IOException, CertificateException {
        this.trustChain = trustChain;
        dirtyAndReloadIfYoung();
    }

    public void setKeyMaterial(KeyMaterial keyMaterial)
        throws NoSuchAlgorithmException, KeyStoreException,
        KeyManagementException, IOException, CertificateException {
        this.keyMaterial = keyMaterial;
        dirtyAndReloadIfYoung();
    }

    public X509Certificate[] getAssociatedCertificateChain() {
        if (keyMaterial != null) {
            return keyMaterial.getAssociatedCertificateChain();
        } else {
            return null;
        }
    }

    public String[] getEnabledCiphers() {
        return enabledCiphers != null ? enabledCiphers : getDefaultCipherSuites();
    }

    public void useDefaultJavaCiphers() {
        this.enabledCiphers = null;
    }

    public void useStrongCiphers() {
        LinkedList list = new LinkedList();
        addCipher(list, SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA, false);
        addCipher(list, SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA, false);
        addCipher(list, SSL_RSA_WITH_3DES_EDE_CBC_SHA, false);
        addCipher(list, SSL_RSA_WITH_RC4_128_SHA, false);
        addCipher(list, TLS_DHE_DSS_WITH_AES_128_CBC_SHA, false);
        addCipher(list, TLS_DHE_DSS_WITH_AES_256_CBC_SHA, false);
        addCipher(list, TLS_DHE_RSA_WITH_AES_128_CBC_SHA, false);
        addCipher(list, TLS_DHE_RSA_WITH_AES_256_CBC_SHA, false);
        addCipher(list, TLS_RSA_WITH_AES_128_CBC_SHA, false);
        addCipher(list, TLS_RSA_WITH_AES_256_CBC_SHA, false);
        String[] strongCiphers = new String[list.size()];
        list.toArray(strongCiphers);
        String[] currentCiphers = getEnabledCiphers();
        // Current ciphers must be default or something.  Odd that it's null,
        // though.
        if (currentCiphers == null) {
            setEnabledCiphers(strongCiphers);
        }

        Arrays.sort(strongCiphers);
        Arrays.sort(currentCiphers);
        // Let's only call "setEnabledCiphers" if our array is actually different
        // than what's already set.
        if (!Arrays.equals(strongCiphers, currentCiphers)) {
            setEnabledCiphers(strongCiphers);
        }
    }

    public void setEnabledCiphers(String[] ciphers) {
        HashSet desired = new HashSet(Arrays.asList(ciphers));
        desired.removeAll(SUPPORTED_CIPHERS_SET);
        if (!desired.isEmpty()) {
            throw new IllegalArgumentException("following ciphers not supported: " + desired);
        }
        this.enabledCiphers = ciphers;
    }

    public String[] getEnabledProtocols() {
        return enabledProtocols != null ? enabledProtocols : KNOWN_PROTOCOLS;
    }

    public void setEnabledProtocols(String[] protocols) {
        HashSet desired = new HashSet(Arrays.asList(protocols));
        desired.removeAll(KNOWN_PROTOCOLS_SET);
        if (!desired.isEmpty()) {
            throw new IllegalArgumentException("following protocols not supported: " + desired);
        }
        this.enabledProtocols = protocols;
    }

    public String getDefaultProtocol() {
        return defaultProtocol;
    }

    public void setDefaultProtocol(String protocol) {
        this.defaultProtocol = protocol;
        dirty();
    }

    public boolean getCheckHostname() {
        return checkHostname;
    }

    /**
     * @return String[] array of alternate "allowed names" to try against a
     *         server's x509 CN field if the host/ip we used didn't match.
     *         Returns an empty list if there are no "allowedNames" currently
     *         set.
     */
    public List getAllowedNames() {
        return Collections.unmodifiableList(allowedNames);
    }

    /**
     * Offers a secure way to use virtual-hosting and SSL in some situations:
     * for example you want to connect to "bar.com" but you know in advance
     * that the SSL Certificate on that server only contains "CN=foo.com".  If
     * you setAllowedNames( new String[] { "foo.com" } ) on your SSLClient in
     * advance, you can connect securely, while still using "bar.com" as the
     * host.
     * 

* Here's a code example using "cucbc.com" to connect, but anticipating * "www.cucbc.com" in the server's certificate: *

     * SSLClient client = new SSLClient();
     * client.setAllowedNames( new String[] { "www.cucbc.com" } );
     * Socket s = client.createSocket( "cucbc.com", 443 );
     * 
*

* This technique is also useful if you don't want to use DNS, and want to * connect using the IP address. * * @param allowedNames Collection of alternate "allowed names" to try against * a server's x509 CN field if the host/ip we used didn't * match. Set to null to force strict matching against * host/ip passed into createSocket(). Null is the * default value. Must be set in advance, before * createSocket() is called. */ public void addAllowedNames(Collection allowedNames) { this.allowedNames.addAll(allowedNames); } public void addAllowedName(String allowedName) { this.allowedNames.add(allowedName); } public void clearAllowedNames() { this.allowedNames.clear(); } public void setCheckHostname(boolean checkHostname) { this.checkHostname = checkHostname; } public void setHostnameVerifier(HostnameVerifier verifier) { if (verifier == null) { verifier = HostnameVerifier.DEFAULT; } this.hostnameVerifier = verifier; } public HostnameVerifier getHostnameVerifier() { return hostnameVerifier; } public boolean getCheckCRL() { return checkCRL; } public void setCheckCRL(boolean checkCRL) { this.checkCRL = checkCRL; } public boolean getCheckExpiry() { return checkExpiry; } public void setCheckExpiry(boolean checkExpiry) { this.checkExpiry = checkExpiry; } public void setSoTimeout(int soTimeout) { if (soTimeout < 0) { throw new IllegalArgumentException("soTimeout must not be negative"); } this.soTimeout = soTimeout; } public int getSoTimeout() { return soTimeout; } public void setConnectTimeout(int connectTimeout) { if (connectTimeout < 0) { throw new IllegalArgumentException("connectTimeout must not be negative"); } this.connectTimeout = connectTimeout; } public void setUseClientMode(boolean useClientMode) { this.useClientModeDefault = false; this.useClientMode = useClientMode; } public boolean getUseClientModeDefault() { return useClientModeDefault; } public boolean getUseClientMode() { return useClientMode; } public void setWantClientAuth(boolean wantClientAuth) { this.wantClientAuth = wantClientAuth; } public void setNeedClientAuth(boolean needClientAuth) { this.needClientAuth = needClientAuth; } public boolean getWantClientAuth() { return wantClientAuth; } public boolean getNeedClientAuth() { return needClientAuth; } public SSLWrapperFactory getSSLWrapperFactory() { return this.sslWrapperFactory; } public void setSSLWrapperFactory(SSLWrapperFactory wf) { this.sslWrapperFactory = wf; } private void initThrowRuntime() { try { init(); } catch (GeneralSecurityException gse) { throw JavaImpl.newRuntimeException(gse); } catch (IOException ioe) { throw JavaImpl.newRuntimeException(ioe); } } private void init() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException, CertificateException { socketFactory = null; serverSocketFactory = null; this.sslContext = JavaImpl.init(this, trustChain, keyMaterial); initCount++; } public void doPreConnectSocketStuff(SSLSocket s) throws IOException { if (!useClientModeDefault) { s.setUseClientMode(useClientMode); } if (soTimeout > 0) { s.setSoTimeout(soTimeout); } if (enabledProtocols != null) { JavaImpl.setEnabledProtocols(s, enabledProtocols); } if (enabledCiphers != null) { s.setEnabledCipherSuites(enabledCiphers); } } public void doPostConnectSocketStuff(SSLSocket s, String host) throws IOException { if (checkHostname) { int size = allowedNames.size() + 1; String[] hosts = new String[size]; // hosts[ 0 ] MUST ALWAYS be the host given to createSocket(). hosts[0] = host; int i = 1; for (Iterator it = allowedNames.iterator(); it.hasNext(); i++) { hosts[i] = (String) it.next(); } hostnameVerifier.check(hosts, s); } } public SSLSocket createSocket() throws IOException { return sslWrapperFactory.wrap(JavaImpl.createSocket(this)); } /** * Attempts to get a new socket connection to the given host within the * given time limit. * * @param remoteHost the host name/IP * @param remotePort the port on the host * @param localHost the local host name/IP to bind the socket to * @param localPort the port on the local machine * @param timeout the connection timeout (0==infinite) * @return Socket a new socket * @throws IOException if an I/O error occurs while creating the socket * @throws UnknownHostException if the IP address of the host cannot be * determined */ public Socket createSocket(String remoteHost, int remotePort, InetAddress localHost, int localPort, int timeout) throws IOException { // Only use our factory-wide connectTimeout if this method was passed // in a timeout of 0 (infinite). int factoryTimeout = getConnectTimeout(); int connectTimeout = timeout == 0 ? factoryTimeout : timeout; SSLSocket s = JavaImpl.createSocket(this, remoteHost, remotePort, localHost, localPort, connectTimeout); return sslWrapperFactory.wrap(s); } public Socket createSocket(Socket s, String remoteHost, int remotePort, boolean autoClose) throws IOException { SSLSocketFactory sf = getSSLSocketFactory(); s = sf.createSocket(s, remoteHost, remotePort, autoClose); doPreConnectSocketStuff((SSLSocket) s); doPostConnectSocketStuff((SSLSocket) s, remoteHost); return sslWrapperFactory.wrap((SSLSocket) s); } public ServerSocket createServerSocket() throws IOException { SSLServerSocket ss = JavaImpl.createServerSocket(this); return getSSLWrapperFactory().wrap(ss, this); } /** * Attempts to get a new socket connection to the given host within the * given time limit. * * @param localHost the local host name/IP to bind against (null == ANY) * @param port the port to listen on * @param backlog number of connections allowed to queue up for accept(). * @return SSLServerSocket a new server socket * @throws IOException if an I/O error occurs while creating thesocket */ public ServerSocket createServerSocket(int port, int backlog, InetAddress localHost) throws IOException { SSLServerSocketFactory f = getSSLServerSocketFactory(); ServerSocket ss = f.createServerSocket(port, backlog, localHost); SSLServerSocket s = (SSLServerSocket) ss; doPreConnectServerSocketStuff(s); return getSSLWrapperFactory().wrap(s, this); } public void doPreConnectServerSocketStuff(SSLServerSocket s) throws IOException { if (soTimeout > 0) { s.setSoTimeout(soTimeout); } if (enabledProtocols != null) { JavaImpl.setEnabledProtocols(s, enabledProtocols); } if (enabledCiphers != null) { s.setEnabledCipherSuites(enabledCiphers); } /* setNeedClientAuth( false ) has an annoying side effect: it seems to reset setWantClient( true ) back to to false. So I do things this way to make sure setting things "true" happens after setting things "false" - giving "true" priority. */ if (!wantClientAuth) { JavaImpl.setWantClientAuth(s, wantClientAuth); } if (!needClientAuth) { s.setNeedClientAuth(needClientAuth); } if (wantClientAuth) { JavaImpl.setWantClientAuth(s, wantClientAuth); } if (needClientAuth) { s.setNeedClientAuth(needClientAuth); } } public SSLSocketFactory getSSLSocketFactory() { if (sslContext == null) { initThrowRuntime(); } if (socketFactory == null) { socketFactory = JavaImpl.getSSLSocketFactory(sslContext); } return socketFactory; } public SSLServerSocketFactory getSSLServerSocketFactory() { if (sslContext == null) { initThrowRuntime(); } if (serverSocketFactory == null) { serverSocketFactory = JavaImpl.getSSLServerSocketFactory(sslContext); } return serverSocketFactory; } public int getConnectTimeout() { return connectTimeout; } public String[] getDefaultCipherSuites() { return getSSLSocketFactory().getDefaultCipherSuites(); } public String[] getSupportedCipherSuites() { String[] s = new String[SUPPORTED_CIPHERS.length]; System.arraycopy(SUPPORTED_CIPHERS, 0, s, 0, s.length); return s; } public TrustChain getTrustChain() { return trustChain; } public void setCurrentServerChain(X509Certificate[] chain) { this.currentServerChain = chain; } public void setCurrentClientChain(X509Certificate[] chain) { this.currentClientChain = chain; } public X509Certificate[] getCurrentServerChain() { return currentServerChain; } public X509Certificate[] getCurrentClientChain() { return currentClientChain; } public static void main(String[] args) { for (int i = 0; i < SUPPORTED_CIPHERS.length; i++) { System.out.println(SUPPORTED_CIPHERS[i]); } System.out.println(); System.out.println("----------------------------------------------"); addCipher(null, SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA, true); addCipher(null, SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA, true); addCipher(null, SSL_RSA_WITH_3DES_EDE_CBC_SHA, true); addCipher(null, SSL_RSA_WITH_RC4_128_SHA, true); addCipher(null, TLS_DHE_DSS_WITH_AES_128_CBC_SHA, true); addCipher(null, TLS_DHE_DSS_WITH_AES_256_CBC_SHA, true); addCipher(null, TLS_DHE_RSA_WITH_AES_128_CBC_SHA, true); addCipher(null, TLS_DHE_RSA_WITH_AES_256_CBC_SHA, true); addCipher(null, TLS_RSA_WITH_AES_128_CBC_SHA, true); addCipher(null, TLS_RSA_WITH_AES_256_CBC_SHA, true); } private static void addCipher(List l, String c, boolean printOnStandardOut) { boolean supported = false; if (c != null && SUPPORTED_CIPHERS_SET.contains(c)) { if (l != null) { l.add(c); } supported = true; } if (printOnStandardOut) { System.out.println(c + ":\t" + supported); } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy