GnuPG String to Key (S2K)

To derive a symmetric key from a passphrase, OpenPGP uses a homespun key-derivation function called String-to-Key, typically abbreviated as S2K. This transformation comes in three variations: Simple S2K, Salted S2K, Iterated and Salted S2K. All three variants work by hashing a string based on a bespoke passphrase to form the session key, and they do so applying the hash function in the same manner. They differ in how they concoct their input strings for hashing. Simple S2K accepts the passphrase as-is. Salted S2K prepends a salt to the passphrase—eight randomly-generated octets. Iterated and Salted S2K likewise salts the passphrase. In addition, it successively repeats the salt-passphrase pair to build an input string of a specified length; so this duo is what gets iterated.

The combination of encryption and hash algorithms determines how S2K forms key bits from hash bits—regardless of the variant's input string. What matters is the relationship of key size to hash size, and there are three cases to handle: (1) Hash length and key length coincide: The input string is hashed once and the output becomes the session key, bit for bit. (2) Hash length exceeds key length: The input string is hashed once and its output is truncated to the key length. (3) Hash length falls short of key length: The input string is hashed two or more times. Each additional round extends the input string by a counter of sorts before hashing. The multiple hash values are concatenated up to the key length.

The following sections demonstrate the S2K in its three variations. In practice, however, only Iterated and Salted S2K is used. gpg2 defaults to this, and its manual disparages the other two variants. Nevertheless, Simple S2K and Salted S2K are handy stepping stones for illustrating Iterated and Salted S2K.

S2K defaults to SHA-1 for its hasher. Use option --s2k-digest-algo to choose a different hash function from the supported offerings. (Not to be confused with --digest-algo, for signing.)

OpenPGP uses the term octet rather than byte, and these notes follow suit. These notes likewise adopt the term quartet to mean four bits, rather than nybble. It's a convenient abbreviation because gpg2 reports keys and hashes as streams of hex digits, hence quartets, and referring to these simplifies some bookkeeping. GnuPG refers to S2K variants as modes, and option --s2k-mode selects the mode. OpenPGP says specifier type. Neither supports alternative key-derivation functions. (But see RFC 4880bis. GnuPG's underlying libgcrypt supports PBKDF2 and SCRYPT, too.) GnuPG uses the term mangling for both augmenting the passphrase and hashing the resultant string. OpenPGP uses the term context where these notes use round.

These notes are informed by the description of S2K in RFC 4880 and the implementation of S2K in libgcrypt (function openpgp_s2k in file cipher/kdf.c). These Stack Exchange articles are also informative: What exactly does s2k do in gpg? What is the GnuPG process for going from a passphrase to a symmetric key?

Simple S2K

This section demonstrates Simple S2K for three combinations of encryption and hash algorithms: hash length and key length coincide, hash length exceeds key length, and hash length falls short of key length. Option "--s2k-mode 0" tells gpg2 to use Simple S2K. The passphrase is just "abc" for convenience.

Hash Length And Key Length Coincide

When the hash length and key length coincide, Simple S2K hashes the passphrase without adjustment and takes the entire output as the session key. For example, the 256-bit hash from SHA-256 serves as-is for an AES-256 session key:

-> gpg2 --homedir alice --quiet --yes --s2k-mode 0 --cipher-algo AES256 --s2k-digest SHA256 --symmetric hello.txt
gpg: Note: simple S2K mode (0) is strongly discouraged

With option --show-session-key, gpg2 reports the 256-bit session key in 64 hex digits; it prompts for the passphrase:

-> gpg2 --homedir alice --quiet --show-session-key --decrypt hello.txt.gpg
gpg: session key: '9:BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD'
Hello there, world.

The prefix "9:" is not part of the key proper. Rather, it's a bookkeeping shortcut that gpg2 uses to record the encryption algorithm associated with the key. And this key is easily seen to be the SHA-256 hash of passphrase "abc":

-> echo -n "abc" | sha256sum | tr a-f A-F
BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD  -

That's all there is to Simple S2K when key size and hash size are equal.

The tail pipe into tr translates lower-case hex digits into upper case to ease comparison with that format from --show-session-key; it's not necessary.

Activating option -n with echo is crucial to prevent adding a newline to the string handed to SHA-256. For example:

-> echo "abc" | sha256sum
edeaaff3f1774ad2888673770c6d64097e391bc362d7d6fb34982ddf0efd18cb  -
-> echo -n -e "abc\n" | sha256sum
edeaaff3f1774ad2888673770c6d64097e391bc362d7d6fb34982ddf0efd18cb  -

Either way, the hasher gets the same input, and that input has a newline character as its fourth byte.

Hash Length Exceeds Key Length

When the hash length exceeds the key length, Simple S2K takes as many bits as needed from the hash value and drops the supernumerary bits. Retention starts with the higher-order bits. For example, under the default combination of AES-128 and SHA-1, the session key coincides with the first 128 bits of the hash's 160 bits:

-> gpg2 --homedir alice --quiet --yes --s2k-mode 0 --symmetric hello.txt
gpg: Note: simple S2K mode (0) is strongly discouraged
-> gpg2 --homedir alice --quiet --show-session-key --decrypt hello.txt.gpg
gpg: session key: '7:A9993E364706816ABA3E25717850C26C'
Hello there, world.

Here is the hash of passphrase under SHA-1:

-> echo -n "abc" | sha1sum | tr a-f A-F
A9993E364706816ABA3E25717850C26C9CD0D89D  -

These 40 hex digits represent the 160 bits of the SHA-1 hash in quartets. The AES-128 session key needs only the first 32 quartets for its 128 bits:

-> printf "%.32s\n" A9993E364706816ABA3E25717850C26C9CD0D89D
A9993E364706816ABA3E25717850C26C

The hash's low-order 8 quartets (9CD0D89D) are nonchalantly tossed to the wind.

GnuPG offers other hash algorithms, which can be used instead of SHA-1 for computing a session key:

-> gpg2 --version | grep Hash
Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224

All of these produce more than 128 bits, so the above analysis for an AES-128 session key applies to all supported hashes. But there's a second round of hashing when determining an AES-256 session key of 256 bits with hashing courtesy of SHA-1, RIPEMD160, or SHA224.

Hash Length Falls Short Of Key Length

With encryption under AES-256, for example, the 160 bits from SHA-1 fall short of the required 256 bits by 96 bits. To make up the difference, S2K continues to second round of hashing. It concatenates all 160 bits from the first round and the high-order 96 bits from the second round to form the 256 bits of the session key.

-> gpg2 --homedir alice --quiet --yes --s2k-mode 0 --cipher-algo AES256 --symmetric hello.txt
gpg: Note: simple S2K mode (0) is strongly discouraged
-> gpg2 --homedir alice --quiet --show-session-key --decrypt hello.txt.gpg
gpg: session key: '9:A9993E364706816ABA3E25717850C26C9CD0D89DDD3742EC1A4D2A5B563A2B62'
Hello there, world.

The first round hashes the passphrase, as in the previous example:

-> echo -n -e "abc" |  sha1sum | tr a-f A-F
A9993E364706816ABA3E25717850C26C9CD0D89D  -

This hash provide the first 40 quartets in the session key above. The input strings for the first and second rounds must differ from each other so that SHA-1 outputs different values for each round. One octet does the trick, and the second round simply prepends the zero octet to the passphrase:

-> echo -n -e "\x00abc" | sha1sum | tr a-z A-Z
DD3742EC1A4D2A5B563A2B62AEF7FC4A46FA6CCA  -

Only the first 24 quartets are needed:

-> printf "%.24s\n" DD3742EC1A4D2A5B563A2B62AEF7FC4A46FA6CCA
DD3742EC1A4D2A5B563A2B62

These two strings of 40 and 24 quartets are joined to form the session key of 64 quartets:

A9993E364706816ABA3E25717850C26C9CD0D89DDD3742EC1A4D2A5B563A2B62
⎣		round 1                ⎦⎣        round 2       ⎦

This eyesore matches the 64-quartet session key from gpg2, above, and thus transmogrifies into a sight for sore eyes.

If twice the hash size falls short of the key size, then S2K soldiers on. In the third round, two zero octets are prepended to the passphrase for hashing fodder:

-> echo -n -e "\x00\x00abc" | hexdump --canonical
00000000  00 00 61 62 63                                    |..abc|
00000005
-> echo -n -e "\x00\x00abc" | sha1sum
fd40b6335c6954332c0f9690cfffaed944c5393c  -

This hash would then be appended to the first and second hashes, wholly or partially. And so on.

But that's not going to happen with the current encryption and hash algorithms that GnuPG supports:

-> gpg2 --version
gpg (GnuPG) 2.2.6
libgcrypt 1.8.2
⋮
Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH,
        CAMELLIA128, CAMELLIA192, CAMELLIA256
Hash: SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224
⋮

The minimum hash size is 160 bits, the maximum key size is 256 bits, and twice 160 exceeds 256. So one or two rounds will suffice.

Salted S2K

This section demonstrates Salted S2K for two cases, first when the hash length suffices for the key length, and second when the hash length falls short of the key length. This variant concatenates the salt and the passphrase to form the input string for the hasher. It otherwise proceeds as does Simple S2K.

Option "--s2k-mode 1" tells gpg2 to use Salted S2K. The passphrase is "abc".

Hash Length Suffices for Key Length

This example combines AES-128 and SHA-1, the default pairing. The first 128 bits of the hash's 160 bits become the session key.

-> gpg2 --homedir alice --quiet --yes --s2k-mode 1 --symmetric hello.txt

gpg2 randomly generate a salt of 8 octets and stores it in the Symmetric-Key Encrypted Session Key Packet. The salt is stored as cleartext and is available for inspection by pgpdump and by gpg2 with command option --list-packets:

-> pgpdump hello.txt.gpg 
Old: Symmetric-Key Encrypted Session Key Packet(tag 3)(12 bytes)
	New version(4)
	Sym alg - AES with 128-bit key(sym 7)
	Salted string-to-key(s2k 1):
		Hash alg - SHA1(hash 2)
                Salt - a1 ae 21 26 fc bf b8 1a

The salt's 8 octets are shown in 8 pairs of hexadecimal digits. pgpdump cannot access the encrypted session key, however. Instead:

-> gpg2 --homedir alice --quiet --show-session-key --list-packets hello.txt.gpg
gpg: session key: '7:9004AE57B0260E6EE233207383E735B8'
# off=0 ctb=8c tag=3 hlen=2 plen=12
:symkey enc packet: version 4, cipher 7, s2k 1, hash 2
	salt A1AE2126FCBFB81A

The salt and the passphrase are joined together to make the input string for SHA-1. It's convenient to put these principal parts into Bash variables $passphrase, $salt, and $input (because the salt changes with each encryption):

-> passphrase='abc'
-> salt='\xA1\xAE\x21\x26\xFC\xBF\xB8\x1A'
-> input="$salt$passphrase"

A quick sanity check confirms that $input has the expected 11 bytes (8+3):

-> echo -n -e $input | wc --bytes
11

Echoing $input tends to produce gibberish because the salt is not likely to be printable, even if a few grains happen to fall on printable characters. For a solid sanity check, direct examination of $input byte-by-byte shows that this string was constructed as intended:

-> echo -n -e $input | hexdump --canonical
00000000  a1 ae 21 26 fc bf b8 1a  61 62 63                 |..!&....abc|
0000000b

Alternatively:

 -> echo -n -e $input | od --address-radix x --format x1z
000000 a1 ae 21 26 fc bf b8 1a 61 62 63                 >..!&....abc<
00000b

Thus the 11-octet (0x0b) string in $input concatenates the 8-octet salt and 3-octet passphrase; no more, no less.

It's now a simple exercise to show that $input is indeed the string input to SHA-1 to produce the session key, above:

-> echo -n -e $input | sha1sum | tr a-f A-F
9004AE57B0260E6EE233207383E735B87EA7B5EA  -

The first 32 quartets or 128 bits of this hash value match the session key:

-> printf "%.32s\n" 9004AE57B0260E6EE233207383E735B87EA7B5EA
9004AE57B0260E6EE233207383E735B8

And that's Salted S2K in a nutshell.

Hash Length Falls Short of Key Length

When the hash does not pony up enough bits for the session key, S2K continues to a second round. This example uses AES-256 for encryption while retaining SHA-1 for hashing:

-> gpg2 --homedir alice --quiet --yes --s2k-mode 1 --cipher-algo AES-256 --symmetric hello.txt
-> gpg2 --homedir alice --quiet --show-session-key --list-packets hello.txt.gpg
gpg: session key: '9:729D52732A6C8A49C6800B7F3F59AA42CCBF30D9CDD64B452EAE90E3F1A4E6BE'
# off=0 ctb=8c tag=3 hlen=2 plen=12
:symkey enc packet: version 4, cipher 9, s2k 1, hash 2
	salt 8DDC0B3A3513BB74
⋮
-> passphrase='abc'
-> salt='\x8D\xDC\x0B\x3A\x35\x13\xBB\x74'

The first round hashes the salted passphrase and keeps all 160 bits:

-> input1="$salt$passphrase"
-> echo -n -e $input1 | hexdump  --canonical
00000000  8d dc 0b 3a 35 13 bb 74  61 62 63                 |...:5..tabc|
0000000b
-> echo -n -e $input1 | sha1sum | tr a-f A-F
729D52732A6C8A49C6800B7F3F59AA42CCBF30D9  -

The second round prepends the zero octet to the salted passphrase:

-> input2="\x00$salt$passphrase"
-> echo -n -e $input2 | hexdump --canonical
00000000  00 8d dc 0b 3a 35 13 bb  74 61 62 63              |....:5..tabc|
0000000c

It hashes this 12-octet (0x0c) string but keeps only the first 24 quartets (94 bits):

-> printf "%.24s\n" "$(echo -n -e $input2 | sha1sum | tr a-f A-F)"
CDD64B452EAE90E3F1A4E6BE

Finally, the 40 quartets of the first round and the 24 quartets of the second round are joined together:

729D52732A6C8A49C6800B7F3F59AA42CCBF30D9CDD64B452EAE90E3F1A4E6BE
⎣		round 1                ⎦⎣        round 2       ⎦

QED: This monstrosity in 64 quartets matches the session key from gpg2, above.

The preceding example invokes Bash's quoted command-substitution to supply the arguments for printf; i.e., "$(…)" in the following:

-> printf "%.24s\n" "$(echo -n -e $input2 | sha1sum | tr a-f A-F)"

Bash executes the enclosed command pipeline and passes the results to printf to format and print. The surrounding quotation marks tell Bash to pass the results as a single string. Without them, Bash would split the results into separate strings wherever it finds a space, tab, or newline (by default). For comparison:

-> printf "%s\n" "$(echo -n -e $input2 | sha1sum | tr a-f A-F)"
CDD64B452EAE90E3F1A4E6BE3301627F9F4ECC5F  -
-> printf "%s\n" $(echo -n -e $input2 | sha1sum | tr a-f A-F)
CDD64B452EAE90E3F1A4E6BE3301627F9F4ECC5F
-

In the first case, printf sees a single string consisting of the hash and trailing hyphen separated by a space. In the second case, printf sees two strings, the hash first and a lone hyphen second.

Iterated and Salted S2K

For a typical user-composed passphrase, the input strings of Simple S2K and Salted S2K lack heft; the hasher can quickly digest its short input. To increase the computational burden on a brute-force attack, Iterated and Salted S2K repeats the salt-passphrase duo. The input string looks like this, loosely:

salt+passpprase+salt+passprhrase+... enough already?

(Where "+" denotes string concatenation.) Therein lies the namesake iteration. The desired length in octets of the final string is the count, an adjustable parameter.

This section demonstrates Iterated and Salted S2K for two cases, first when the hash length suffices for the key length, and second when the hash length falls short of the key length. Option "--s2k-mode 3" tells gpg2 to use this variant, and option --s2k-count sets the count. The passphrase is "abc", and the count is 1,408. Thus the salted passphrase appears 128 times: (8+3)×128 = 1,408.

Hash Length Suffices for Key Length

Initial steps follow Salted S2K, but with tweaked options:

-> gpg2 --homedir alice --quiet --yes --s2k-mode 3 --s2k-count 1408 --symmetric hello.txt
-> gpg2 --homedir alice --quiet --show-session-key --list-packets hello.txt.gpg
gpg: session key: '7:2184EF5861A441ADD29DFB31061F166A'
# off=0 ctb=8c tag=3 hlen=2 plen=13
:symkey enc packet: version 4, cipher 7, s2k 3, hash 2
salt 333DAA0B9B1899DD, count 1408 (6)
⋮
-> passphrase='abc'
-> salt='\x33\x3D\xAA\x0B\x9B\x18\x99\xDD'

For convenience, variable $iterate (as a noun) stores the salted passphrase to be repeated:

-> iterate="$salt$passphrase"
-> echo -n -e $iterate | hexdump --canonical
00000000  33 3d aa 0b 9b 18 99 dd  61 62 63                 |3=......abc|
0000000b

The input string for the hasher comprises 128 copies of $iterate, exactly, for a total size of the desired 1,408 octets. A little Bash legerdemain does the xeroxing (see following note):

-> printf -v input "$iterate%.0s" {1..128}
-> echo -n -e $input | wc --bytes
1408

To examine $input in 11-octet chunks, it's easier to use od rather than hexdump (but see following note):

-> echo -n -e $input | od --address-radix x --format x1z --width=11
000000 33 3d aa 0b 9b 18 99 dd 61 62 63  >3=......abc<
*
000580
-> echo 'ibase=16; 0580' | bc
1408

The upshot is that $input iterates the salted passphrase according to plan.

Iterated and Salted S2K now resumes to the ways of its cousins by hashing $input for its first 32 quartets (128 bits):

-> printf "%.32s\n" "$(echo -n $input | sha1sum | tr a-f A-F)"
2184EF5861A441ADD29DFB31061F166A

This string matches the session key reported above.

The assignment to $input above takes advantage of Bash's printf coupled with brace expansion to repeatedly concatenate $iterate with itself.

-> printf -v input "$iterate%.0s" {1..128}

The expression {1..128} enumerates the integers 1, 2, …, 128 into a space-separated sequence, which becomes the argument list handed off to printf. For example, these two expressions are equivalent:

-> printf "<%s>" {1..3}; echo
<1><2><3>
-> printf "<%s>" 1 2 3; echo
<1><2><3>

The format "%.0s" for printf then consumes its argument without a trace:

-> printf "<%.0s>" {1..3}; echo
<><><>

The result is a multi-link chain of the literal string in the format; i.e., "<>" here and $iterate above. Finally, option -v redirects the output into the named variable:

-> printf -v chain "<%.0s>" {1..3}
-> echo $chain
<><><>

Nifty.

Nifty but not foolproof. Using printf this way trips up if a grain of the salt should happen to be the zero octet, which printf interprets as a string terminator. For example, this sequence works as expected:

-> salt='\x33\x3D\xAA\x0B\x9B\x18\x99\xDD'
-> iterate="$salt$passphrase"
-> printf -v input "$iterate%.0s" {1..2} 
-> echo -n  $input | od --address-radix x --format x1z --width=11
000000 33 3d aa 0b 9b 18 99 dd 61 62 63  >3=......abc<
*
000016

Now, change the salt's fourth octet, say, to zero and repeat:

-> salt='\x33\x3D\xAA\x00\x9B\x18\x99\xDD'
-> iterate="$salt$passphrase"
-> printf -v input "$iterate%.0s" {1..2} 
-> echo -n -e $input | od --address-radix x --format x1z --width=11
000000 33 3d aa                          >3=.<
000003

But for demonstration purposes, this handy construct is fine as long as the salt is kosher.

In the absence of direction from option --s2k-count, gpg2 asks its colleague gpg-agent what count to use for Iterated and Salted S2K. By default, gpg-agent returns a count that requires about 100 ms to hash. It calculates this calibrated count using rough-cut plug-and-chug. You can query gpg-agent directly for its calibrated count:

-> gpg-connect-agent --homedir alice 'GETINFO s2k_count_cal' /bye
D 33722368
OK

(32 MiB) You can instead set a count with option --s2k-count in gpg-agent.conf, called a standard count (in source code, anyway):

-> cat alice/gpg-agent.conf
s2k-count 65011712
-> gpg-connect-agent --homedir alice 'GETINFO s2k_count' /bye
D 65011712
OK

This count, 62 MiB, is the largest allowed by OpenPGP. A standard count takes precedence over a calibrated count. In either case, however, gpg-agent insists on a minimum of 64 KiB (65,536 octets). You can also query gpg-agent to see how long your computer takes to hash a string with the count in effect. Here and now for a string size of 62 MiB:

-> gpg-connect-agent --homedir alice 'GETINFO s2k_time' /bye
D 199
OK

That's 199 ms.

There's one quirk to note: If gpg2 sees option "--s2k-count 1024", it consults gpg-agent for the count.

See man pages for gpg2 and gpg-agent. Some of the details above were gleaned from the gpg-agent source code for several functions in file agent/protect.c; search for "_s2k" in function names.

By default, gpg-agent looks for its configuration file gpg-agent.conf in $HOME/gnupg but happily accepts redirection with option --homedir.

For calibrating the count, gpg-agent successively tries Iterated and Salted S2K with increasing count until computation requires 100 ms. It starts with a count of 64 KiB and then doubles the count on each trial. For this it uses SHA-1; that's coded in.

As it turns out, the iteration count is restricted to 256 values; the arcane details are given at the end of this section. The preceding count of 1,408 is agreeably divisible by 11, the size of the salt-passphrase iterate (8+3). Divisibility here just tidies the initial presentation, but the algorithm does not care.

When the count is not divisible by the length of the salt-passphrase iterate, the last iterate is truncated as needed. Here's a recapitulation of the previous example when the count is 1,152 (11×104+8 or 11×105-3). In building the input string, this example initially overshoots its mark by cloning $iterate 105 times to 1,155 octets. It then drops the last three octets to curb its excess.

-> gpg2 --homedir alice --quiet --yes --s2k-mode 3 --s2k-count 1152 --symmetric hello.txt
-> gpg2 --homedir alice --quiet --show-session-key --list-packets hello.txt.gpg
gpg: session key: '7:C6C5DC945E490FB73199D4F3C935C495'
# off=0 ctb=8c tag=3 hlen=2 plen=13
:symkey enc packet: version 4, cipher 7, s2k 3, hash 2
	salt C5ACB0FDE783F750, count 1152 (1)
⋮
-> passphrase='abc'
-> salt='\xC5\xAC\xB0\xFD\xE7\x83\xF7\x50'
-> iterate="$salt$passphrase"
-> printf -v input0 "$iterate%.0s" {1..105}
-> echo -n -e $input0 | wc --bytes
1155
-> printf -v input "%.1152s" $input0
-> echo -n -e $input | wc --bytes
1152
-> printf "%.32s\n" "$(echo -n -e $input | sha1sum | tr a-f A-F)"
C6C5DC945E490FB73199D4F3C935C495

Coaxing hexdump to format its display of $input into 11-byte chunks takes an esoteric incantation:

-> echo -n -e $input | hexdump --format '"%06_ax " 11/1 "%02x " " |" 11/1 "%_p"  "|\n" "%06_Ax\n"' 
000000 33 3d aa 0b 9b 18 99 dd 61 62 63 |3=......abc|
*
000580

That's more or less what option --canonical does with 16-byte chunks, but the 11-byte pattern of the iterated string gets out of whack:

-> echo -n -e $input | hexdump --canonical --length 44
00000000  33 3d aa 0b 9b 18 99 dd  61 62 63 33 3d aa 0b 9b  |3=......abc3=...|
00000010  18 99 dd 61 62 63 33 3d  aa 0b 9b 18 99 dd 61 62  |...abc3=......ab|
00000020  63 33 3d aa 0b 9b 18 99  dd 61 62 63              |c3=......abc|
0000002c

Hash Length Falls Short of Key Length

This example runs through Iterated and Salted S2K for two rounds of hashing by combining AES-192 for encryption with SHA-1 for hashing. The session key needs 192 bits. The first round determines the initial 160 bits, and the second round provides the remaining 32 bits.

-> gpg2 --homedir alice --quiet --yes --s2k-mode 3 --s2k-count 1408 --cipher-algo AES192 --symmetric hello.txt
-> gpg2 --homedir alice --quiet --show-session-key --list-packets hello.txt.gpg
gpg2 --homedir alice --quiet --show-session-key --list-packets hello.txt.gpg
gpg: session key: '8:24E7008FED286D104C9EA95023893D30929069A051FA093F'
# off=0 ctb=8c tag=3 hlen=2 plen=13
:symkey enc packet: version 4, cipher 8, s2k 3, hash 2
        salt 8578D944258F3ABD, count 1408 (6)
⋮
-> passphrase='abc'
-> salt='\x85\x78\xD9\x44\x25\x8F\x3A\xBD'
-> iterate="$salt$passphrase"

The first input, $input1, is constructed without adjustment for round:

-> printf -v input1 "$iterate%.0s" {1..128}
-> echo -n -e $input1 | od --address-radix x --format x1z --width=11
000000 85 78 d9 44 25 8f 3a bd 61 62 63  >.x.D%.:.abc<
*
000580
-> echo "ibase=16; 580" | bc
1408

The second input, $input2, simply prepends the zero octet to $input1. This prefix does not figure in counting octets of the input string, and that's why $input1 is taken without adjustment:

-> input2="\x00$input1"
-> echo -n -e $input2 | od --address-radix x --format x1z --read-bytes=12
000000 00 85 78 d9 44 25 8f 3a bd 61 62 63              >..x.D%.:.abc<
00000c

Finally, both inputs are hashed into $hash1 and $hash2, respectively. The session key then concatenates all 40 quartets of $hash1 and the first 8 quartets of $hash2:

-> hash1=$(echo -n -e $input1 | sha1sum | cut --delimiter=' ' --fields=1)
-> hash2=$(echo -n -e $input2 | sha1sum | cut --delimiter=' ' --fields=1)
-> printf "%40s%.8s\n" $hash1 $hash2 | tr a-f A-F
24E7008FED286D104C9EA95023893D30929069A051FA093F

The cut pipe just lops off the pesky hyphen that sha1sum appends to its output.

Should a third round be necessary, the prefix of two zero octets would not figure in counting octets; $input3 would be "\x00\x00$input1" (or "\x00$input2"). And so on.

If S2K receives a count less than the length of the salted passphrase, it silently resets the count to the latter length.

OpenPGP encodes the count into a single octet using an obtuse formula that only a bit-slinging programmer would love, or at least tolerate. This formula leads to counts in the range 1 KiB to 62 MiB (1,024 octets to 65,011,712 octets). Not all integers in this range can be encoded, however. To accommodate, gpg2 silently replaces an unobtainable value given to option --s2k-count by rounding up to the next obtainable count. For example, code 0 yields 1,024 and code 1 yields 1,088. Thus gpg2 pushes any requested count between 1,025 and 1,087 up to 1,088 octets.

In fact, there are only 256 admissible counts, each corresponding to one of 28 possible codes. Here is the relationship between a valid count ($count) and its encoding ($code) expressed in Perl:

    $count = ((16 + ($code & 15)) << (($code >> 4) + 6));

[§3.7.1.3] Setting $code successively to 0, 1, 2, …, 254, 255 lists the valid counts; e.g., 1,024; 1,088; 1,152; …; 62,914,560; 65,011,712.

The count of 1,408 in the preceding examples is the first count evenly divisible by 11 (1,408÷11=128), the length of the salt-passphrase duo (8+3). That's why the examples choose it. Its code is 6.

An OpenPGP file stores only the code; the octet is part of a String-to-Key (S2K) Specifier [§3.7.1.3] tucked inside a Symmetric-Key Encrypted Session Key Packet [§5.3]. GnuPG delegates S2K to a libgcrypt function that requires the count and knows nothing of codes—function openpgp_s2k in file cipher/kdf.c. GnuPG function encode_s2k_iterations in file g10/passphrase.c determines the S2K code for a requested count. GnuPG macro S2K_DECODE_COUNT in file agent/protect.c returns the count for a given code; it implements the Perl expression above.