zeppelin-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From m...@apache.org
Subject zeppelin git commit: [ZEPPELIN-2952] encrypt credentials.json with AES
Date Thu, 05 Oct 2017 22:00:55 GMT
Repository: zeppelin
Updated Branches:
  refs/heads/master b05a1b699 -> 540dd185d


[ZEPPELIN-2952] encrypt credentials.json with AES

### What is this PR for?
Support encrypting passwords using a private key

### What type of PR is it?
Improvement

### What is the Jira issue?
https://issues.apache.org/jira/browse/ZEPPELIN-2952

### How should this be tested?
- Set the env variable `ZEPPELIN_CREDENTIALS_ENCRYPT_KEY=something`
- Save a few credentials
- Check that the `credentials.json` file is storing encrypted passwords
- Restart server using the same env variable for `ZEPPELIN_CREDENTIALS_ENCRYPT_KEY`
- The credentials should still be decryptable

### Questions:
* Does the licenses files need update?
No

* Is there breaking changes for older versions?
No

* Does this needs documentation?
Yes

Author: Herval Freire <hfreire@twitter.com>

Closes #2599 from herval/encrypt-credentials and squashes the following commits:

e5857d8 [Herval Freire] missing license
1d4bc04 [Herval Freire] documentation....?
82ae8f4 [Herval Freire] added license
c3e0ead [Herval Freire] encrypt credentials.json with AES


Project: http://git-wip-us.apache.org/repos/asf/zeppelin/repo
Commit: http://git-wip-us.apache.org/repos/asf/zeppelin/commit/540dd185
Tree: http://git-wip-us.apache.org/repos/asf/zeppelin/tree/540dd185
Diff: http://git-wip-us.apache.org/repos/asf/zeppelin/diff/540dd185

Branch: refs/heads/master
Commit: 540dd185d5a526ecd033c46388afead05bbccd10
Parents: b05a1b6
Author: Herval Freire <hfreire@twitter.com>
Authored: Wed Oct 4 12:49:05 2017 -0700
Committer: Lee moon soo <moon@apache.org>
Committed: Thu Oct 5 15:00:52 2017 -0700

----------------------------------------------------------------------
 docs/setup/operation/configuration.md           | 28 ++++++-
 zeppelin-distribution/src/bin_license/LICENSE   |  1 +
 zeppelin-interpreter/pom.xml                    |  6 ++
 .../org/apache/zeppelin/user/Credentials.java   | 26 ++++++-
 .../org/apache/zeppelin/user/Encryptor.java     | 80 ++++++++++++++++++++
 .../apache/zeppelin/user/CredentialsTest.java   |  2 +-
 .../org/apache/zeppelin/user/EncryptorTest.java | 41 ++++++++++
 .../apache/zeppelin/server/ZeppelinServer.java  |  7 +-
 .../zeppelin/conf/ZeppelinConfiguration.java    |  5 ++
 .../helium/HeliumApplicationFactoryTest.java    |  2 +-
 .../apache/zeppelin/notebook/NotebookTest.java  |  2 +-
 .../notebook/repo/NotebookRepoSyncTest.java     |  2 +-
 12 files changed, 194 insertions(+), 8 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/docs/setup/operation/configuration.md
----------------------------------------------------------------------
diff --git a/docs/setup/operation/configuration.md b/docs/setup/operation/configuration.md
index e91a7df..21ae5b3 100644
--- a/docs/setup/operation/configuration.md
+++ b/docs/setup/operation/configuration.md
@@ -77,7 +77,19 @@ If both are defined, then the **environment variables** will take priority.
     <td>*</td>
     <td>Enables a way to specify a ',' separated list of allowed origins for REST and
websockets. <br /> e.g. http://localhost:8080</td>
   </tr>
-    <tr>
+  <tr>
+    <td><h6 class="properties">ZEPPELIN_CREDENTIALS_PERSIST</h6></td>
+    <td><h6 class="properties">zeppelin.credentials.persist</h6></td>
+    <td>true</td>
+    <td>Persist credentials on a JSON file (credentials.json)</td>
+  </tr>  
+  <tr>
+    <td><h6 class="properties">ZEPPELIN_CREDENTIALS_ENCRYPT_KEY</h6></td>
+    <td><h6 class="properties">zeppelin.credentials.encryptKey</h6></td>
+    <td></td>
+    <td>If provided, encrypt passwords on the credentials.json file (passwords will
be stored as plain-text otherwise</td>
+  </tr>  
+  <tr>
     <td>N/A</td>
     <td><h6 class="properties">zeppelin.anonymous.allowed</h6></td>
     <td>true</td>
@@ -411,6 +423,20 @@ The following properties needs to be updated in the `zeppelin-site.xml`
in order
 </property>
 ```
 
+### Storing user credentials
+
+In order to avoid having to re-enter credentials every time you restart/redeploy Zeppelin,
you can store the user credentials. Zeppelin supports this via the ZEPPELIN_CREDENTIALS_PERSIST
configuration.
+
+Please notice that passwords will be stored in *plain text* by default. To encrypt the passwords,
use the ZEPPELIN_CREDENTIALS_ENCRYPT_KEY config variable. This will encrypt passwords using
the AES-128 algorithm.
+
+You can generate an appropriate encryption key any way you'd like - for instance, by using
the openssl tool:
+
+```
+openssl enc -aes-128-cbc -k secret -P -md sha1
+```
+
+*Important*: storing your encryption key in a configuration file is _not advised_. Depending
on your environment security needs, you may want to consider utilizing a credentials server,
storing the ZEPPELIN_CREDENTIALS_ENCRYPT_KEY as an OS env variable, or any other approach
that would not colocate the encryption key and the encrypted content (the credentials.json
file).
+
 
 ### Obfuscating Passwords using the Jetty Password Tool
 

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-distribution/src/bin_license/LICENSE
----------------------------------------------------------------------
diff --git a/zeppelin-distribution/src/bin_license/LICENSE b/zeppelin-distribution/src/bin_license/LICENSE
index 3a6d0aa..ec0da9e 100644
--- a/zeppelin-distribution/src/bin_license/LICENSE
+++ b/zeppelin-distribution/src/bin_license/LICENSE
@@ -273,6 +273,7 @@ The text of each license is also included at licenses/LICENSE-[project]-[version
     (The MIT License) headroom.js 0.9.3 (https://github.com/WickyNilliams/headroom.js) -
https://github.com/WickyNilliams/headroom.js/blob/master/LICENSE
     (The MIT License) angular-viewport-watch 0.135 (https://github.com/wix/angular-viewport-watch)
- https://github.com/wix/angular-viewport-watch/blob/master/LICENSE
     (The MIT License) ansi-up 2.0.2 (https://github.com/drudru/ansi_up) - https://github.com/drudru/ansi_up#license
+    (The MIT License) bcpkix-jdk15on 1.52 (org.bouncycastle:bcpkix-jdk15on:1.52 https://github.com/bcgit/bc-java)
- https://github.com/bcgit/bc-java/blob/master/LICENSE.html
 
 ========================================================================
 BSD-style licenses

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-interpreter/pom.xml
----------------------------------------------------------------------
diff --git a/zeppelin-interpreter/pom.xml b/zeppelin-interpreter/pom.xml
index 384b9d1..d08ce4e 100644
--- a/zeppelin-interpreter/pom.xml
+++ b/zeppelin-interpreter/pom.xml
@@ -133,6 +133,12 @@
     </dependency>
 
     <dependency>
+      <groupId>org.bouncycastle</groupId>
+      <artifactId>bcpkix-jdk15on</artifactId>
+      <version>1.52</version>
+    </dependency>
+
+    <dependency>
       <groupId>org.apache.maven</groupId>
       <artifactId>maven-aether-provider</artifactId>
       <version>${maven.aeither.provider.version}</version>

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Credentials.java
----------------------------------------------------------------------
diff --git a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Credentials.java
b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Credentials.java
index e80a89f..d345e3c 100644
--- a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Credentials.java
+++ b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Credentials.java
@@ -47,7 +47,21 @@ public class Credentials {
   private Boolean credentialsPersist = true;
   File credentialsFile;
 
-  public Credentials(Boolean credentialsPersist, String credentialsPath) {
+  private Encryptor encryptor;
+
+  /**
+   * Wrapper fro user credentials. It can load credentials from a file if credentialsPath
is
+   * supplied, and will encrypt the file if an encryptKey is supplied.
+   *
+   * @param credentialsPersist
+   * @param credentialsPath
+   * @param encryptKey
+   */
+  public Credentials(Boolean credentialsPersist, String credentialsPath, String encryptKey)
{
+    if (encryptKey != null) {
+      this.encryptor = new Encryptor(encryptKey);
+    }
+
     this.credentialsPersist = credentialsPersist;
     if (credentialsPath != null) {
       credentialsFile = new File(credentialsPath);
@@ -119,6 +133,11 @@ public class Credentials {
       fis.close();
 
       String json = sb.toString();
+
+      if (encryptor != null) {
+        json = encryptor.decrypt(json);
+      }
+
       CredentialsInfoSaving info = CredentialsInfoSaving.fromJson(json);
       this.credentialsMap = info.credentialsMap;
     } catch (IOException e) {
@@ -146,6 +165,11 @@ public class Credentials {
 
       FileOutputStream fos = new FileOutputStream(credentialsFile, false);
       OutputStreamWriter out = new OutputStreamWriter(fos);
+
+      if (encryptor != null) {
+        jsonString = encryptor.encrypt(jsonString);
+      }
+
       out.append(jsonString);
       out.close();
       fos.close();

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Encryptor.java
----------------------------------------------------------------------
diff --git a/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Encryptor.java b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Encryptor.java
new file mode 100644
index 0000000..cf04b37
--- /dev/null
+++ b/zeppelin-interpreter/src/main/java/org/apache/zeppelin/user/Encryptor.java
@@ -0,0 +1,80 @@
+/*
+ * 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.zeppelin.user;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+
+import org.bouncycastle.crypto.BufferedBlockCipher;
+import org.bouncycastle.crypto.InvalidCipherTextException;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher;
+import org.bouncycastle.crypto.paddings.ZeroBytePadding;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.util.encoders.Base64;
+
+/**
+ * Encrypt/decrypt arrays of bytes!
+ */
+public class Encryptor {
+  private final BufferedBlockCipher encryptCipher;
+  private final BufferedBlockCipher decryptCipher;
+
+  public Encryptor(String encryptKey) {
+    encryptCipher = new PaddedBufferedBlockCipher(new AESEngine(), new ZeroBytePadding());
+    encryptCipher.init(true, new KeyParameter(encryptKey.getBytes()));
+
+    decryptCipher = new PaddedBufferedBlockCipher(new AESEngine(), new ZeroBytePadding());
+    decryptCipher.init(false, new KeyParameter(encryptKey.getBytes()));
+  }
+
+
+  public String encrypt(String inputString) throws IOException {
+    byte[] input = inputString.getBytes();
+    byte[] result = new byte[encryptCipher.getOutputSize(input.length)];
+    int size = encryptCipher.processBytes(input, 0, input.length, result, 0);
+
+    try {
+      size += encryptCipher.doFinal(result, size);
+
+      byte[] out = new byte[size];
+      System.arraycopy(result, 0, out, 0, size);
+      return new String(Base64.encode(out));
+    } catch (InvalidCipherTextException e) {
+      throw new IOException("Cannot encrypt: " + e.getMessage(), e);
+    }
+  }
+
+  public String decrypt(String base64Input) throws IOException {
+    byte[] input = Base64.decode(base64Input);
+    byte[] result = new byte[decryptCipher.getOutputSize(input.length)];
+    int size = decryptCipher.processBytes(input, 0, input.length, result, 0);
+
+    try {
+      size += decryptCipher.doFinal(result, size);
+
+      byte[] out = new byte[size];
+      System.arraycopy(result, 0, out, 0, size);
+      return new String(out);
+    } catch (InvalidCipherTextException e) {
+      throw new IOException("Cannot decrypt: " + e.getMessage(), e);
+    }
+  }
+}

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/CredentialsTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/CredentialsTest.java
b/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/CredentialsTest.java
index 259516f..4516bea 100644
--- a/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/CredentialsTest.java
+++ b/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/CredentialsTest.java
@@ -27,7 +27,7 @@ public class CredentialsTest {
 
   @Test
   public void testDefaultProperty() throws IOException {
-    Credentials credentials = new Credentials(false, null);
+    Credentials credentials = new Credentials(false, null, null);
     UserCredentials userCredentials = new UserCredentials();
     UsernamePassword up1 = new UsernamePassword("user2", "password");
     userCredentials.putUsernamePassword("hive(vertica)", up1);

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/EncryptorTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/EncryptorTest.java
b/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/EncryptorTest.java
new file mode 100644
index 0000000..9950be6
--- /dev/null
+++ b/zeppelin-interpreter/src/test/java/org/apache/zeppelin/user/EncryptorTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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.zeppelin.user;
+
+import java.io.IOException;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+public class EncryptorTest {
+
+  @Test
+  public void testEncryption() throws IOException {
+    Encryptor encryptor = new Encryptor("foobar1234567890");
+
+    String input = "test";
+
+    String encrypted = encryptor.encrypt(input);
+    assertNotEquals(input, encrypted);
+
+    String decrypted = encryptor.decrypt(encrypted);
+    assertEquals(input, decrypted);
+  }
+}

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
----------------------------------------------------------------------
diff --git a/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
b/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
index c103eeb..f27bfbe 100644
--- a/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
+++ b/zeppelin-server/src/main/java/org/apache/zeppelin/server/ZeppelinServer.java
@@ -132,7 +132,10 @@ public class ZeppelinServer extends Application {
     this.notebookRepo = new NotebookRepoSync(conf);
     this.noteSearchService = new LuceneSearch();
     this.notebookAuthorization = NotebookAuthorization.init(conf);
-    this.credentials = new Credentials(conf.credentialsPersist(), conf.getCredentialsPath());
+    this.credentials = new Credentials(
+        conf.credentialsPersist(),
+        conf.getCredentialsPath(),
+        conf.getCredentialsEncryptKey());
     notebook = new Notebook(conf,
         notebookRepo, schedulerFactory, replFactory, interpreterSettingManager, notebookWsServer,
             noteSearchService, notebookAuthorization, credentials);
@@ -152,7 +155,7 @@ public class ZeppelinServer extends Application {
     } catch (Exception e) {
       LOG.error(e.getMessage(), e);
     }
-
+    
     // to update notebook from application event from remote process.
     heliumApplicationFactory.setNotebook(notebook);
     // to update fire websocket event on application event.

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-zengine/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
b/zeppelin-zengine/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
index 2dec19c..a3deaaa 100644
--- a/zeppelin-zengine/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
+++ b/zeppelin-zengine/src/main/java/org/apache/zeppelin/conf/ZeppelinConfiguration.java
@@ -443,6 +443,10 @@ public class ZeppelinConfiguration extends XMLConfiguration {
     return getBoolean(ConfVars.ZEPPELIN_CREDENTIALS_PERSIST);
   }
 
+  public String getCredentialsEncryptKey() {
+    return getString(ConfVars.ZEPPELIN_CREDENTIALS_ENCRYPT_KEY);
+  }
+
   public String getCredentialsPath() {
     return getRelativeDir(String.format("%s/credentials.json", getConfDir()));
   }
@@ -680,6 +684,7 @@ public class ZeppelinConfiguration extends XMLConfiguration {
     ZEPPELIN_ALLOWED_ORIGINS("zeppelin.server.allowed.origins", "*"),
     ZEPPELIN_ANONYMOUS_ALLOWED("zeppelin.anonymous.allowed", true),
     ZEPPELIN_CREDENTIALS_PERSIST("zeppelin.credentials.persist", true),
+    ZEPPELIN_CREDENTIALS_ENCRYPT_KEY("zeppelin.credentials.encryptKey", null),
     ZEPPELIN_WEBSOCKET_MAX_TEXT_MESSAGE_SIZE("zeppelin.websocket.max.text.message.size",
"1024000"),
     ZEPPELIN_SERVER_DEFAULT_DIR_ALLOWED("zeppelin.server.default.dir.allowed", false),
     ZEPPELIN_SERVER_XFRAME_OPTIONS("zeppelin.server.xframe.options", "SAMEORIGIN"),

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumApplicationFactoryTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumApplicationFactoryTest.java
b/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumApplicationFactoryTest.java
index bf49490..8f3e615 100644
--- a/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumApplicationFactoryTest.java
+++ b/zeppelin-zengine/src/test/java/org/apache/zeppelin/helium/HeliumApplicationFactoryTest.java
@@ -77,7 +77,7 @@ public class HeliumApplicationFactoryTest extends AbstractInterpreterTest
implem
         this,
         search,
         notebookAuthorization,
-        new Credentials(false, null));
+        new Credentials(false, null, null));
 
     heliumAppFactory.setNotebook(notebook);
 

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java
b/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java
index f044fbd..bc185a0 100644
--- a/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java
+++ b/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/NotebookTest.java
@@ -98,7 +98,7 @@ public class NotebookTest extends AbstractInterpreterTest implements JobListener
     SearchService search = mock(SearchService.class);
     notebookRepo = new VFSNotebookRepo(conf);
     notebookAuthorization = NotebookAuthorization.init(conf);
-    credentials = new Credentials(conf.credentialsPersist(), conf.getCredentialsPath());
+    credentials = new Credentials(conf.credentialsPersist(), conf.getCredentialsPath(), null);
 
     notebook = new Notebook(conf, notebookRepo, schedulerFactory, interpreterFactory, interpreterSettingManager,
this, search,
         notebookAuthorization, credentials);

http://git-wip-us.apache.org/repos/asf/zeppelin/blob/540dd185/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/repo/NotebookRepoSyncTest.java
----------------------------------------------------------------------
diff --git a/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/repo/NotebookRepoSyncTest.java
b/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/repo/NotebookRepoSyncTest.java
index 8a0b5a1..2236654 100644
--- a/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/repo/NotebookRepoSyncTest.java
+++ b/zeppelin-zengine/src/test/java/org/apache/zeppelin/notebook/repo/NotebookRepoSyncTest.java
@@ -103,7 +103,7 @@ public class NotebookRepoSyncTest implements JobListenerFactory {
     search = mock(SearchService.class);
     notebookRepoSync = new NotebookRepoSync(conf);
     notebookAuthorization = NotebookAuthorization.init(conf);
-    credentials = new Credentials(conf.credentialsPersist(), conf.getCredentialsPath());
+    credentials = new Credentials(conf.credentialsPersist(), conf.getCredentialsPath(), null);
     notebookSync = new Notebook(conf, notebookRepoSync, schedulerFactory, factory, interpreterSettingManager,
this, search,
             notebookAuthorization, credentials);
     anonymous = new AuthenticationInfo("anonymous");


Mime
View raw message