diff --git a/java15/src/test/java/dev/o1c/SecretKeySealTest.java b/java15/src/test/java/dev/o1c/SecretKeySealTest.java
new file mode 100644
index 0000000..676c5c9
--- /dev/null
+++ b/java15/src/test/java/dev/o1c/SecretKeySealTest.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020 Matt Sicker
+ *
+ * Licensed 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 dev.o1c;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Random;
+
+import static dev.o1c.spi.Algorithm.ChaCha20Poly1305;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+class SecretKeySealTest {
+
+    private static Random random;
+
+    private TokenSeal seal;
+
+    @BeforeAll
+    static void beforeAll() throws NoSuchAlgorithmException {
+        random = SecureRandom.getInstanceStrong();
+    }
+
+    @BeforeEach
+    void setUp() {
+        var key = new byte[ChaCha20Poly1305.getKeySize()];
+        random.nextBytes(key);
+        seal = DataSecurity.sealWithKey(key);
+    }
+
+    @Test
+    void sealNoContext() {
+        var plaintext = new byte[4096];
+        random.nextBytes(plaintext);
+        assertArrayEquals(plaintext, seal.unseal(seal.seal(plaintext)));
+    }
+
+    @Test
+    void sealWithContext() {
+        var plaintext = new byte[420];
+        random.nextBytes(plaintext);
+        var context = new byte[42];
+        random.nextBytes(context);
+        assertArrayEquals(plaintext, seal.unseal(seal.seal(plaintext, context), context));
+    }
+
+    @Test
+    void tokenSealNoContext() {
+        var plaintext = new byte[2043];
+        random.nextBytes(plaintext);
+        var secureData = seal.tokenSeal(plaintext);
+        assertArrayEquals(plaintext, seal.tokenUnseal(secureData.getEncryptedData(), secureData.getToken()));
+    }
+
+    @Test
+    void tokenSealWithContext() {
+        var plaintext = new byte[1023];
+        random.nextBytes(plaintext);
+        var context = new byte[63];
+        random.nextBytes(context);
+        var secureData = seal.tokenSeal(plaintext, context);
+        assertArrayEquals(plaintext, seal.tokenUnseal(secureData.getEncryptedData(), secureData.getToken(), context));
+    }
+}
diff --git a/java8/src/main/java/dev/o1c/DataSecurity.java b/java8/src/main/java/dev/o1c/DataSecurity.java
new file mode 100644
index 0000000..0bf9dc7
--- /dev/null
+++ b/java8/src/main/java/dev/o1c/DataSecurity.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2020 Matt Sicker
+ *
+ * Licensed 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 dev.o1c;
+
+import dev.o1c.spi.Algorithm;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.util.Objects;
+
+public class DataSecurity {
+    public static TokenSeal sealWithKey(byte[] key) {
+        Algorithm algorithm = Algorithm.ChaCha20Poly1305;
+        if (key.length != algorithm.getKeySize()) {
+            throw new IllegalArgumentException(
+                    "Keys must be " + algorithm.getKeySize() + " bytes but got " + key.length + " bytes");
+        }
+        return sealWithKey(new SecretKeySpec(key, algorithm.getAlgorithm()));
+    }
+
+    public static TokenSeal sealWithKey(SecretKey key) {
+        return new SecretKeySeal(Objects.requireNonNull(key));
+    }
+}
diff --git a/java8/src/main/java/dev/o1c/Seal.java b/java8/src/main/java/dev/o1c/Seal.java
new file mode 100644
index 0000000..3be9d67
--- /dev/null
+++ b/java8/src/main/java/dev/o1c/Seal.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Matt Sicker
+ *
+ * Licensed 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 dev.o1c;
+
+public interface Seal {
+    byte[] seal(byte[] data, byte[] context);
+
+    default byte[] seal(byte[] data) {
+        return seal(data, null);
+    }
+
+    byte[] unseal(byte[] sealedData, byte[] context);
+
+    default byte[] unseal(byte[] sealedData) {
+        return unseal(sealedData, null);
+    }
+}
diff --git a/java8/src/main/java/dev/o1c/SealedData.java b/java8/src/main/java/dev/o1c/SealedData.java
new file mode 100644
index 0000000..057e11b
--- /dev/null
+++ b/java8/src/main/java/dev/o1c/SealedData.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020 Matt Sicker
+ *
+ * Licensed 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 dev.o1c;
+
+import java.util.Objects;
+
+public final class SealedData {
+    private final byte[] encryptedData;
+    private final byte[] token;
+
+    public SealedData(byte[] encryptedData, byte[] token) {
+        this.encryptedData = Objects.requireNonNull(encryptedData);
+        this.token = Objects.requireNonNull(token);
+    }
+
+    public byte[] getEncryptedData() {
+        return encryptedData;
+    }
+
+    public byte[] getToken() {
+        return token;
+    }
+}
diff --git a/java8/src/main/java/dev/o1c/SecretKeySeal.java b/java8/src/main/java/dev/o1c/SecretKeySeal.java
new file mode 100644
index 0000000..51e5fdb
--- /dev/null
+++ b/java8/src/main/java/dev/o1c/SecretKeySeal.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2020 Matt Sicker
+ *
+ * Licensed 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 dev.o1c;
+
+import dev.o1c.spi.Algorithm;
+import dev.o1c.spi.ByteOps;
+import dev.o1c.spi.InvalidProviderException;
+
+import javax.crypto.AEADBadTagException;
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.ShortBufferException;
+import javax.crypto.spec.IvParameterSpec;
+import java.nio.ByteBuffer;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Objects;
+
+class SecretKeySeal implements TokenSeal {
+    private static final int TAG_SIZE = 16;
+    private static final int NONCE_SIZE = 12;
+    private static final int TOKEN_TYPE = 0x43433230; // CC20 in ASCII, big endian order
+    private static final int TOKEN_SIZE = NONCE_SIZE + TAG_SIZE + Integer.BYTES;
+    // token = tag [0..15] + nonce [16..27] + token_type [28..31]
+
+    private final SecretKey key;
+
+    SecretKeySeal(SecretKey key) {
+        this.key = key;
+    }
+
+    @Override
+    public byte[] seal(byte[] data, byte[] context) {
+        Objects.requireNonNull(data);
+        Cipher cipher = initEncrypt(context);
+        byte[] nonce = cipher.getIV();
+        ByteBuffer sealedData = ByteBuffer.allocate(cipher.getOutputSize(data.length) + nonce.length + Integer.BYTES);
+        try {
+            cipher.doFinal(ByteBuffer.wrap(data), sealedData);
+        } catch (BadPaddingException | IllegalBlockSizeException | ShortBufferException e) {
+            throw new IllegalStateException(e);
+        }
+        sealedData.put(nonce);
+        sealedData.putInt(TOKEN_TYPE);
+        return sealedData.array();
+    }
+
+    // TODO: consider propagating checked AEADBadTagException
+    @Override
+    public byte[] unseal(byte[] sealedData, byte[] context) {
+        Objects.requireNonNull(sealedData);
+        int typeOffset = sealedData.length - Integer.BYTES;
+        int tokenType = ByteOps.unpackInt(sealedData, typeOffset);
+        if (tokenType != TOKEN_TYPE) {
+            throw new IllegalArgumentException("Unsupported seal type detected: " + Integer.toHexString(tokenType));
+        }
+        int nonceOffset = typeOffset - NONCE_SIZE;
+        IvParameterSpec nonce = new IvParameterSpec(sealedData, nonceOffset, NONCE_SIZE);
+        try {
+            return initDecrypt(nonce, context).doFinal(sealedData, 0, nonceOffset);
+        } catch (AEADBadTagException e) {
+            throw new O1CException(e);
+        } catch (BadPaddingException | IllegalBlockSizeException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    @Override
+    public SealedData tokenSeal(byte[] data, byte[] context) {
+        Objects.requireNonNull(data);
+        Cipher cipher = initEncrypt(context);
+        byte[] nonce = cipher.getIV();
+        ByteBuffer ciphertext = ByteBuffer.allocate(cipher.getOutputSize(data.length));
+        try {
+            cipher.doFinal(ByteBuffer.wrap(data), ciphertext);
+        } catch (BadPaddingException | IllegalBlockSizeException | ShortBufferException e) {
+            throw new IllegalStateException(e);
+        }
+        ciphertext.flip();
+        byte[] encryptedData = new byte[data.length];
+        ciphertext.get(encryptedData);
+        ByteBuffer token = ByteBuffer.allocate(TOKEN_SIZE);
+        token.put(ciphertext);
+        token.put(nonce);
+        token.putInt(TOKEN_TYPE);
+        return new SealedData(encryptedData, token.array());
+    }
+
+    @Override
+    public byte[] tokenUnseal(byte[] encryptedData, byte[] token, byte[] context) {
+        Objects.requireNonNull(encryptedData);
+        Objects.requireNonNull(token);
+        if (token.length != TOKEN_SIZE) {
+            throw new IllegalArgumentException("Token size must be " + TOKEN_SIZE + " bytes");
+        }
+        int tokenType = ByteOps.unpackInt(token, TAG_SIZE + NONCE_SIZE);
+        if (tokenType != TOKEN_TYPE) {
+            throw new IllegalArgumentException("Unsupported seal token type detected: " + Integer.toHexString(tokenType));
+        }
+        IvParameterSpec nonce = new IvParameterSpec(token, TAG_SIZE, NONCE_SIZE);
+        Cipher cipher = initDecrypt(nonce, context);
+        byte[] plaintext = new byte[encryptedData.length];
+        try {
+            cipher.doFinal(token, 0, TAG_SIZE, plaintext,
+                    cipher.update(encryptedData, 0, encryptedData.length, plaintext));
+        } catch (AEADBadTagException e) {
+            throw new O1CException(e);
+        } catch (BadPaddingException | IllegalBlockSizeException | ShortBufferException e) {
+            throw new IllegalStateException(e);
+        }
+        return plaintext;
+    }
+
+    private Cipher initEncrypt(byte[] context) {
+        Cipher cipher = createCipher();
+        try {
+            cipher.init(Cipher.ENCRYPT_MODE, key, SecureRandom.getInstanceStrong());
+        } catch (InvalidKeyException e) {
+            throw new IllegalArgumentException(e);
+        } catch (NoSuchAlgorithmException e) {
+            throw new InvalidProviderException(e);
+        }
+        if (context != null && context.length > 0) {
+            cipher.updateAAD(context);
+        }
+        return cipher;
+    }
+
+    private Cipher initDecrypt(IvParameterSpec nonce, byte[] context) {
+        Cipher cipher = createCipher();
+        try {
+            cipher.init(Cipher.DECRYPT_MODE, key, nonce);
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+            throw new IllegalArgumentException(e);
+        }
+        if (context != null && context.length > 0) {
+            cipher.updateAAD(context);
+        }
+        return cipher;
+    }
+
+    private static Cipher createCipher() {
+        try {
+            return Cipher.getInstance(Algorithm.ChaCha20Poly1305.getAlgorithm());
+        } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+            throw new InvalidProviderException(e);
+        }
+    }
+}
diff --git a/java8/src/main/java/dev/o1c/TokenSeal.java b/java8/src/main/java/dev/o1c/TokenSeal.java
new file mode 100644
index 0000000..97edd5f
--- /dev/null
+++ b/java8/src/main/java/dev/o1c/TokenSeal.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Matt Sicker
+ *
+ * Licensed 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 dev.o1c;
+
+public interface TokenSeal extends Seal {
+    SealedData tokenSeal(byte[] data, byte[] context);
+
+    default SealedData tokenSeal(byte[] data) {
+        return tokenSeal(data, null);
+    }
+
+    byte[] tokenUnseal(byte[] encryptedData, byte[] token, byte[] context);
+
+    default byte[] tokenUnseal(byte[] encryptedData, byte[] token) {
+        return tokenUnseal(encryptedData, token, null);
+    }
+}
diff --git a/java8/src/main/java/dev/o1c/spi/ByteOps.java b/java8/src/main/java/dev/o1c/spi/ByteOps.java
index a1a3711..da6cd25 100644
--- a/java8/src/main/java/dev/o1c/spi/ByteOps.java
+++ b/java8/src/main/java/dev/o1c/spi/ByteOps.java
@@ -38,6 +38,10 @@ public static byte[] reverseCopyOf(byte[] buf) {
         return copy;
     }
 
+    public static int unpackInt(byte[] buf, int off) {
+        return (buf[off] & 0xff) << 24 | (buf[off + 1] & 0xff) << 16 | (buf[off + 2] & 0xff) << 8 | buf[off + 3] & 0xff;
+    }
+
     public static byte[] fromHex(CharSequence data) {
         return HEX_DECODER.decode(data);
     }