Skip to content

No yescrypt - #1791

Closed
alejandro-colomar wants to merge 1 commit into
shadow-maint:masterfrom
alejandro-colomar:no_yescrypt
Closed

alejandro-colomar wants to merge 1 commit into
shadow-maint:masterfrom
alejandro-colomar:no_yescrypt

Conversation

@alejandro-colomar

@alejandro-colomar alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

It's a can of worms. The format is very difficult to understand, and
the specification is unnecessarily complex, requiring difficult manual
handling of bytes, which is highly prone to dangerous bugs, such as
buffer overflows. It seems designed to be obfuscated. Let's just stay
away from it.

I expect that other implementations of yescrypt have important bugs,
because it's just insanely difficult to handle this format. If one
cares about security, one should avoid using any software that uses this
algorithm.

Cc: @ikerexxe
Cc: @hallyn


Revisions:

v2
  • Remove some tests using yescrypt hashes.
$ git rd 
 1:  d4d817fff41c =  1:  d4d817fff41c lib/salt.c: gensalt(): Remove duplicate operation
 2:  e210cf8ef0ef =  2:  e210cf8ef0ef lib/salt.c: gensalt(): Minimize zeroing
 3:  c5bd4d80f3b7 =  3:  c5bd4d80f3b7 lib/salt.c: gensalt(): Put assertion before code
 4:  b365cd18f142 =  4:  b365cd18f142 lib/salt.c: crypt_make_salt(): Remove dead initialization
 5:  331c1ff7fac1 =  5:  331c1ff7fac1 lib/salt.c: Compact code (mostly braces)
 6:  6d79a267494e =  6:  6d79a267494e lib/salt.c: *_get_salt_rounds(): Compact local variable names
 7:  d16f1c463b1e =  7:  d16f1c463b1e lib/salt.c: Use array parameters
 8:  14c99a0ef62a =  8:  14c99a0ef62a lib/salt.c: Remove superfluous casts
 9:  d1e3ce358058 =  9:  d1e3ce358058 lib/salt.c: Compact branches
10:  b8e72ad92bde = 10:  b8e72ad92bde lib/salt.c: Don't catenate strings in complex functions
11:  58a766d66442 = 11:  58a766d66442 lib/, src/: crypt_make_salt(), *_get_salt_*(): Use correct parameter type, long*
12:  e34500142197 = 12:  e34500142197 lib/salt.c: *MAGNUM(): Implement with calls to string functions
13:  6698a3660b5f = 13:  6698a3660b5f lib/salt.c: gensalt(): Rename salt size to salt length
14:  7f43a54eb8bd = 14:  7f43a54eb8bd lib/salt.c: gensalt(): Use strtcat_a() to truncate
15:  6e7da030bbed = 15:  6e7da030bbed lib/salt.c: gensalt(): Remove minimum length check
16:  5fbd166c4e9d = 16:  5fbd166c4e9d lib/salt.c: gensalt(): Use stpecpy() to chain copies with truncation
17:  187e9e676bba = 17:  187e9e676bba lib/salt.c: Encapsulate salt length wisdom inside gensalt()
18:  71a9c4f6e90d = 18:  71a9c4f6e90d lib/salt.c: magnum(): Add and use function to produce the "magnum"
19:  08c0a3fb3bab = 19:  08c0a3fb3bab lib/salt.c: crypt_make_salt(): Remove superfluous local variable
20:  f8849d7058b0 = 20:  f8849d7058b0 lib/salt.c: Fix bogus comment
21:  f19392e7a39c = 21:  f19392e7a39c lib/salt.c: crypt_make_salt(): Rename local variable
22:  025fca9b60bd = 22:  025fca9b60bd lib/salt.c: get_salt_cost(): Add and use generic wrapper
23:  e2450239cb75 = 23:  e2450239cb75 lib/salt.c: salt_cost(): Add and use generic wrapper
24:  de34740fd9c5 = 24:  de34740fd9c5 lib/salt.c: crypt_make_salt(): De-duplicate code
25:  f8171ee38640 = 25:  f8171ee38640 lib/salt.c: Add 'assert(0)' after switches that return
26:  ae884c724119 = 26:  ae884c724119 lib/salt.c: Clarify that 'j' is a magic character that means YESCRYPT_DEFAULTS
27:  5b06dab1bd78 = 27:  5b06dab1bd78 lib/salt.c: tfix
28:  b64b13927ac9 ! 28:  02ab3c77e91a *: Remove YESCRYPT support
    @@ tests/unit/test_chkhash.c: test_is_valid_hash_ok_special(MAYBE_UNUSED void ** _1
      }
      
      
    +@@ tests/unit/test_chkhash.c: test_is_valid_hash_turkish_locale(MAYBE_UNUSED void ** _1)
    +           skip();  // locale not available on this system
    +   }
    + 
    +-  assert_true(is_valid_hash("$y$j9T$lChB5KbMj.6n51V3DjPfn0$6WyruF4YWm9GDgKx2mik5QEHE6J4m2RVXXegiqrfjI8"));
    +-
    +   setlocale(LC_ALL, (saved != NULL) ? saved : "C");
    +   free(saved);
    + }
    +@@ tests/unit/test_chkhash.c: test_is_invalid_algorithm(MAYBE_UNUSED void ** _1)
    + static void
    + test_is_invalid_hash_length(MAYBE_UNUSED void ** _1)
    + {
    +-  assert_false(is_valid_hash("$y$j9T$salt$tooshort"));
    +   assert_false(is_valid_hash("$2a$12$tooshort"));
    +   assert_false(is_valid_hash("$6$salt$tooshort"));
    +   assert_false(is_valid_hash("$5$salt$tooshort"));
     @@ tests/unit/test_chkhash.c: int
      main(void)
      {
v3
  • Rebase
$ git rd 
 1:  d4d817fff41c <  -:  ------------ lib/salt.c: gensalt(): Remove duplicate operation
 2:  e210cf8ef0ef <  -:  ------------ lib/salt.c: gensalt(): Minimize zeroing
 3:  c5bd4d80f3b7 <  -:  ------------ lib/salt.c: gensalt(): Put assertion before code
 4:  b365cd18f142 <  -:  ------------ lib/salt.c: crypt_make_salt(): Remove dead initialization
 5:  331c1ff7fac1 <  -:  ------------ lib/salt.c: Compact code (mostly braces)
 6:  6d79a267494e <  -:  ------------ lib/salt.c: *_get_salt_rounds(): Compact local variable names
 7:  d16f1c463b1e <  -:  ------------ lib/salt.c: Use array parameters
 8:  14c99a0ef62a <  -:  ------------ lib/salt.c: Remove superfluous casts
 9:  d1e3ce358058 <  -:  ------------ lib/salt.c: Compact branches
10:  b8e72ad92bde <  -:  ------------ lib/salt.c: Don't catenate strings in complex functions
11:  58a766d66442 <  -:  ------------ lib/, src/: crypt_make_salt(), *_get_salt_*(): Use correct parameter type, long*
12:  e34500142197 <  -:  ------------ lib/salt.c: *MAGNUM(): Implement with calls to string functions
13:  6698a3660b5f <  -:  ------------ lib/salt.c: gensalt(): Rename salt size to salt length
14:  7f43a54eb8bd <  -:  ------------ lib/salt.c: gensalt(): Use strtcat_a() to truncate
15:  6e7da030bbed <  -:  ------------ lib/salt.c: gensalt(): Remove minimum length check
16:  5fbd166c4e9d <  -:  ------------ lib/salt.c: gensalt(): Use stpecpy() to chain copies with truncation
17:  187e9e676bba <  -:  ------------ lib/salt.c: Encapsulate salt length wisdom inside gensalt()
18:  71a9c4f6e90d <  -:  ------------ lib/salt.c: magnum(): Add and use function to produce the "magnum"
19:  08c0a3fb3bab <  -:  ------------ lib/salt.c: crypt_make_salt(): Remove superfluous local variable
20:  f8849d7058b0 <  -:  ------------ lib/salt.c: Fix bogus comment
21:  f19392e7a39c <  -:  ------------ lib/salt.c: crypt_make_salt(): Rename local variable
22:  025fca9b60bd <  -:  ------------ lib/salt.c: get_salt_cost(): Add and use generic wrapper
23:  e2450239cb75 <  -:  ------------ lib/salt.c: salt_cost(): Add and use generic wrapper
24:  de34740fd9c5 <  -:  ------------ lib/salt.c: crypt_make_salt(): De-duplicate code
25:  f8171ee38640 <  -:  ------------ lib/salt.c: Add 'assert(0)' after switches that return
26:  ae884c724119 <  -:  ------------ lib/salt.c: Clarify that 'j' is a magic character that means YESCRYPT_DEFAULTS
27:  5b06dab1bd78 <  -:  ------------ lib/salt.c: tfix
28:  02ab3c77e91a !  1:  ab7d717a671d *: Remove YESCRYPT support
    @@ lib/salt.c
      #define SHA_ROUNDS_MAX 999999999
      
     -#ifdef USE_YESCRYPT
    +-# if !USE_XCRYPT_GENSALT
    +-#  error "yescrypt reqires crypt_gensalt"
    +-# endif
     -/* Default cost if not explicitly specified.  */
     -#define Y_COST_DEFAULT 5
     -/* Minimum cost.  */
    @@ lib/salt.c: enum encrypt_method {
      };
      
      
    -@@ lib/salt.c: static /*@observer@*/const char *SHA_salt_rounds(unsigned long rounds);
    - static /*@observer@*/unsigned long BCRYPT_get_salt_rounds(/*@null@*/const long *preferred_rounds);
    +@@ lib/salt.c: static /*@observer@*/unsigned long BCRYPT_get_salt_rounds(/*@null@*/const long *
      static /*@observer@*/const char *BCRYPT_salt_rounds(unsigned long rounds);
    + # endif
      #endif /* USE_BCRYPT */
     -#ifdef USE_YESCRYPT
     -static /*@observer@*/unsigned long YESCRYPT_get_salt_cost(/*@null@*/const long *preferred_cost);
    --static /*@observer@*/const char *YESCRYPT_salt_cost(unsigned long cost);
     -#endif /* USE_YESCRYPT */
      
      
      /* Return the the rounds number for the SHA crypt methods. */
     @@ lib/salt.c: BCRYPT_salt_rounds(unsigned long rounds)
    - }
    + # endif
      #endif /* USE_BCRYPT */
      
     -#ifdef USE_YESCRYPT
    @@ lib/salt.c: BCRYPT_salt_rounds(unsigned long rounds)
     -
     -  return cost;
     -}
    --
    --/*
    -- * Create a salt prefix specifying the cost for the YESCRYPT method.
    -- */
    --static /*@observer@*/const char *YESCRYPT_salt_cost(unsigned long cost)
    --{
    --  char         *p;
    --  static char  buf[5];
    --
    --  /*
    --   * We are going to write four bytes,
    --   * plus one byte for the terminator.
    --   *    jXX$
    --   *    0000
    --   *    1234
    --   */
    --
    --  p = buf;
    --  p = stpcpy(p, "j");  // 'j' == YESCRYPT_DEFAULTS
    --  if (cost < 3)
    --          *p++ = 0x36 + cost;
    --  else if (cost < 6)
    --          *p++ = 0x34 + cost;
    --  else
    --          *p++ = 0x3b + cost;
    --
    --  p = stpcpy(p, (cost >= 3) ? "T" : "5");
    --  stpcpy(p, "$");
    --
    --  return buf;
    --}
     -#endif /* USE_YESCRYPT */
     -
      static unsigned long
    @@ lib/salt.c: get_salt_cost(enum encrypt_method m, const long *preferred_cost)
      #endif
        }
        assert(0);
    -@@ lib/salt.c: salt_cost(enum encrypt_method m, unsigned long cost)
    - #ifdef USE_BCRYPT
    -   case ENCRYPT_METHOD_BCRYPT:
    -           return BCRYPT_salt_rounds(cost);
    --#endif
    --#ifdef USE_YESCRYPT
    --  case ENCRYPT_METHOD_YESCRYPT:
    --          return YESCRYPT_salt_cost(cost);
    - #endif
    -   }
    -   assert(0);
     @@ lib/salt.c: magnum(enum encrypt_method m)
      #ifdef USE_BCRYPT
        case ENCRYPT_METHOD_BCRYPT:
    @@ lib/salt.c: magnum(enum encrypt_method m)
      #endif
        }
        assert(0);
    -@@ lib/salt.c: gensalt(enum encrypt_method m)
    - #ifdef USE_BCRYPT
    -           SALT_LEN_BCRYPT = 22,
    - #endif
    --#ifdef USE_YESCRYPT
    --          SALT_LEN_YESCRYPT = 24,
    --#endif
    - 
    -           SALT_SIZE_MAX  // XXX: Keep the above sorted!
    -   };
    -@@ lib/salt.c: gensalt(enum encrypt_method m)
    -   case ENCRYPT_METHOD_BCRYPT:
    -           len = SALT_LEN_BCRYPT;
    -           break;
    --#endif
    --#ifdef USE_YESCRYPT
    --  case ENCRYPT_METHOD_YESCRYPT:
    --          len = SALT_LEN_YESCRYPT;
    --          break;
    - #endif
    -   }
    - 
     @@ lib/salt.c: gensalt(enum encrypt_method m)
       * An additional parameter is provided.
       *  * For the SHA256 and SHA512 method, this specifies the number of rounds
    @@ po/shadow.pot: msgstr ""
      
      #: src/chgpasswd.c:174
     
    - ## share/ansible/roles/ci_run/tasks/alpine.yml ##
    -@@
    -     ./autogen.sh
    -     --disable-man
    -     --disable-nls
    --    --with-yescrypt
    -     --without-selinux
    -   args:
    -     chdir: /usr/local/src/shadow/
    -
      ## share/ansible/roles/ci_run/tasks/debian.yml ##
     @@
        ansible.builtin.command: >

Comment thread tests/unit/test_chkhash.c
Comment on lines 178 to -182
skip(); // locale not available on this system
}

assert_true(is_valid_hash("$y$j9T$lChB5KbMj.6n51V3DjPfn0$6WyruF4YWm9GDgKx2mik5QEHE6J4m2RVXXegiqrfjI8"));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexey-tikhonov

Would you mind sending a patch using a different hashing algorithm?

@ikerexxe ikerexxe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the proper way to do things, thus I'm blocking this PR

@alejandro-colomar

alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

@thesamesam , @jubalh , @dvzrv , @zeha

I and @hallyn would like to get rid of this code. This needs some coordination with distros, since they seem to be defaulting to YESCRYPT. So, how do you see changing your defaults to something else (perhaps SHA512, which I expect is okay)?

The problem with YESCRYPT is fundamental: it requires code that is more prone to buffer overflows than the other algorithms (I wrote one, and we didn't realize for many months, and we almost merged it). I found it only by chance, when trying to actually understand the format of YESCRYPT, which is actually hard to understand.

@zeha

zeha commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

@thesamesam , @jubalh , @dvzrv , @zeha

I and @hallyn would like to get rid of this code. This needs some coordination with distros, since they seem to be defaulting to YESCRYPT. So, how do you see changing your defaults to something else (perhaps SHA512, which I expect is okay)?

Debian enables YESCRYPT because Debian's PAM uses YESCRYPT.

From my PoV, ideally shadow with PAM would delegate all password hashing and manipulation to PAM, and let PAM do whatever it thinks is right.
Until this is the case, both programs should agree on the settings.

Or, if this is already the case, then ... what does --enable-yescrypt do with --with-libpam and why do the config options exist when --with-libpam is given?

@alejandro-colomar

alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

@thesamesam , @jubalh , @dvzrv , @zeha
I and @hallyn would like to get rid of this code. This needs some coordination with distros, since they seem to be defaulting to YESCRYPT. So, how do you see changing your defaults to something else (perhaps SHA512, which I expect is okay)?

Debian enables YESCRYPT because Debian's PAM uses YESCRYPT.

Is PAM's default YESCRYPT? Or is it just an existing algorithm?

Cc: @thkukuk

In Debian, the default seems to be YESCRYPT (at least, my passwords are using YESCRYPT, and I didn't change the defaults in my Debian system).

From my PoV, ideally shadow with PAM would delegate all password hashing and manipulation to PAM, and let PAM do whatever it thinks is right.

That is orthogonal at the moment. YESCRYPT is prone to bugs, because of its format design, and wherever it happens, that code will be prone to buffer overflows and other important problems. I wouldn't trust shadow doing that. I also wouldn't trust PAM doing that. YESCRYPT must be extinguished.

Until this is the case, both programs should agree on the settings.

shadow defaults to SHA512. I think PAM should also default to SHA512, indeed.

Or, if this is already the case, then ... what does --enable-yescrypt do with --with-libpam and why do the config options exist when --with-libpam is given?

No idea.

@zeha

zeha commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Is PAM's default YESCRYPT? Or is it just an existing algorithm?

Debian's PAM default is YESCRYPT.

@zeha

zeha commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

I saw quite some if (use_pam) ... code, so it doesn't seem like shadow always delegates password management to PAM. Having one tool use SHA512 and the other YESCRYPT seems bad.

@dvzrv

dvzrv commented Nov 4, 2026

Copy link
Copy Markdown

On Arch Linux we have switched from SHA512 to YESCRYPT as default in 2023: https://archlinux.org/news/changes-to-default-password-hashing-algorithm-and-umask-settings/

To understand the switching costs here: Every user's passphrase would need to be updated? 🤔

@thkukuk

thkukuk commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Is PAM's default YESCRYPT? Or is it just an existing algorithm?

Cc: @thkukuk

The default is SHA512 if no ENCRYPT_METHOD= entry in login.defs is found:

#define UNIX_DEFAULT_HASH_ALGO  UNIX_SHA512_PASS

That's hardcoded, no compile time option.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

On Arch Linux we have switched from SHA512 to YESCRYPT as default in 2023: https://archlinux.org/news/changes-to-default-password-hashing-algorithm-and-umask-settings/

The reasons for using yescrypt seem to be its better resilience, which is nice.

The problem we have with yescrypt is actually in its format for the parameters, and not the hashing algorithm itself.

Is it possible to use a different format with yescrypt? The problematic format is $y$, but it seems yescrypt also supports $7$, which seems to have a better format (it doesn't require such complex code for manipulating it). I wonder if $7$ format can be used to specify the same strong parameter values, or if it's fundamentally lacking.

To understand the switching costs here: Every user's passphrase would need to be updated? 🤔

Yes. That will be the case eventually, anyway.

@alexey-tikhonov

Copy link
Copy Markdown
Contributor

Fedora uses yescrypt by default.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

@besser82 , can yescrypt use $7$ without degrading security?

@felixonmars

felixonmars commented Nov 4, 2026 •

Copy link
Copy Markdown

$7$ means scrypt according to crypt.5.

Could we delegate yescrypt salt generation to libxcrypt’s crypt_gensalt_rn(), as Linux-PAM already does? That would avoid maintaining the parameter encoding in shadow.

This PR also makes chpasswd -e reject existing $y$ hashes, even though PAM/libxcrypt can still verify them.

@alejandro-colomar

alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

$7$ means scrypt according to crypt.5.

As far as I could find in various web searches, yescrypt supports $7$ too, as a legacy format. What I wonder is if it extended that format to support the extra features of yescrypt.

Could we delegate yescrypt salt generation to libxcrypt’s crypt_gensalt_rn(), as Linux-PAM already does? That would avoid maintaining the parameter encoding in shadow.

I do NOT trust libxcrypt doing that, because I don't trust myself doing that. $y$ is just poison.

The problem is not maintaining it here. I actually trust myself better than libxcrypt to do a good job of writing safe code. The problem is that I don't think it's possible to maintain $y$ code at all, in any project. I don't want to hide the problem under a dependency.

The underlying problem is that whoever thought that it would be nice to compact the string so much, using such a weird translation format, didn't think in the consequences of that. Compression software isn't easy. And just for a few bytes...

This PR also makes chpasswd -e reject existing $y$ hashes, even though PAM/libxcrypt can still verify them.

Yes, that's something we can discuss later, though.

@guillemj

guillemj commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Given the claims that the yescrypt support was so bad, I was expecting tons of code needed to implement it. But if I'm not missing anything this seems to get down to just the YESCRYPT_get_salt_cost and YESCRYPT_salt_cost_to_buf functions (?) totaling approx 45 lines of code (including comments and blank lines), which seems exceedingly tiny.

It seems the amount of pain this removal would cause is not proportional to the amount of code involved. If this is still considered a burden, I think the proposal to delegate this to libxcrypt makes sense.

@felixonmars

felixonmars commented Nov 4, 2026 •

Copy link
Copy Markdown

$7$ means scrypt according to crypt.5.

As far as I could find in various web searches, yescrypt supports $7$ too, as a legacy format. What I wonder is if it extended that format to support the extra features of yescrypt.

I checked libxcrypt’s implementation: $7$ appears to use scrypt compatibility mode, without the additional yescrypt flags and parameters.

@alejandro-colomar

alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Given the claims that the yescrypt support was so bad, I was expecting tons of code needed to implement it. But if I'm not missing anything this seems to get down to just the YESCRYPT_get_salt_cost and YESCRYPT_salt_cost_to_buf functions (?) totaling approx 45 lines of code (including comments and blank lines), which seems exceedingly tiny.

It's a small piece of code, but it's quite unreadable, and enough to hide a buffer overflow for more than a month to my eyes.

It seems the amount of pain this removal would cause is not proportional to the amount of code involved.

This depends on the answer to the question about $7$.

If $7$ is not viable, I suggest that people involved in YESCRYPT actively look for a replacement format that doesn't require so much brain damage. That is, please invent a format that doesn't compress the string. Just do what every other format does.

If anyone has doubts of what I mean:

  • Good: $5$rounds=999999999$
  • Bad: $y$j9T$

If this is still considered a burden, I think the proposal to delegate this to libxcrypt makes sense.

I consider this a viable (but not ideal) option, but someone please actively work in replacing that format for the future, because it's a can of worms.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

$7$ means scrypt according to crypt.5.

As far as I could find in various web searches, yescrypt supports $7$ too, as a legacy format. What I wonder is if it extended that format to support the extra features of yescrypt.

I checked libxcrypt’s implementation: $7$ appears to use scrypt compatibility mode, without the additional yescrypt flags and parameters.

Thanks!

Then, please someone involved in YESCRYPT invent a replacement format for it. $y$j9T$ is insane.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

@felixonmars

Could we delegate yescrypt salt generation to libxcrypt’s crypt_gensalt_rn(), as Linux-PAM already does? That would avoid maintaining the parameter encoding in shadow.

@guillemj

If this is still considered a burden, I think the proposal to delegate this to libxcrypt makes sense.

I've been trying to write a patch doing this, but the problem is in generating what libxcrypt calls the "prefix". That is, libxcrypt's crypt_gensalt() is the consumer of this string, not the producer. Does libxcrypt have a function for generating this string??

@alejandro-colomar

alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

@felixonmars

Could we delegate yescrypt salt generation to libxcrypt’s crypt_gensalt_rn(), as Linux-PAM already does? That would avoid maintaining the parameter encoding in shadow.

@guillemj

If this is still considered a burden, I think the proposal to delegate this to libxcrypt makes sense.

I've been trying to write a patch doing this, but the problem is in generating what libxcrypt calls the "prefix". That is, libxcrypt's crypt_gensalt() is the consumer of this string, not the producer. Does libxcrypt have a function for generating this string??

Or perhaps our source code is wrong? It feels weird that we encode the cost in the string, and then call crypt_gensalt() again with the cost. Can someone who knows about libxcrypt please check?

shadow/lib/salt.c

Lines 370 to 375 in 5d25282

} else if (streq(method, "YESCRYPT")) {
MAGNUM(result, 'y');
salt_len = YESCRYPT_SALT_SIZE;
rounds = YESCRYPT_get_salt_cost (arg);
YESCRYPT_salt_cost_to_buf (result, rounds);
#endif /* USE_YESCRYPT */

char *retval = crypt_gensalt (result, rounds, NULL, 0);

@felixonmars

Copy link
Copy Markdown

Yes, that encoding appears redundant when using libxcrypt. The prefix can be just "$y$", with the cost passed separately:

crypt_gensalt("$y$", 5, NULL, 0);

Linux-PAM uses the same approach (https://github.com/linux-pam/linux-pam/blob/v1.7.3/modules/pam_unix/passverify.c#L464) with crypt_gensalt_rn(). So shadow can skip YESCRYPT_salt_cost_to_buf() in the USE_XCRYPT_GENSALT path.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

Yes, that encoding appears redundant when using libxcrypt. The prefix can be just "$y$", with the cost passed separately:

crypt_gensalt("$y$", 5, NULL, 0);

Linux-PAM uses the same approach (https://github.com/linux-pam/linux-pam/blob/v1.7.3/modules/pam_unix/passverify.c#L464) with crypt_gensalt_rn(). So shadow can skip YESCRYPT_salt_cost_to_buf() in the USE_XCRYPT_GENSALT path.

Thanks!!

@alejandro-colomar

alejandro-colomar commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Per the other PR (#1792), we can't hide this code in libxcrypt, because musl systems (such as Alpine) use libc crypt(3), and don't provide crypt_gensalt(3).

We need to provide fallback code (and BTW, the code using libc crypt(3) is quite simple except for YESCRYPT).

So, YESCRYPT remains deprecated to me. If you want to continue using YESCRYPT, please invent a new format that isn't so brain-damaged. Or, use our YESCRYPT code at your own risk. If you want to keep it around for some time, we can indeed keep it around for some time, but you'll be using it at your own risk. I warn that bugs like buffer overflows are likely in code handling YESCRYPT.

@floppym

floppym commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Per the other PR (#1792), we can't hide this code in libxcrypt, because musl systems (such as Alpine) use libc crypt(3), and don't provide crypt_gensalt(3).

We need to provide fallback code (and BTW, the code using libc crypt(3) is quite simple except for YESCRYPT).

musl's crypt implementation does not support yescrypt, so we don't need to provide a fallback crypt_gensalt for it.

We require libxcrypt for yescrypt already, so relying on crypt_gensalt when libxcrypt is enabled seems like a non-issue.

@alejandro-colomar

alejandro-colomar commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

I still don't trust libxcrypt to handle YESCRYPT correctly. I find it risky to trust libxcrypt to handle it well. YESCRYPT's format is still poison and should be replaced by something new.

Maybe it's just me, but I find this style of hyperbolic discourse very off-putting, more so when the context is not an extremely complex implementation taking thousands of lines of code, but around 10 lines of core code…

It's the first time I ever write a buffer overflow in public code (IIRC). I'm sorry if I'm freaking out, but I'm good at recognizing code which is prone to these bugs (which is the reason I've never written one), and this is such code. 5 minutes before knowing there was a buffer overflow, I was already scared of the code, and I warned that I might write such a bug there eventually. 5 minutes after that warning, an LLM told me that indeed there was a buffer overflow there (and I verified it was true).

So, I think I have good reasons to freak out.

(there are not so many bugs that can fit in that amount of lines TBH, and bugs happen yes, which can be prevented by test suites, and running them with sanitizing enabled for example, etc.).

I disagree. The number of lines of code is entirely orthogonal to the number of bugs. I've written many thousands of lines of code in this same project, knowing I had no chances of making such a mistake. In these 10 lines of code, I knew something would go wrong, before actually knowing it was wrong.

Tests, etc., are only a mitigation. The first barrier should be not writing code that is prone to bugs in the first place.

and bugs happen yes

These bugs don't happen without a reason; not to me (so far). And I'd like it to stay like that.

@floppym

floppym commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

I think you are overreacting.

Have you found a bug in the libxcrypt implementation?
Have this bug been reported?
Has the bug gone unfixed for some extended period?

There are several distros using this code. Ripping support out of shadow based on your uneasy feelings is not acceptable.

@floppym

floppym commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Deprecating it without real evidence is unacceptable as well.

@alejandro-colomar

alejandro-colomar commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Deprecating it without real evidence is unacceptable as well.

Evidence of what? Of the format resulting in code that is unreasonably prone to buffer overflows? There's evidence of that: the buffer overflow added in #1750 v1, and fixed in #1750 v4.

The reason why it's prone to buffer overflows is that it forces the programmer to perform manual byte operations and pointer dereferences, which are inherently prone to that. We've been getting rid of such code everywhere we could, and this is one of the few places where we couldn't get rid of it.

Rust got that part of language design right. C is not a bad language, but programmers that refuse to avoid bad patterns are what give it the bad fame it has. One can write C code that is as safe as Rust, but only by avoiding patterns such as the one promoted by YESCRYPT.

What more evidence do you need?

I think you are overreacting.

Have you found a bug in the libxcrypt implementation?

No, but I don't need to. I hadn't yet found a bug in our code, when I already knew it was bad. 5 minutes later, an LLM found a buffer overflow for me. Are we waiting until we find such a vulnerability in libxcrypt's YESCRYPT code? Or can we pretty please solve the problem before it becomes an urgency?

If you wait to fix the railways until after 2 trains crash, you've got it bad. The same happens with code. Just look at the early symptoms, and you'll avoid the bad problems entirely.

@floppym

floppym commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Deprecating it without real evidence is unacceptable as well.

Evidence of what?

Evidence of a bug in libxcrypt's yescrypt gensalt code that has not been addressed by its maintainers.

Your broad claims that the format results in code that is prone to errors are not compelling to me.

@floppym

floppym commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

There's evidence of that: the buffer overflow added in #1750 v1, and fixed in #1750 v4.

Can you provide links to the affected commits? I don't see any way for me to identify those that correspond to v1 versus v4.

@alejandro-colomar

alejandro-colomar commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

There's evidence of that: the buffer overflow added in #1750 v1, and fixed in #1750 v4.

Can you provide links to the affected commits? I don't see any way for me to identify those that correspond to v1 versus v4.

I don't know if github stores old versions of PRs. Sorry. You should be able to reconstruct v1 from the range-diffs I provide in the first comment in that PR, but there's no direct link to it, AFAIK.

Deprecating it without real evidence is unacceptable as well.

Evidence of what?

Evidence of a bug in libxcrypt's yescrypt gensalt code that has not been addressed by its maintainers.

I hope we don't have evidence of that.

Your broad claims that the format results in code that is prone to errors are not compelling to me.

Our job should be preventing bugs, not reacting to them. That's how I work, at least.

@floppym

floppym commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Our job should be preventing bugs, not reacting to them. That's how I work, at least.

You are basically proposing that everyone should boycott the format because you can't figure out how to re-implement it in a way you feel is safe.

Discuss the issue with the author of yescrypt and the maintainer of libxcrypt if you think there is a problem.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

Our job should be preventing bugs, not reacting to them. That's how I work, at least.

You are basically proposing that everyone should boycott the format because you can't figure out how to re-implement it in a way you feel is safe.

Can you? Illuminate me, please.

Discuss the issue with the author of yescrypt and the maintainer of libxcrypt if you think there is a problem.

I am doing that (with the author of yescrypt; I think the author of libxcrypt is irrelevant in this discussion).

@floppym

floppym commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

You are basically proposing that everyone should boycott the format because you can't figure out how to re-implement it in a way you feel is safe.

Can you? Illuminate me, please.

At this moment no, because I do not fully understand what you find objectionable, and I do not fully understand the format myself. I can't claim to understand every piece of crypto code that runs on my system either. But I don't advocate that nobody use code I don't comprehend.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

You are basically proposing that everyone should boycott the format because you can't figure out how to re-implement it in a way you feel is safe.

Can you? Illuminate me, please.

and I do not fully understand the format myself. I can't claim to understand every piece of crypto code that runs on my system either. But I don't advocate that nobody use code I don't comprehend.

Yet you are trying to argue that I'm arbitrarily saying that the format is promoting unsafe code, as if I was some passing-by rando in a social network ranting about some random topic.

I have learnt the format (or rather, I've tried hard to learn it), and am experienced with this code base, and can say that it's some of the worst code I've seen in this code base, and I can't improve it, because of the format. I have been writing code free of buffer overflows for many years, and know when I can't because of external conditions, and this is the case.

Please try to learn about what you're talking before actually talking.

But I don't advocate that nobody use code I don't comprehend.

Again, you're implying that I don't comprehend that code. Please don't. I comprehend that code and that format enough to be able to know that one can't write code as simple as for the other algorithms. And I also comprehend that it is not intrinsically necessary of the algorithm; it's just a bad design decision from the author of the format.

At this moment no, because I do not fully understand what you find objectionable,

Well, perhaps you could try to, before dismissing my claims, don't you think?

@jubalh

jubalh commented Nov 6, 2026

Copy link
Copy Markdown
Member

I think you are overreacting.
Have you found a bug in the libxcrypt implementation?

No, but I don't need to. I hadn't yet found a bug in our code, when I already knew it was bad. 5 minutes later, an LLM found a buffer overflow for me. Are we waiting until we find such a vulnerability in libxcrypt's YESCRYPT code? Or can we pretty please solve the problem before it becomes an urgency?

If you wait to fix the railways until after 2 trains crash, you've got it bad. The same happens with code. Just look at the early symptoms, and you'll avoid the bad problems entirely.

So the LLM prevented you from publishing code with a buffer overflow. Problem solved. No trains crashed.

This clearly shows that there are tools available that make writing code with libxcrypt possible :)
So no need to remove something that some users want to have.

@floppym

floppym commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Well, perhaps you could try to, before dismissing my claims, don't you think?

I do not see you as an authority on this subject. You haven't provided a detailed explanation of the problem. You just say the format is complex and mention a couple of features of C that you don't like (byte manipulation and pointers).

Assuming the yescrypt author disagrees with you and cannot alleviate your concerns, convincing libxcrypt to deprecate it would go a long way to convince other people it is unsafe.

Until then, please keep the minimal amount of code that is necessary to keep using yescrypt via libxcrypt's interface. If there's a bug, it's not your bug and you can let others worry about it.

@alejandro-colomar

alejandro-colomar commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

So the LLM prevented you from publishing code with a buffer overflow. Problem solved. No trains crashed.

I was lucky, indeed. We might not be so lucky the next time. That's why we should discuss improving the situation.

This clearly shows that there are tools available that make writing code with libxcrypt possible :)

No; LLMs are far from being infallible at finding these issues in code. We certainly gained some time, and we should use that time wisely.

We should also learn from it. Certain bugs are orders of magnitude more likely in some code, and this is one of those cases. Can we avoid the risk in some way?

So no need to remove something that some users want to have.

We're not in a hurry, indeed. But we should not be wasting the time we've been given either. We should now discuss about improving the format so that this doesn't happen again. We don't need to drop YESCRYPT, but we should consider adding a new format for it.

@floppym

floppym commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

We don't need to drop YESCRYPT, but we should consider adding a new format for it.

That certainly seems like a reasonable path forward.

@alejandro-colomar

alejandro-colomar commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Well, perhaps you could try to, before dismissing my claims, don't you think?

I do not see you as an authority on this subject.

Well, perhaps you should. In the subject of writing safe code and finding unsafe patterns, I believe I am quite an expert. You can ask those that work with me closely.

You haven't provided a detailed explanation of the problem.

The format seems to be $y$...$, with 3 characters between the '$'.

Here's the function that produces that:

        static const char *YESCRYPT_salt_cost(unsigned long cost)
        {
                char         *p;
                static char  buf[5];

                /*
                 * We are going to write four bytes,
                 * plus one byte for the terminator.
                 *    jXX$
                 *    0000
                 *    1234
                 */

                p = buf;
                p = stpcpy(p, "j");  // 'j' == YESCRYPT_DEFAULTS
                if (cost < 3)
                        *p++ = 0x36 + cost;
                else if (cost < 6)
                        *p++ = 0x34 + cost;
                else
                        *p++ = 0x3b + cost;

                p = stpcpy(p, (cost >= 3) ? "T" : "5");
                stpcpy(p, "$");

                return buf;
        }

The first byte seems (but I'm not sure) to be a single uint8_t flags
parameter.  This is more or less okay, since we can write it as a
single-letter string literal, written with stpcpy(3).

The next two bytes seem to encode several parameters in two bytes, and
it's unclear how that's done.  I expect it's actually done as a look-up
table, and the values have been just hard-coded in the code.  One
couldn't find a logic bug in those values even if looking at it for
hours.  For example: one could change the parameters to use a
significantly cheaper cost, and we wouldn't notice.  Because the string
being produced isn't telling the cost to a human.

So far, it seems clear that misconfiguring parameters is easy.  But
there's a second problem, which is that of buffer overflows.  One of the
keys to avoid buffer overflows is to avoid manual operations on bytes.
That is, one should treat string pointers as opaque objects which are
to be handled by str*() functions.  Here, we're manually dereferencing
the pointer and performing pointer arithmetic on it.  That has a high
risk of producing buffer overflow.  (Rust disallowed dereferences in safe code for a reason.)

You just say the format is complex

    -  Good: $5$rounds=999999999$
    -  Bad: $y$j9T$

Writing code for producing 'rounds=999999' is trivial, such as
snprintf(s,n,"rounds=%d",rounds).  Writing code for producing 'j9T' is NOT
trivial.  Similarly when reading.  Saving a few bytes is not worth it.

and mention a couple of features of C that you don't like (byte manipulation and pointers).

You keep talking as if I was some weirdo who arbitrarily dislikes some features. Rust disallows dereferences in safe code for a reason. BTW, I'm member of the C Commitee, in case you doubt my understanding of C.

[...]

Until then, please keep the minimal amount of code that is necessary to keep using yescrypt via libxcrypt's interface.

I will.

If there's a bug, it's not your bug and you can let others worry about it.

Of course; that's what I said.

@floppym

floppym commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Thank you for the explanation. I will add a couple comments, though I expect you already know these details.

In the yescrypt reference implementation (and libxcrypt), the parameter string (eg. $j9T$) is generated by packing several 32-bit integer values into a smaller number of bits using a bespoke algorithm that encodes smaller numbers in fewer bits. These packed values are then output as base64.

The packing algorithm is quite hard for me to follow, but Claude gives a pretty nice summary. I can understand why you might not want to implement that yourself; it makes my head hurt looking at it.

@alejandro-colomar

alejandro-colomar commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thank you for the explanation. I will add a couple comments, though I expect you already know these details.

Yup.

[...]

The packing algorithm is quite hard for me to follow, [...]. I can understand why you might not want to implement that yourself; it makes my head hurt looking at it.

Heh! Indeed, that is the feeling. It seems now we're on the same page. :)

@alejandro-colomar
alejandro-colomar marked this pull request as draft November 7, 2026 12:13
@kyrias

kyrias commented Nov 7, 2026

Copy link
Copy Markdown

I'm confused by the fact that you keep blaming the complexity of the format for you having implemented a buffer overflow, ("manual byte operations and pointer dereferences", "Here, we're manually dereferencing the pointer and performing pointer arithmetic on it",) but the actual buffer overflow bug appears to be entirely unrelated to any of this.

This is the part of the diff that had the bug:

0cb8001#diff-a69570be0b33e25224d86849a25125570656605fc205d787135a9b653ee1367dL266-R286

We have a comment (correctly) stating that we're building a string of 4 bytes, but we're only creating a buffer of 4 bytes, meaning that we don't have enough space for the terminating null. This then leads to the final stpcpy call to write its null character one byte past the end. There's a single character in the middle which happens to use pointer dereferences to directly write a character into the buffer, but that part doesn't have any bug, and can clearly only ever write a single byte into the buffer.

So I'm confused as to how you could possibly blame this specific bug on the format being complicated? We could implement the exact same operation with a single sprintf call instead and still have the exact same bug:

static const char *salt_cost(unsigned long cost) {
    static char buf[4];

    char c;
    if (cost < 3) {
        c = 0x36 + cost;
    } else if (cost < 6) {
        c = 0x34 + cost;
    } else {
        c = 0x3b + cost;
    }

    sprintf(buf, "j%c%c$", c, (cost >= 3) ? 'T' : '5');
    return buf;
}

Additionally, while I definitely agree that the variable-length encoding is kind of complicated to understand, one kind of doesn't even have to fully understand how it works.

If you reimplement the version from the reference implementation exactly (the 12 lines here) then you will end up with the correct variable-length encoded version of the parameter. This would always be safe since all it does is perform a bunch of integer arithmetic on values that are all uint32_t. Not a single pointer in sight.

The bit at the end of the function then Base64 encodes this encoded value. (Or are we going to claim that the entire concept of Base64 is too complicated and brain damaged as well?)

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

[...]

So I'm confused as to how you could possibly blame this specific bug on the format being complicated?

It produced confusing code, which degrades human review, which causes bugs nearby.

We could implement the exact same operation with a single sprintf call instead and still have the exact same bug:

static const char *salt_cost(unsigned long cost) {
    static char buf[4];

    char c;
    if (cost < 3) {
        c = 0x36 + cost;
    } else if (cost < 6) {
        c = 0x34 + cost;
    } else {
        c = 0x3b + cost;
    }

    sprintf(buf, "j%c%c$", c, (cost >= 3) ? 'T' : '5');
    return buf;
}

This is an interesting implementation, indeed. I believe this might have been caught by the compiler at compile time.

Additionally, while I definitely agree that the variable-length encoding is kind of complicated to understand, one kind of doesn't even have to fully understand how it works.

If you reimplement the version from the reference implementation exactly (the 12 lines here) then you will end up with the correct variable-length encoded version of the parameter. This would always be safe since all it does is perform a bunch of integer arithmetic on values that are all uint32_t. Not a single pointer in sight.

Safe as in memory-safe, but far from logic-bug-safe. But yeah, better than what we had.

The bit at the end of the function then Base64 encodes this encoded value. (Or are we going to claim that the entire concept of Base64 is too complicated and brain damaged as well?)

Base64 isn't bad per se, but using it unnecessarily is quite OTT.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

[...]

So I'm confused as to how you could possibly blame this specific bug on the format being complicated?

It produced confusing code, which degrades human review, which causes bugs nearby.

We could implement the exact same operation with a single sprintf call instead and still have the exact same bug:

static const char *salt_cost(unsigned long cost) {
    static char buf[4];

    char c;
    if (cost < 3) {
        c = 0x36 + cost;
    } else if (cost < 6) {
        c = 0x34 + cost;
    } else {
        c = 0x3b + cost;
    }

    sprintf(buf, "j%c%c$", c, (cost >= 3) ? 'T' : '5');
    return buf;
}

This is an interesting implementation, indeed. I believe this might have been caught by the compiler at compile time.

Additionally, while I definitely agree that the variable-length encoding is kind of complicated to understand, one kind of doesn't even have to fully understand how it works.
If you reimplement the version from the reference implementation exactly (the 12 lines here) then you will end up with the correct variable-length encoded version of the parameter. This would always be safe since all it does is perform a bunch of integer arithmetic on values that are all uint32_t. Not a single pointer in sight.

Safe as in memory-safe, but far from logic-bug-safe. But yeah, better than what we had.

Self-correction: not safe.

*dst++ = itoa64[start + (src >> bits)];

Those 12 lines you highlighted are used for producing an offset which seems later used in a dereference. That's not safe, unless I'm missing something.

The bit at the end of the function then Base64 encodes this encoded value. (Or are we going to claim that the entire concept of Base64 is too complicated and brain damaged as well?)

Base64 isn't bad per se, but using it unnecessarily is quite OTT.

@kyrias

kyrias commented Nov 7, 2026

Copy link
Copy Markdown

So I'm confused as to how you could possibly blame this specific bug on the format being complicated?
It produced confusing code, which degrades human review, which causes bugs nearby.

I don't buy this. While the reason behind the three different offsets depending on the cost's range is opaque (since it's treating a doubly encoded value as a magic value), we're talking about 9 lines of C code that are very easy to understand what they do. I don't at all believe that if it was instead p = stpcpy(p, '0' + cost); or something else without any real logic to it, that a reviewer would have been any more or less likely to discover the buffer overflow.

If anything I'd say that a reviewer would on average be more likely to be confused by the repeated use of stpcpy, (which compared to the other C string handling functions is relatively speaking uncommonly used,) to construct a 4 character string where half of them are constants.

This is an interesting implementation, indeed. I believe this might have been caught by the compiler at compile time.

gcc can, clang does by default, yes.

Safe as in memory-safe, but far from logic-bug-safe. But yeah, better than what we had.

Self-correction: not safe.

*dst++ = itoa64[start + (src >> bits)];

Those 12 lines you highlighted are used for producing an offset which seems later used in a dereference. That's not safe, unless I'm missing something.

I was very explicitly talking specifically about re-using the implementation of the variable-length encoding itself, which is what you mentioned finding confusing above. I very intentionally said nothing about the Base64 encoding, which you could then implement in whichever way you find most appealing. The simplest option would of course be to just take the result & 0x3F as is done with the second array indexing operation and you'd again ensure that it would always be memory safe. (Unless we're dealing with a C implementation where char is signed and the VLE implementation can somehow lead to start + (src >> bits) overflowing.)

*dst++ = itoa64[(start + (src >> bits)) & 0x3F];

@kyrias

kyrias commented Nov 8, 2026 •

Copy link
Copy Markdown

Because I agree that the reference implementation is inscrutable, here's a reimplementation that I threw together in case anyone want to better understand how the format works: https://c.godbolt.org/z/YonW5Prj7

That godbolt link has the reference implementation, my reimplementation, and a version that executes both of them against all representable values and asserts that the output is the same.

My version is definitely longer, but I'd argue significantly more readable, and amusingly clang's generated assembly is shorter for my version.

@solardiz

solardiz commented Nov 8, 2026 •

Copy link
Copy Markdown

yescrypt designer and upstream maintainer here.

I agree the hash encoding format is more complicated than many of us (me included) would have liked, but there are technical and historical reasons for that, as well as other people's preferences. Some actually prefer compact over verbose encoding - we just won't see them complaining unless we make the encoding too verbose instead.

The comparison against sha512crypt's human-friendly encoding does not fully apply. sha512crypt is merely an iterated password hash, whereas the full upstream yescrypt is iterated, cache-hard, memory-hard, and optionally ROM-port-hard, and is both a KDF and a password hash, and there are defined ways to encode usage of larger caches and wider SIMD for future scalability. This flexibility required many more parameters, most of them optional.

One alternative would have been splitting this into several formats specified separately by use case, but even then it'd be multiple parameters per use case.

So what we did for libxcrypt instead was define a mapping from a linear cost parameter (which used to specify the iteration count with previous generation password hashes like bcrypt and sha512crypt) to lower-level yescrypt parameter sets that are currently optimal for this use case. As it happens, we only vary the values of two lower-level parameters while keeping the rest constant or even at implied defaults. I think this mapping is actually simple enough for what it achieves.

With this, we effectively already have and use a lighter version/subset of yescrypt, where much of the other-use-case and maybe-future functionality is not exposed and the complicated encoding for those many parameters is mostly an internal detail - it can be viewed as opaque mapping from 11 linear values to two-character substrings. Not that hard, really.

Possible alternative formats I can think of would be either putting the linear cost value into the string directly or putting the current multiple parameters in there in human-readable form like it's commonly done for Argon2. Both of these have drawbacks.

One of my design goals for the current yescrypt format was non-redundancy - there should be exactly one way to encode a combination of parameter values. This is desirable e.g. for password security audits. We have a lot of extra complexity for detecting and dealing with effectively duplicate encodings for other hash types in John the Ripper. I didn't want more of that.

If we introduce any new format now, it will already be a duplicate with the current one. Further, if we make it a human-readable format, there's a further question of whether we nevertheless enforce strict field order and minimal representation (e.g., no leading 0's in integers, all parameters must always be specified explicitly or if any have defaults then those with default values must always be omitted) or not (with more duplicates then). This will be way beyond the level of complexity that scared Alejandro here, and in many projects it will be on top of also supporting the current format for years.

Focusing on the shadow project's needs, I think the approach taken in #1794 to use libxcrypt's crypt_gensalt is good at least for now, because yescrypt support here is currently only needed on systems with libxcrypt. musl doesn't have crypt_gensalt, but it also doesn't have yescrypt yet.

I also think Alejandro's originally desired cleaned up function isn't actually hard to write without a buffer overflow, e.g. here are a few ways to do it, along with defensive programming against wrong inputs to the function, and a test program:

#include <stdio.h>

#if 1
static /*@observer@*/const char *YESCRYPT_salt_cost(unsigned long cost)
{
	static char  buf[5];
	if (cost < 1 || cost > 11)
		return NULL;
	buf[0] = 'j'; // flags = YESCRYPT_DEFAULTS
	buf[1] = "78789ABCDEF"[cost - 1]; // Nlog2 from 10 to 18
	buf[2] = cost < 3 ? '5' : 'T'; // r = 8 (1 KiB) or r = 32 (4 KiB)
	buf[3] = '$';
	buf[4] = 0;
	return buf;
}
#elif 1
static /*@observer@*/const char *YESCRYPT_salt_cost(unsigned long cost)
{
	static char  buf[5] = "j..$"; // Assumes the caller won't overwrite
	if (cost < 1 || cost > 11)
		return NULL;
	buf[1] = "78789ABCDEF"[cost - 1]; // Nlog2 from 10 to 18
	buf[2] = cost < 3 ? '5' : 'T'; // r = 8 (1 KiB) or r = 32 (4 KiB)
	return buf;
}
#else
static /*@observer@*/const char *YESCRYPT_salt_cost(unsigned long cost)
{
	static const char bufs[11][5] = {"j75$", "j85$", "j7T$", "j8T$", "j9T$", "jAT$", "jBT$", "jCT$", "jDT$", "jET$", "jFT$"};
	if (cost < 1 || cost > 11)
		return NULL;
	return bufs[cost - 1];
}
#endif

int main(void)
{
	unsigned long cost;
	for (cost = 0; cost <= 12; cost++)
		puts(YESCRYPT_salt_cost(cost) ?: "NULL");
}

It is not necessary to (re-)add any code like this to shadow now, as long as the rest of the support stays and the system uses libxcrypt. Please just do not deprecate and drop the remaining support. Doing so would result in multiple distros re-adding such support via patches maintained downstream, which would waste effort and make things worse for the ecosystem (more code variations and thus potentially more bugs too).

@alejandro-colomar

alejandro-colomar commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

yescrypt designer and upstream maintainer here.

[...]

Hi @solardiz !

Thanks a lot for the detailed comment.

I like the translation using the string literal "78789ABCDEF"[cost - 1].

Yes, this does dereference an array, which I recall is something Alejandro complained about elsewhere in this discussion (maybe not in this specific PR), but I don't see anything wrong about intended use of a language feature.

Being a literal (thus, with compile-time-known bounds), and with a bounds check in the previous line of code, it seems safe.

Combining with @kyrias 's sprintf(3) suggestion, we can get a quite simple implementation:

static const char *
YESCRYPT_salt_cost(unsigned long cost)
{
	static char buf[5];

	if (cost < 1 || cost > 11)
		return NULL;
	sprintf(buf, "j%c%c$", "78789ABCDEF"[cost - 1], (cost >= 3) ? 'T' : '5'));
	return buf;
}

While the rationale for the specific values is not clear, we can now see all possible translations easily (and assume it's a look-up-table of magic values). And the compiler is able to check whether the bounds of the arrays are respected. sprintf(3) writes exactly 5 bytes, so buf is safe, and the string literal is accessed at somewhere between 0 and 10, which is known to be in bounds.

And the previous check is obvious now: it's there for the string literal bounds.

I feel now okay with this format, and have removed the deprecation notice. Thanks!
In fact, if musl feels like adding support for yesccrypt, I'd be okay adding back support for the non-libxcrypt path using this implementation.

I wonder something about the difference between this and the code we had: we allowed any value of cost, while this now restricts the values to 1..11. Should we have been rejecting a value of 12 before? Was it a bug?

@alejandro-colomar

alejandro-colomar commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

BTW, I'm pretty happy to have been using stpcpy(3) instead of writing the last byte manually with p = '\0';. Even if the buffer overflow would have been published in a release, the fortification in libc of stpcpy(3) means we would have crashed instead of actually going off-bounds. :-)

It seems this commit has already paid off: 59e5eef (2024-07-03; "contrib, lib/, src/, tests/: Use stpcpy(3) instead of its pattern").

diff
$ git show 59e5eef38f899 -- lib/salt.c
commit 59e5eef38f899cf00925f0117e3173566b14a5fc
Author: Alejandro Colomar <alx@kernel.org>
Date:   2024-05-19 01:53:12 +0200

    contrib, lib/, src/, tests/: Use stpcpy(3) instead of its pattern
    
    Signed-off-by: Alejandro Colomar <alx@kernel.org>

diff --git a/lib/salt.c b/lib/salt.c
index 529d59cace4d..efef4e59ca4f 100644
--- a/lib/salt.c
+++ b/lib/salt.c
@@ -291,6 +291,7 @@ static /*@observer@*/unsigned long YESCRYPT_get_salt_cost (/*@null@*/const int *
 static /*@observer@*/void YESCRYPT_salt_cost_to_buf (char *buf, unsigned long cost)
 {
        const size_t buf_begin = strlen (buf);
+       char  *p;
 
        /*
         * Check if the result buffer is long enough.
@@ -302,17 +303,17 @@ static /*@observer@*/void YESCRYPT_salt_cost_to_buf (char *buf, unsigned long co
         */
        assert (GENSALT_SETTING_SIZE > buf_begin + 4);
 
-       buf[buf_begin + 0] = 'j';
+       p = &buf[buf_begin];
+       p = stpcpy(p, "j");
        if (cost < 3) {
-               buf[buf_begin + 1] = 0x36 + cost;
+               *p++ = 0x36 + cost;
        } else if (cost < 6) {
-               buf[buf_begin + 1] = 0x34 + cost;
+               *p++ = 0x34 + cost;
        } else {
-               buf[buf_begin + 1] = 0x3b + cost;
+               *p++ = 0x3b + cost;
        }
-       buf[buf_begin + 2] = cost >= 3 ? 'T' : '5';
-       buf[buf_begin + 3] = '$';
-       buf[buf_begin + 4] = '\0';
+       p = stpcpy(p, (cost >= 3) ? "T" : "5");
+       stpcpy(p, "$");
 }
 #endif /* USE_YESCRYPT */
 
@@ -330,7 +331,7 @@ static /*@observer@*/const char *gensalt (size_t salt_size)
                strcat (salt, l64a (csrand ()));
        } while (strlen (salt) < salt_size);
 
-       salt[salt_size] = '\0';
+       stpcpy(&salt[salt_size], "");
 
        return salt;
 }
@@ -421,7 +422,7 @@ static /*@observer@*/const char *gensalt (size_t salt_size)
                salt_len = GENSALT_SETTING_SIZE - 1;
                rounds = 0;
                memset(result, '.', salt_len);
-               result[salt_len] = '\0';
+               stpcpy(&result[salt_len], "");
        }
 
        char *retval = crypt_gensalt (result, rounds, NULL, 0);

@solardiz

solardiz commented Nov 8, 2026

Copy link
Copy Markdown

Thank you, @alejandro-colomar!

I wonder something about the difference between this and the code we had: we allowed any value of cost, while this now restricts the values to 1..11. Should we have been rejecting a value of 12 before? Was it a bug?

I seem to recall this having been pre-checked in the calling code, but we may take a closer look and confirm now.

libxcrypt currently defines the mapping for this range only, which corresponds to the range from 1 MiB to 1 GiB. Some sane limit is needed as this can quickly get to ridiculous sizes, and besides I think *p++ = 0x3b + cost; would only have worked kind of right until letter Z.

@alejandro-colomar

Copy link
Copy Markdown
Collaborator Author

Thank you, @alejandro-colomar!

I wonder something about the difference between this and the code we had: we allowed any value of cost, while this now restricts the values to 1..11. Should we have been rejecting a value of 12 before? Was it a bug?

I seem to recall this having been pre-checked in the calling code, but we may take a closer look and confirm now.

libxcrypt currently defines the mapping for this range only, which corresponds to the range from 1 MiB to 1 GiB. Some sane limit is needed as this can quickly get to ridiculous sizes, and besides I think *p++ = 0x3b + cost; would only have worked kind of right until letter Z.

Hmmm, indeed, we have checks elsewhere:

/* Sanity checks. */

Now they make sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.