How SSH and PEM key hashes are extracted
A technical walk-through of how Hash Extractor pulls crackable hashes from encrypted SSH and PEM private keys — entirely in the browser.
Encrypted SSH and PEM private keys protect sensitive credentials behind a password. When you're auditing your own infrastructure or working a CTF challenge, you need a crackable hash — not the key file itself. Hash Extractor parses these files client-side in WebAssembly and emits exactly what ssh2john and pem2john would produce, without uploading the file anywhere.
There are three distinct wire formats, each with a different parsing path.
Shape 1 — Traditional openssl PEM
What the file looks like
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,000102030405060708090a0b0c0d0e0f
ESIzRFVmd4g=
-----END RSA PRIVATE KEY-----
The header line reads BEGIN RSA PRIVATE KEY (or EC PRIVATE KEY, DSA PRIVATE KEY). The presence of Proc-Type: 4,ENCRYPTED and a DEK-Info: line signals that the body is encrypted.
Parsing
Detection checks for -----BEGIN within the first 120 bytes and PRIVATE KEY within the first 400 bytes. For traditional PEM the extractor then:
- Locates the
DEK-Info:line and splits on the comma to get the cipher name and the IV hex string. - Decodes the IV from hex — this 16-byte value is the salt.
- Strips the PEM headers (
Proc-Type:,DEK-Info:, and the blank separator line) and base64-decodes the remaining body to get the raw ciphertext blob. - Maps the cipher name to an integer id:
DES-EDE3-CBC→ 0,AES-128-CBC→ 1 (3 for EC keys),AES-192-CBC→ 4,AES-256-CBC→ 5.
Output format
$sshng$<cid>$<saltlen>$<salt_hex>$<datalen>$<data_hex>
Using the test vector from the source (16-byte IV 000102030405060708090a0b0c0d0e0f, 8-byte body 1122334455667788, AES-256-CBC):
$sshng$5$16$000102030405060708090a0b0c0d0e0f$8$1122334455667788
Fields: cipher id 5 (AES-256-CBC), salt length 16, the IV as the salt, ciphertext length 8, ciphertext hex.
Shape 2 — New OpenSSH format
What the file looks like
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAA...
-----END OPENSSH PRIVATE KEY-----
ssh-keygen has produced this format by default since OpenSSH 6.5. There are no in-header metadata lines — everything lives inside the base64 blob.
Parsing
After base64-decoding the body, the extractor validates the magic prefix openssh-key-v1\0, then reads sequentially using SSH wire-string framing (each string is a big-endian uint32 length prefix followed by that many bytes):
ciphername— e.g.aes256-cbc(id 2) oraes256-ctr(id 6).kdfname— must bebcrypt;nonemeans unencrypted (rejected).kdfoptions— itself a wire-string whose content is another wire-string (the bcrypt salt) followed by auint32round count.num_keys(uint32) and the public key wire-string are consumed and discarded.- The next
uint32is the length of the encrypted private section. The current byte position after reading that length is recorded asciphertext_begin_offset.
The extractor emits the entire raw blob (not just the ciphertext) because John the Ripper needs it to locate the check bytes at ciphertext_begin_offset.
Output format
$sshng$<cid>$<saltlen>$<salt_hex>$<bloblen>$<blob_hex>$<rounds>$<offset>
The extra $<rounds>$<offset> fields distinguish OpenSSH entries from traditional ones.
Shape 3 — PKCS#8 PBES2
What the file looks like
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIBtzBRBgkqhkiG9w0BBQ0wRDAj...
-----END ENCRYPTED PRIVATE KEY-----
openssl pkcs8 -topk8 produces this format. There are no plaintext header lines; all parameters are DER-encoded inside the blob.
Parsing — a DER walk
After base64-decoding, the extractor walks the ASN.1 DER structure of EncryptedPrivateKeyInfo:
SEQUENCE {
SEQUENCE { -- AlgorithmIdentifier
OID 1.2.840.113549.1.5.13 -- id-PBES2
SEQUENCE { -- PBES2-params
SEQUENCE { -- keyDerivationFunc
OID 1.2.840.113549.1.5.12 -- id-PBKDF2
SEQUENCE { -- PBKDF2-params
OCTET STRING -- salt
INTEGER -- iterations
SEQUENCE { OID ... } -- optional PRF (absent = hmacWithSHA1)
}
}
SEQUENCE { -- encryptionScheme
OID ... -- cipher (AES-128/192/256-CBC or 3DES)
OCTET STRING -- IV
}
}
}
OCTET STRING -- ciphertext
}
The OID at the PRF position (last byte 0x07–0x0b) resolves to SHA-1 through SHA-512.
Output format
SHA-1 PRF ($PEM$1, hashcat 24410):
$PEM$1$<cid>$<salt_hex>$<iterations>$<iv_hex>$<ct_len>$<ct_hex>
SHA-256 (or other) PRF ($PEM$2, hashcat 24420):
$PEM$2$pbkdf2$<prf_name>$<cipher_name>$<cid>$<salt_hex>$<iterations>$<iv_hex>$<ct_len>$<ct_hex>
From the regression test (PBKDF2-HMAC-SHA1, AES-128-CBC, 2048 iterations):
$PEM$1$2$bb34848915ca58920b61819e6c14569b$2048$<iv_hex>$<ct_len>$<ct_hex>
Cipher id 2 = aes128_cbc. Salt bb34848915ca58920b61819e6c14569b (16 bytes), 2048 iterations.
Cracking the hash
hashcat
# Traditional / OpenSSH sshng
echo '$sshng$5$16$000102030405060708090a0b0c0d0e0f$8$1122334455667788' > hash.txt
hashcat -m 22911 hash.txt /usr/share/wordlists/rockyou.txt
# PKCS#8 PBKDF2-HMAC-SHA1
hashcat -m 24410 hash.txt /usr/share/wordlists/rockyou.txt
# PKCS#8 PBKDF2-HMAC-SHA256
hashcat -m 24420 hash.txt /usr/share/wordlists/rockyou.txt
John the Ripper
# Traditional and OpenSSH
john --format=ssh hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
# PKCS#8
john --format=PEM hash.txt --wordlist=/usr/share/wordlists/rockyou.txt
John's ssh2john / pem2john scripts produce identical output — Hash Extractor mirrors their logic exactly.
Why client-side WASM matters
The key file never leaves your machine. The Rust extractor is compiled to WebAssembly and runs entirely in the browser: it reads the file from a local <input>, parses the PEM structure in memory, and returns the hash string. No file is uploaded, no server sees the plaintext key material. The hash itself contains no private key data — only the salt, IV, and ciphertext, which are already present on disk.