diff --git a/Cargo.toml b/Cargo.toml
index 479d279f2f75aed9a07594c840ede2b0e283101b..9e824d09accfe29f5c4e634929180c6e34f00ecb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,6 +49,7 @@ digest = "0.10"
 signature = { version = "1.4", default-features = false }
 zeroize = { version = "1", default-features = false, features = ["derive"] }
 cipher = { version = "0.4", features = ["zeroize"] }
+subtle = { version = "2.4", default-features = false }
 # ed25519/x25519
 # fork allows hashing by parts (sign/verify from sshwire), and zeroize
 salty = { version = "0.2", git = "https://github.com/mkj/salty", branch = "sunset" }
diff --git a/src/encrypt.rs b/src/encrypt.rs
index 8bca668b7d78370ce18037a6ffcbcaeb5ec67871..0a986f74a27e9cbae7bb9367bc341e2c940a2089 100644
--- a/src/encrypt.rs
+++ b/src/encrypt.rs
@@ -512,7 +512,7 @@ impl Cipher {
     /// Length in bytes
     pub fn key_len(&self) -> usize {
         match self {
-            Cipher::ChaPoly => SSHChaPoly::key_len(),
+            Cipher::ChaPoly => SSHChaPoly::KEY_LEN,
             Cipher::Aes256Ctr => aes::Aes256::key_size(),
         }
     }
@@ -564,7 +564,7 @@ impl EncKey {
     ) -> Result<Self, Error> {
         match cipher {
             Cipher::ChaPoly => {
-                Ok(EncKey::ChaPoly(SSHChaPoly::new(key).trap()?))
+                Ok(EncKey::ChaPoly(SSHChaPoly::new_from_slice(key).trap()?))
             }
             Cipher::Aes256Ctr => Ok(EncKey::Aes256Ctr(
                 Aes256Ctr32BE::new_from_slices(key, iv).trap()?,
@@ -614,7 +614,7 @@ impl DecKey {
     ) -> Result<Self, Error> {
         match cipher {
             Cipher::ChaPoly => {
-                Ok(DecKey::ChaPoly(SSHChaPoly::new(key).trap()?))
+                Ok(DecKey::ChaPoly(SSHChaPoly::new_from_slice(key).trap()?))
             }
             Cipher::Aes256Ctr => Ok(DecKey::Aes256Ctr(
                 Aes256Ctr32BE::new_from_slices(key, iv).trap()?,
@@ -702,7 +702,7 @@ impl IntegKey {
     }
     pub fn size_out(&self) -> usize {
         match self {
-            IntegKey::ChaPoly => SSHChaPoly::tag_len(),
+            IntegKey::ChaPoly => SSHChaPoly::TAG_LEN,
             IntegKey::HmacSha256(_) => sha2::Sha256::output_size(),
             IntegKey::NoInteg => 0,
         }
diff --git a/src/ssh_chapoly.rs b/src/ssh_chapoly.rs
index a378a6e3fc142c992c1b952417c8b2bb8fe737cc..41698174e964c65050b21a354ebf87419ff4b666 100644
--- a/src/ssh_chapoly.rs
+++ b/src/ssh_chapoly.rs
@@ -9,23 +9,29 @@ use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek, StreamCipherSe
 use poly1305::Poly1305;
 use poly1305::universal_hash::{NewUniversalHash, UniversalHash};
 use poly1305::universal_hash::generic_array::GenericArray;
+use subtle::ConstantTimeEq;
 use zeroize::{Zeroize, ZeroizeOnDrop};
 
 use crate::*;
 use encrypt::SSH_LENGTH_SIZE;
 
 #[derive(Clone, ZeroizeOnDrop)]
+/// `chacha20-poly1305@openssh.com` authenticated cipher
 pub struct SSHChaPoly {
+    /// Length key
     k1: [u8; 32],
+    /// Packet key
     k2: [u8; 32],
 }
 
-const TAG_LEN: usize = 16;
-const KEY_LEN: usize = 64;
 
 impl SSHChaPoly {
-    pub fn new(key: &[u8]) -> Result<Self> {
-        if key.len() != KEY_LEN {
+    pub const TAG_LEN: usize = 16;
+    pub const KEY_LEN: usize = 64;
+
+    /// `key` must be 64 bytes
+    pub fn new_from_slice(key: &[u8]) -> Result<Self> {
+        if key.len() != Self::KEY_LEN {
             return Err(Error::BadKey)
         }
         let mut k1 = [0u8; 32];
@@ -38,20 +44,15 @@ impl SSHChaPoly {
         })
     }
 
-    pub const fn tag_len() -> usize {
-        TAG_LEN
-    }
-
-    pub const fn key_len() -> usize {
-        KEY_LEN
-    }
-
     fn cha20(key: &[u8; 32], seq: u32) -> ChaCha20 {
         let mut nonce = [0u8; 12];
         nonce[8..].copy_from_slice(&seq.to_be_bytes());
         ChaCha20::new(key.into(), (&nonce).into())
     }
 
+    /// Decrypts the packet length.
+    ///
+    /// `buf` must be at least 4 bytes, extra data is ignored.
     pub fn packet_length(&self, seq: u32, buf: &[u8]) -> Result<u32> {
         if buf.len() < SSH_LENGTH_SIZE {
             return Err(Error::BadDecrypt);
@@ -62,27 +63,32 @@ impl SSHChaPoly {
         Ok(u32::from_be_bytes(b.try_into().unwrap()))
     }
 
+    /// Decrypts in-place and validates the MAC.
+    ///
+    /// Length has already been decrypted by `packet_length()`.
     pub fn decrypt(&self, seq: u32, msg: &mut [u8], mac: &[u8]) -> Result<()> {
         if msg.len() < SSH_LENGTH_SIZE {
             return Err(Error::BadDecrypt);
         }
-        if mac.len() != TAG_LEN {
+        if mac.len() != Self::TAG_LEN {
             return Err(Error::BadDecrypt);
         }
-        let msg_tag = poly1305::Tag::new(*GenericArray::from_slice(mac));
 
         let mut c = Self::cha20(&self.k2, seq);
         let mut poly_key = [0u8; 32];
         c.apply_keystream(&mut poly_key);
 
         // check tag
+        let msg_tag = poly1305::Tag::new(*GenericArray::from_slice(mac));
         let poly = Poly1305::new((&poly_key).into());
+        // compute_unpadded() adds the necessary trailing 1 byte when padding output
         let tag = poly.compute_unpadded(msg);
-        if tag != msg_tag {
+        let good: bool = tag.ct_eq(&msg_tag).into();
+        if !good {
             return Err(Error::BadDecrypt);
         }
 
-        // encrypt payload
+        // decrypt payload
         let (_, payload) = msg.split_at_mut(SSH_LENGTH_SIZE);
         // set block counter to 1
         c.seek(64u32);
@@ -90,11 +96,12 @@ impl SSHChaPoly {
         Ok(())
     }
 
+    /// Encrypt in-place, including length, payload, MAC.
     pub fn encrypt(&self, seq: u32, msg: &mut [u8], mac: &mut [u8]) -> Result<()> {
         if msg.len() < SSH_LENGTH_SIZE {
             return Err(Error::BadDecrypt);
         }
-        if mac.len() != TAG_LEN {
+        if mac.len() != Self::TAG_LEN {
             return Err(Error::BadDecrypt);
         }