directory-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From erodrig...@apache.org
Subject svn commit: r537553 - in /directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi: HandleGssapi.java SaslGssapiFilter.java
Date Sun, 13 May 2007 04:59:17 GMT
Author: erodriguez
Date: Sat May 12 21:59:16 2007
New Revision: 537553

URL: http://svn.apache.org/viewvc?view=rev&rev=537553
Log:
Added an implementation of a GSSAPI-only SASL Bind handler.  This was written using JGSS prior
to the current SASL Bind handler, which uses the JDK 1.5 SaslServer to also support DIGEST-MD5
and CRAM-MD5, in addition to GSSAPI.

Added:
    directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/HandleGssapi.java
  (with props)
    directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/SaslGssapiFilter.java
  (with props)

Added: directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/HandleGssapi.java
URL: http://svn.apache.org/viewvc/directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/HandleGssapi.java?view=auto&rev=537553
==============================================================================
--- directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/HandleGssapi.java
(added)
+++ directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/HandleGssapi.java
Sat May 12 21:59:16 2007
@@ -0,0 +1,386 @@
+/*
+ *  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. 
+ *  
+ */
+package org.apache.directory.server.saslgssapi;
+
+
+import java.security.Principal;
+import java.security.PrivilegedAction;
+
+import javax.naming.Context;
+import javax.security.auth.Subject;
+import javax.security.auth.kerberos.KerberosKey;
+import javax.security.auth.kerberos.KerberosPrincipal;
+
+import org.apache.directory.shared.ldap.message.BindRequest;
+import org.apache.directory.shared.ldap.message.BindResponse;
+import org.apache.directory.shared.ldap.message.LdapResult;
+import org.apache.directory.shared.ldap.message.ResultCodeEnum;
+import org.apache.mina.common.ByteBuffer;
+import org.apache.mina.common.IoSession;
+import org.apache.mina.handler.chain.IoHandlerCommand;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSCredential;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.MessageProp;
+import org.ietf.jgss.Oid;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
+ * @version $Rev$, $Date$
+ */
+public class HandleGssapi implements IoHandlerCommand
+{
+    private static final Logger log = LoggerFactory.getLogger( HandleGssapi.class );
+
+    private static final String SASL_GSSAPI_CONTEXT = "sasl gssapi context";
+    private static final String SASL_GSSAPI_STATE = "sasl gssapi state";
+
+    // Server expects simple or SASL credentials, may return continuation-token.
+    // SASL:  token --> counter token
+    private static final String BIND_STATE_SIMPLE = "simple";
+
+    // Server expects empty client ack, will return negotiation-token.
+    // SASL:  client ACK --> nego token
+    private static final String BIND_STATE_WAIT_1 = "waiting 1";
+
+    // Server expects counter-negotition-token, should return SUCCESS if OK.
+    // SASL:  nego token --> SUCCESS
+    private static final String BIND_STATE_WAIT_2 = "waiting 2";
+
+    // Server has bound, specifically the bind requires QoP processing on all messages (similar
to SSL).
+    private static final String BIND_STATE_GSSAPI_BOUND = "gssapi bound";
+
+    private GSSCredential serviceCredential;
+
+
+    /**
+     * Creates a new instance of HandleGssapi.
+     */
+    public HandleGssapi()
+    {
+        getSubject();
+
+        log.debug( "Starting GSSAPI handler with credential " + serviceCredential.toString()
);
+    }
+
+
+    private void getSubject()
+    {
+        KerberosPrincipal servicePrincipal = new KerberosPrincipal( "ldap/ldap.example.com@EXAMPLE.COM"
);
+        char[] password = new String( "randall" ).toCharArray();
+        KerberosKey serviceKey = new KerberosKey( servicePrincipal, password, "DES" );
+        Subject subject = new Subject();
+        subject.getPrivateCredentials().add( serviceKey );
+
+        serviceCredential = ( GSSCredential ) Subject.doAs( subject, new PrivilegedAction()
+        {
+            public Object run()
+            {
+                return getGssCredential();
+            }
+        } );
+    }
+
+
+    private GSSCredential getGssCredential()
+    {
+        GSSCredential serverCredential = null;
+
+        try
+        {
+            GSSManager manager = GSSManager.getInstance();
+            Oid krb5Mechanism = new Oid( "1.2.840.113554.1.2.2" );
+            Oid krb5PrincipalNameType = new Oid( "1.2.840.113554.1.2.2.1" );
+            GSSName serverName = manager.createName( "ldap/ldap.example.com@EXAMPLE.COM",
krb5PrincipalNameType );
+            serverCredential = manager.createCredential( serverName, GSSCredential.DEFAULT_LIFETIME,
krb5Mechanism,
+                GSSCredential.ACCEPT_ONLY );
+            // context.requestMutualAuth(true);
+            // context.requestCredDeleg(true);
+        }
+        catch ( GSSException gsse )
+        {
+            log.error( "GSSException:       " + gsse.getMessage() );
+            log.debug( "GSSException major: " + gsse.getMajorString() );
+            log.debug( "GSSException minor: " + gsse.getMinorString() );
+        }
+
+        return serverCredential;
+    }
+
+
+    public void execute( NextCommand next, IoSession session, Object message ) throws Exception
+    {
+        BindRequest request = ( BindRequest ) message;
+
+        if ( !session.containsAttribute( SASL_GSSAPI_STATE ) )
+        {
+            session.setAttribute( SASL_GSSAPI_STATE, BIND_STATE_SIMPLE );
+        }
+
+        if ( request.getSaslMechanism() != null && request.getSaslMechanism().equals(
"GSSAPI" ) )
+        {
+            handleGssapi( next, session, message );
+        }
+        else
+        {
+            next.execute( session, message );
+        }
+    }
+
+
+    private void handleGssapi( NextCommand next, IoSession session, Object message ) throws
Exception
+    {
+        String gssapiState = ( String ) session.getAttribute( SASL_GSSAPI_STATE );
+
+        BindRequest request = ( BindRequest ) message;
+        LdapResult result = request.getResultResponse().getLdapResult();
+
+        /**
+         * The server attempts to establish a security context.  Establishment may result
in
+         * tokens that the server must return to the client.
+         */
+        GSSContext context;
+        byte tokenBytes[] = null;
+
+        try
+        {
+            byte[] gssapiData = request.getCredentials();
+
+            if ( session.containsAttribute( SASL_GSSAPI_CONTEXT ) )
+            {
+                context = ( GSSContext ) session.getAttribute( SASL_GSSAPI_CONTEXT );
+            }
+            else
+            {
+                GSSManager manager = GSSManager.getInstance();
+                context = manager.createContext( serviceCredential );
+                tokenBytes = context.acceptSecContext( gssapiData, 0, gssapiData.length );
+            }
+
+            if ( context != null && !context.isEstablished() && gssapiState.equals(
BIND_STATE_SIMPLE ) )
+            {
+                /**
+                 * If a GSSAPI token remains, it must be returned to
+                 * the client for the final leg of an authentication.
+                 */
+                if ( tokenBytes != null && tokenBytes.length > 0 )
+                {
+                    log.info( "Final leg GSSAPI token had length " + tokenBytes.length );
+                    result.setResultCode( ResultCodeEnum.SASL_BIND_IN_PROGRESS );
+                    BindResponse resp = ( BindResponse ) request.getResultResponse();
+                    resp.setServerSaslCreds( tokenBytes );
+                    session.write( resp );
+                    log.debug( "Returning final authentication data to client to complete
context." );
+                    return;
+                }
+            }
+
+            /**
+             * If the context is established, we can attempt to retrieve the name of the
"context
+             * initiator."  In the case of the Kerberos mechanism, the context initiator
is the
+             * Kerberos principal of the client.  Additionally, the client may be delegating
+             * credentials.
+             */
+            if ( context != null && context.isEstablished() && gssapiState.equals(
BIND_STATE_SIMPLE ) )
+            {
+                log.debug( "Context established, attempting Kerberos principal retrieval."
);
+                session.setAttribute( SASL_GSSAPI_CONTEXT, context );
+
+                Subject subject = new Subject();
+                GSSName clientGSSName = context.getSrcName();
+
+                /*
+                 * This is the principal name that will be used to bind to the DIT.
+                 * Note that this principal has not yet completed binding.
+                 */
+                session.setAttribute( Context.SECURITY_PRINCIPAL, clientGSSName.toString()
);
+
+                Principal clientPrincipal = new KerberosPrincipal( clientGSSName.toString()
);
+                subject.getPrincipals().add( clientPrincipal );
+                log.info( "Got client Kerberos principal: " + clientGSSName );
+
+                if ( context.getCredDelegState() )
+                {
+                    GSSCredential delegateCredential = context.getDelegCred();
+                    GSSName delegateGSSName = delegateCredential.getName();
+                    Principal delegatePrincipal = new KerberosPrincipal( delegateGSSName.toString()
);
+                    subject.getPrincipals().add( delegatePrincipal );
+                    subject.getPrivateCredentials().add( delegateCredential );
+                    log.info( "Got delegated Kerberos principal: " + delegateGSSName );
+                }
+
+                session.setAttribute( SASL_GSSAPI_STATE, BIND_STATE_WAIT_1 );
+
+                /**
+                 * If a GSSAPI token remains, it must be returned to
+                 * the client for the final leg of an authentication.
+                 */
+                if ( tokenBytes != null && tokenBytes.length > 0 )
+                {
+                    log.info( "Final leg GSSAPI token had length " + tokenBytes.length );
+                    result.setResultCode( ResultCodeEnum.SASL_BIND_IN_PROGRESS );
+                    BindResponse resp = ( BindResponse ) request.getResultResponse();
+                    resp.setServerSaslCreds( tokenBytes );
+                    session.write( resp );
+                    log.debug( "Returning final authentication data to client to complete
context." );
+                    return;
+                }
+            }
+
+            if ( context != null && context.isEstablished() && gssapiState.equals(
BIND_STATE_WAIT_1 ) )
+            {
+                /*
+                 * ... the server then constructs 4 octets of data, with
+                 * the first octet containing a bit-mask specifying the
+                 * security layers supported by the server and the second
+                 * through fourth octets containing in network byte order
+                 * the maximum size output_token the server is able to receive.
+                 */
+                byte[] bitMaskAndLength =
+                    { ( byte ) 0x06, ( byte ) 0x00, ( byte ) 0x00, ( byte ) 0xFF };
+
+                /*
+                 * The first MessageProp argument is 0 to request the default Quality-of-Protection.
+                 * The second argument is true to request privacy (encryption of the message).
+                 * 
+                 * The server must then pass the plaintext to GSS_Wrap with conf_flag set
to FALSE.
+                 */
+                MessageProp prop = new MessageProp( 0, false );
+                byte[] token = context.wrap( bitMaskAndLength, 0, bitMaskAndLength.length,
prop );
+
+                // Issue the generated output_message to the client in a challenge.
+                result.setResultCode( ResultCodeEnum.SASL_BIND_IN_PROGRESS );
+                BindResponse resp = ( BindResponse ) request.getResultResponse();
+                resp.setServerSaslCreds( token );
+                session.write( resp );
+                log.debug( "Returned QoP bitmask and requested maximum message length." );
+
+                session.setAttribute( SASL_GSSAPI_STATE, BIND_STATE_WAIT_2 );
+
+                return;
+            }
+
+            if ( context != null && context.isEstablished() && gssapiState.equals(
BIND_STATE_WAIT_2 ) )
+            {
+                log.info( "Negotiated token had length " + request.getCredentials().length
);
+
+                /*
+                 * The first MessageProp argument is 0 to request the default Quality-of-Protection.
+                 * The second argument is true to request privacy (encryption of the message).
+                 */
+                MessageProp prop = new MessageProp( 0, false );
+
+                /*
+                 * The server must then pass the resulting response to GSS_Unwrap and
+                 * interpret the first octet of resulting cleartext as the bit-mask for
+                 * the selected security layer, the second through fourth octets as the
+                 * maximum size output_message to send to the client, and the remaining
+                 * octets as the authorization identity.
+                 */
+                byte[] token = context.unwrap( request.getCredentials(), 0, request.getCredentials().length,
prop );
+
+                log.debug( "Unwrapped token length is " + token.length );
+
+                /*
+                 * The security layers and their corresponding bit-masks are as follows:
+                 * 1 No security layer.
+                 * 2 Integrity protection.  Sender calls GSS_Wrap with conf_flag set to FALSE.
+                 * 4 Privacy protection.  Sender calls GSS_Wrap with conf_flag set to TRUE.
+                 */
+                // 1st octet is bit-mask of QoP.
+                ByteBuffer bitMaskAndLength = ByteBuffer.wrap( token );
+                int bitMask = bitMaskAndLength.get();
+                log.debug( "1st octet bitmask is QoP as int " + bitMask );
+
+                /*
+                 * Note that SASL negotiates the maximum size of the output_message to send.
+                 * Implementations can use the GSS_Wrap_size_limit call to determine the
+                 * corresponding maximum size input_message.
+                 */
+                // Bytes 2-4 are requested maximum message size.
+                byte b[] = new byte[3];
+                bitMaskAndLength.get( b );
+                int length = ( b[0] & 0xff ) << 16 | ( b[1] & 0xff ) <<
8 | ( b[2] & 0xff );
+                log.debug( "Bytes 2-4 are requested maximum message length " + length );
+
+                log.debug( "QoP bitmask and requested maximum message length negotiation
complete." );
+
+                /*
+                 * The server must verify that the src_name is authorized to
+                 * authenticate as the authorization identity.  After these
+                 * verifications, the authentication process is complete.
+                 */
+
+                /**
+                 * TODO - The SimpleAuthenticator is expecting at least an empty String for
+                 * anonymous authentication credentials.  Once an Authenticator is available
+                 * for SASL/GSSAPI, we can revisit what it needs.
+                 * 
+                 * The Context.SECURITY_PRINCIPAL and Context.SECURITY_CREDENTIALS will be
+                 * used to get an LdapContext and truly bind to the DIT.
+                 */
+                session.setAttribute( Context.SECURITY_CREDENTIALS, "" );
+
+                /**
+                 * If we got here, we're ready to try getting an initial LDAP context.
+                 */
+                session.setAttribute( SASL_GSSAPI_STATE, BIND_STATE_GSSAPI_BOUND );
+                next.execute( session, message );
+            }
+        }
+        catch ( GSSException gsse )
+        {
+            log.error( "GSSException:       " + gsse.getMessage() );
+            log.debug( "GSSException major: " + gsse.getMajorString() );
+            log.debug( "GSSException minor: " + gsse.getMinorString() );
+
+            /*
+             * A bad key for the server will result in:
+             * Mechanism level: Integrity check on decrypted field failed (31)
+             */
+            result.setResultCode( ResultCodeEnum.INVALID_CREDENTIALS );
+            result.setErrorMessage( gsse.getMinorString() );
+            session.write( request.getResultResponse() );
+            return;
+        }
+        /*
+         catch (GSSException gsse) {
+         LOG.fatal(gsse.getMessage());
+
+         if( gsse.getMajor() == GSSException.DEFECTIVE_CREDENTIAL
+         || gsse.getMajor() == GSSException.CREDENTIALS_EXPIRED )
+         throw new InvalidCredentialsException(gsse.getMessage(),gsse);
+         if( gsse.getMajor() == GSSException.NO_CRED )
+         throw new CredentialsNotAvailableException(gsse.getMessage(),gsse);
+         if( gsse.getMajor() == GSSException.DEFECTIVE_TOKEN
+         || gsse.getMajor() == GSSException.DUPLICATE_TOKEN
+         || gsse.getMajor() == GSSException.OLD_TOKEN )
+         throw new AuthChallengeException(gsse.getMessage(),gsse);
+
+         throw new AuthenticationException(gsse.getMessage());
+         }
+         */
+    }
+}

Propchange: directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/HandleGssapi.java
------------------------------------------------------------------------------
    svn:eol-style = native

Added: directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/SaslGssapiFilter.java
URL: http://svn.apache.org/viewvc/directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/SaslGssapiFilter.java?view=auto&rev=537553
==============================================================================
--- directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/SaslGssapiFilter.java
(added)
+++ directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/SaslGssapiFilter.java
Sat May 12 21:59:16 2007
@@ -0,0 +1,167 @@
+/*
+ *  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. 
+ *  
+ */
+package org.apache.directory.server.saslgssapi;
+
+
+import org.apache.mina.common.ByteBuffer;
+import org.apache.mina.common.IoFilterAdapter;
+import org.apache.mina.common.IoSession;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.MessageProp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * An {@link IoFilterAdapter} that handles privacy and confidentiality protections
+ * for a SASL GSSAPI bound session.
+ * 
+ * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
+ * @version $Rev$, $Date$
+ */
+public class SaslGssapiFilter extends IoFilterAdapter
+{
+    private static final Logger log = LoggerFactory.getLogger( SaslGssapiFilter.class.getName()
);
+
+    private static final String SASL_GSSAPI_CONTEXT = "sasl gssapi context";
+    private static final String SASL_GSSAPI_USE_SASL = "sasl gssapi use sasl";
+
+    /*
+     * The first MessageProp argument is 0 to request the default Quality-of-Protection.
+     * The second argument is false to not request privacy (encryption of the message).
+     */
+    private static MessageProp REQUEST_PRIVACY = new MessageProp( false );
+
+    /*
+     * The first MessageProp argument is 0 to request the default Quality-of-Protection.
+     * The second argument is true to request privacy (encryption of the message).
+     */
+    private static MessageProp REQUEST_CONFIDENTIALITY = new MessageProp( true );
+
+
+    public void messageReceived( NextFilter nextFilter, IoSession session, Object message
) throws GSSException
+    {
+        log.debug( "Message received:  " + message );
+
+        /*
+         * Guard clause:  check if in SASL GSSAPI bound mode.
+         */
+        Boolean useSasl = ( Boolean ) session.getAttribute( SASL_GSSAPI_USE_SASL );
+
+        if ( useSasl == null || !useSasl.booleanValue() )
+        {
+            log.debug( "Will not use SASL on received message." );
+            nextFilter.messageReceived( session, message );
+            return;
+        }
+
+        GSSContext context = getGssContext( session );
+
+        /*
+         * Get the buffer as bytes.  First 4 bytes are length as int.
+         */
+        ByteBuffer buf = ( ByteBuffer ) message;
+        int bufferLength = buf.getInt();
+        byte[] bufferBytes = new byte[bufferLength];
+        buf.get( bufferBytes );
+
+        log.debug( "Will use SASL on received message of length:  " + bufferLength );
+
+        /*
+         * Unwrap the data.
+         */
+        byte[] token = context.unwrap( bufferBytes, 0, bufferBytes.length, REQUEST_PRIVACY
);
+
+        nextFilter.messageReceived( session, ByteBuffer.wrap( token ) );
+    }
+
+
+    public void filterWrite( NextFilter nextFilter, IoSession session, WriteRequest writeRequest
) throws GSSException
+    {
+        log.debug( "Filtering write request:  " + writeRequest );
+
+        /*
+         * Guard clause:  check if in SASL GSSAPI bound mode.
+         */
+        Boolean useSasl = ( Boolean ) session.getAttribute( SASL_GSSAPI_USE_SASL );
+
+        if ( useSasl == null || !useSasl.booleanValue() )
+        {
+            log.debug( "Will not use SASL on write request." );
+            nextFilter.filterWrite( session, writeRequest );
+            return;
+        }
+
+        GSSContext context = getGssContext( session );
+
+        /*
+         * Get the buffer as bytes.
+         */
+        ByteBuffer buf = ( ByteBuffer ) writeRequest.getMessage();
+        int bufferLength = buf.remaining();
+        byte[] bufferBytes = new byte[bufferLength];
+        buf.get( bufferBytes );
+
+        log.debug( "Will use SASL on to filter message of length:  " + bufferLength );
+
+        /*
+         * Wrap the data.
+         */
+        byte[] token = context.wrap( bufferBytes, 0, bufferBytes.length, REQUEST_CONFIDENTIALITY
);
+
+        /*
+         * Prepend 4 byte length.
+         */
+        ByteBuffer encryptedBuffer = ByteBuffer.allocate( 4 + token.length );
+        encryptedBuffer.putInt( token.length );
+        encryptedBuffer.put( token );
+        encryptedBuffer.position( 0 );
+        encryptedBuffer.limit( 4 + token.length );
+
+        log.debug( "Sending encrypted token of length " + token.length );
+
+        nextFilter.filterWrite( session, new WriteRequest( encryptedBuffer, writeRequest.getFuture()
) );
+    }
+
+
+    /**
+     * Helper method to get the {@link GSSContext} and perform basic checks.
+     *  
+     * @param session The {@link IoSession}
+     * @return {@link GSSContext} The {@link GSSContext} stored in the session by the {@link
BindHandler}.
+     */
+    private GSSContext getGssContext( IoSession session )
+    {
+        GSSContext context = null;
+
+        if ( session.containsAttribute( SASL_GSSAPI_CONTEXT ) )
+        {
+            context = ( GSSContext ) session.getAttribute( SASL_GSSAPI_CONTEXT );
+        }
+
+        if ( context == null )
+        {
+            throw new IllegalStateException();
+        }
+
+        return context;
+    }
+}

Propchange: directory/sandbox/erodriguez/sasl-gssapi/src/main/java/org/apache/directory/server/saslgssapi/SaslGssapiFilter.java
------------------------------------------------------------------------------
    svn:eol-style = native



Mime
View raw message