Skip to content

feat(crypto): Yescrypt#2421

Open
vic1707 wants to merge 7 commits intohairyhenderson:mainfrom
vic1707:yescrypt
Open

feat(crypto): Yescrypt#2421
vic1707 wants to merge 7 commits intohairyhenderson:mainfrom
vic1707:yescrypt

Conversation

@vic1707
Copy link

@vic1707 vic1707 commented Aug 10, 2025

Fixes: #2384

Hi, tried to implement it, let me know what you think 😁
I might also try to do Argon2 & Argon2Id while I'm at it. maybe later

Note: I'm learning go, my code is probably not the prettiest/idiomatic, please be kind 🙏

@vic1707 vic1707 force-pushed the yescrypt branch 2 times, most recently from aded7bf to c957c0e Compare August 22, 2025 13:49
@vic1707
Copy link
Author

vic1707 commented Aug 22, 2025

Edit: I edited the PR to have both Yescrypt and YescryptMCF functions, the first one to stay consistent with other crypto functions, the second because it's a format common enough in config files 🙃

@vic1707 vic1707 force-pushed the yescrypt branch 2 times, most recently from 67e85b3 to 8d83b8d Compare August 23, 2025 12:48
@github-actions
Copy link

This pull request is stale because it has been open for 60 days with
no activity. If it is no longer relevant or necessary, please close
it. Given no action, it will be closed in 14 days.

If it's still relevant, one of the following will remove the stale
marking:

  • A maintainer can add this pull request to a milestone to indicate
    that it's been accepted and will be worked on
  • A maintainer can remove the stale label
  • Anyone can post an update or other comment
  • Anyone with write access can push a commit to the pull request
    branch

@github-actions github-actions bot added Stale and removed Stale labels Oct 24, 2025
@vic1707
Copy link
Author

vic1707 commented Oct 28, 2025

🥲

@vic1707
Copy link
Author

vic1707 commented Nov 22, 2025

While waiting for this, I made

#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! anyhow = { version = "1.0.100", default-features = false }
//! clap = { version = "4.5.53", default-features = false, features = ["derive", "std"] }
//! clap-stdin = { version = "0.8.0", default-features = false }
//! pbkdf2 = { version = "0.13.0-rc.2", default-features = false, features = ["simple"] }
//! rand = { version = "0.9.2", default-features = false, features = ["alloc", "thread_rng"]}
//! yescrypt = { version = "0.1.0-rc.0", default-features = false, features = ["simple"] }
//! ```

use clap::{Parser, ValueEnum};
use clap_stdin::MaybeStdin;
use pbkdf2::{
    password_hash::{self, PasswordHasher, PasswordVerifier, SaltString},
    Pbkdf2,
};

const DEFAULT_PWD_LENGTH: usize = 64;
const PBKDF2_ALGO: password_hash::Ident = password_hash::Ident::new_unwrap("pbkdf2-sha512");
const PBKDF2_PARAMS: ::pbkdf2::Params = ::pbkdf2::Params {
    rounds: 600_000,   // Authelia's default == 310_000
    output_length: 64, // Authelia's default == 72, crate only supports <=64
};

#[derive(Parser)]
struct Cli {
    #[arg(long, value_enum)]
    algo: Algo,

    #[arg(short, long, default_value_t = DEFAULT_PWD_LENGTH)]
    length: usize,

    #[arg()]
    input: Option<MaybeStdin<String>>,
}

fn main() -> anyhow::Result<()> {
    let Cli {
        input,
        algo,
        length,
    } = Cli::parse();
    let salt = SaltString::generate();
    let password = input
        .map(|v| v.into_inner())
        .unwrap_or_else(|| random_string(length));

    let hash = &algo.hash_and_verify(password.as_bytes(), &salt)?;
    // dbg!(password, salt, hash);
    print!("{}", hash);

    Ok(())
}

fn random_string(lenght: usize) -> String {
    use rand::distr::{Alphanumeric, SampleString};
    Alphanumeric.sample_string(&mut ::rand::rng(), lenght)
}

#[derive(ValueEnum, Clone)]
enum Algo {
    Pbkdf2,
    Yescrypt,
}

impl Algo {
    fn hash_and_verify(&self, password: &[u8], salt: &SaltString) -> anyhow::Result<String> {
        match self {
            Self::Yescrypt => {
                let hash = ::yescrypt::yescrypt(
                    password,
                    salt.as_str().as_bytes(),
                    &::yescrypt::Params::default(),
                )?;

                ::yescrypt::yescrypt_verify(password, &hash)?;

                Ok(hash)
            }
            Self::Pbkdf2 => {
                let hcp_hash = Pbkdf2.hash_password_customized(
                    password,
                    Some(PBKDF2_ALGO),
                    None,
                    PBKDF2_PARAMS,
                    salt,
                )?;

                Pbkdf2.verify_password(password, &hcp_hash)?;

                // dirty convert to mcf format
                Ok(format!(
                    "${}${}${}${}",
                    hcp_hash.algorithm,
                    PBKDF2_PARAMS.rounds,
                    hcp_hash.salt.expect("Pbkdf2 no salt"),
                    hcp_hash.hash.expect("Pbkdf2 no hash"),
                ).replace('+', "."))
            }
        }
    }
}

Usable within gomplate via plugins (requires rust-script & rust/cargo itself)
Gomplate config:

plugins:
  yescrypt:
    cmd: .conf/hash
    args:
     - --algo=yescrypt
  pbkdf2:
    cmd: .conf/hash
    args:
     - --algo=pbkdf2

Then you use them as {{ yescrypt }} (random password) or {{ yescrypt "mypass" }}.

@hairyhenderson
Copy link
Owner

Apologies for the silence on this one - I don't have much free time to review PRs. I'll try to get to it over the coming week.

First, however, can you please rebase to resolve the conflicts?

@vic1707 vic1707 force-pushed the yescrypt branch 2 times, most recently from 87d8156 to 8a78d0e Compare January 22, 2026 08:45
@vic1707
Copy link
Author

vic1707 commented Jan 22, 2026

@hairyhenderson Rebase done, one tais is failing but I believe it's from main 🤔

@vic1707
Copy link
Author

vic1707 commented Jan 22, 2026

@hairyhenderson rebase done, one test still failling but I believe it's from main

go.mod Outdated
// is merged
require github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce

require github.com/openwall/yescrypt-go v1.0.0
Copy link
Owner

Choose a reason for hiding this comment

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

this should ideally go into the require block above with all the other direct dependencies

msg = toBytes(args[1])
default:
return nil, nil, fmt.Errorf("wrong number of args: want 2 or 3, got %d", len(args))
return nil, nil, fmt.Errorf("wrong number of args: want 1 or 2, got %d", len(args))
Copy link
Owner

Choose a reason for hiding this comment

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

thanks for fixing this, though maybe best to go in a separate PR since it's completely unrelated to this feature

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds support for the Yescrypt password hashing algorithm to gomplate's crypto functions. Yescrypt is a modern, memory-hard key derivation function designed as an extension of scrypt, providing resistance to GPU/ASIC attacks. This addresses issue #2384, which requested this functionality for use with Fedora CoreOS installations.

Changes:

  • Adds two new experimental crypto functions: crypto.Yescrypt (low-level key derivation) and crypto.YescryptMCF (password hashing with MCF format)
  • Adds comprehensive test coverage for both new functions
  • Adds documentation for both functions in YAML and Markdown formats
  • Includes a minor bug fix for an incorrect error message in parseAESArgs

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
internal/funcs/crypto.go Implements Yescrypt and YescryptMCF functions with vendored base64 encoding helpers; includes unrelated bug fix for AES error message
internal/funcs/crypto_test.go Adds comprehensive test cases for both new functions including edge cases and verification
go.mod Adds dependency on github.com/openwall/yescrypt-go v1.0.0
go.sum Adds checksums for the new dependency
docs/content/functions/crypto.md Documents both new functions with usage examples and parameter descriptions
docs-src/content/functions/crypto.yml Source documentation for generating crypto.md

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +201 to +218
// Yescrypt -
func (CryptoFuncs) Yescrypt(password, salt, cost, blockSize, keylen any) (string, error) {
N, err := conv.ToInt(cost)
if err != nil {
return "", fmt.Errorf("cost must be an integer: %w", err)
}
r, err := conv.ToInt(blockSize)
if err != nil {
return "", fmt.Errorf("blockSize must be an integer: %w", err)
}
k, err := conv.ToInt(keylen)
if err != nil {
return "", fmt.Errorf("keylen must be an integer: %w", err)
}

key, err := yescrypt.Key(toBytes(password), toBytes(salt), N, r, 1, k)
return fmt.Sprintf("%02x", key), err
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The Yescrypt function is missing the experimental check. Since this function is marked as experimental in the documentation, it should check if experimental mode is enabled. The function should use a pointer receiver (f *CryptoFuncs) instead of (CryptoFuncs) to access f.ctx, and add if err := checkExperimental(f.ctx); err != nil { return "", err } at the beginning of the function, similar to other experimental crypto functions like RSAEncrypt and DecryptAES.

Copilot uses AI. Check for mistakes.
Comment on lines +220 to +270
// YescryptMCF -
func (CryptoFuncs) YescryptMCF(args ...any) (string, error) {
cost := 14
blockSize := 8
salt, _ := RandomFuncs{}.AlphaNum(16)
input := ""
var err error

switch len(args) {
case 1:
input = conv.ToString(args[0])
case 3:
cost, err = conv.ToInt(args[0])
if err != nil {
return "", fmt.Errorf("yescrypt cost must be an integer: %w", err)
}

blockSize, err = conv.ToInt(args[1])
if err != nil {
return "", fmt.Errorf("yescrypt blockSize must be an integer: %w", err)
}

input = conv.ToString(args[2])
case 4:
cost, err = conv.ToInt(args[0])
if err != nil {
return "", fmt.Errorf("yescrypt cost must be an integer: %w", err)
}

blockSize, err = conv.ToInt(args[1])
if err != nil {
return "", fmt.Errorf("yescrypt blockSize must be an integer: %w", err)
}

salt = conv.ToString(args[2])
input = conv.ToString(args[3])
default:
return "", fmt.Errorf("wrong number of args: want 1 or 4, got %d", len(args))
}

// yescrypt requires
// - cost ∈ [10, 18]
// - blockSize ∈ [1, 32]
N := yescryptItoa64[(cost-1)&0x3f]
r := yescryptItoa64[(blockSize-1)&0x3f]
saltB64 := yescryptEncode64([]byte(salt))
settings := fmt.Sprintf("$y$j%c%c$%s", N, r, saltB64)

hash, err := yescrypt.Hash([]byte(input), []byte(settings))
return string(hash), err
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The YescryptMCF function is missing the experimental check. Since this function is marked as experimental in the documentation, it should check if experimental mode is enabled. The function should use a pointer receiver (f *CryptoFuncs) instead of (CryptoFuncs) to access f.ctx, and add if err := checkExperimental(f.ctx); err != nil { return "", err } at the beginning of the function, similar to other experimental crypto functions like RSAEncrypt and DecryptAES.

Copilot uses AI. Check for mistakes.
|------|-------------|
| `cost` | _(optional)_ the cost parameter (log₂ of the iteration count) - integer from `10` to `18` - defaults to `14` |
| `blockSize` | _(optional)_ the block size parameter (`r`) - integer from `1` to `32` - defaults to `8` |
| `salt` | _(optional)_ the salt string used in hashing - defaults to a random alphanumeric string of length 10 |
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The documentation states the default salt is "a random alphanumeric string of length 10", but the code actually generates a 16-character string with RandomFuncs{}.AlphaNum(16) (see line 224 in crypto.go). The documentation should be updated to say "length 16" instead of "length 10".

Suggested change
| `salt` | _(optional)_ the salt string used in hashing - defaults to a random alphanumeric string of length 10 |
| `salt` | _(optional)_ the salt string used in hashing - defaults to a random alphanumeric string of length 16 |

Copilot uses AI. Check for mistakes.
description: the block size parameter (`r`) - integer from `1` to `32` - defaults to `8`
- name: salt
required: false
description: the salt string used in hashing - defaults to a random alphanumeric string of length 10
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The documentation states the default salt is "a random alphanumeric string of length 10", but the code actually generates a 16-character string with RandomFuncs{}.AlphaNum(16) (see line 224 in crypto.go). The documentation should be updated to say "length 16" instead of "length 10".

Suggested change
description: the salt string used in hashing - defaults to a random alphanumeric string of length 10
description: the salt string used in hashing - defaults to a random alphanumeric string of length 16

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +226
salt, _ := RandomFuncs{}.AlphaNum(16)
input := ""
var err error
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The error from RandomFuncs{}.AlphaNum(16) is silently ignored using the blank identifier. If salt generation fails, this could lead to an empty salt being used, which would be a security issue. The error should be checked and propagated, e.g., salt, err := RandomFuncs{}.AlphaNum(16); if err != nil { return "", fmt.Errorf("failed to generate salt: %w", err) }.

Suggested change
salt, _ := RandomFuncs{}.AlphaNum(16)
input := ""
var err error
salt, err := RandomFuncs{}.AlphaNum(16)
if err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
input := ""

Copilot uses AI. Check for mistakes.
Comment on lines +260 to +266
// yescrypt requires
// - cost ∈ [10, 18]
// - blockSize ∈ [1, 32]
N := yescryptItoa64[(cost-1)&0x3f]
r := yescryptItoa64[(blockSize-1)&0x3f]
saltB64 := yescryptEncode64([]byte(salt))
settings := fmt.Sprintf("$y$j%c%c$%s", N, r, saltB64)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The function doesn't validate that cost is in the range [10, 18] and blockSize is in the range [1, 32] before constructing the settings string and calling yescrypt.Hash. While the yescrypt library will validate these and return an error, it would be more maintainable and user-friendly to validate early with clear, gomplate-specific error messages. For example, add validation like: if cost < 10 || cost > 18 { return "", fmt.Errorf("cost must be between 10 and 18, got %d", cost) } and similar for blockSize.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

this is validated by the yescrypt lib itself, should we still validate ourselves?

Comment on lines +123 to +182
func TestYescryptMCF(t *testing.T) {
t.Parallel()
c := testCryptoNS()

in := "foo"

t.Run("no arg default", func(t *testing.T) {
t.Parallel()
actual, err := c.YescryptMCF(in)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(actual, "$y$jB5$"))
})

t.Run("cost less than min", func(t *testing.T) {
t.Parallel()
_, err := c.YescryptMCF(9, 8, "salt", in)
require.ErrorContains(t, err, "N out of supported range")
})

t.Run("blockSize less than min", func(t *testing.T) {
t.Parallel()
_, err := c.YescryptMCF(14, 0, "salt", in)
require.ErrorContains(t, err, "r out of supported range")
})

t.Run("custom salt appears", func(t *testing.T) {
t.Parallel()
salt := "abc123"
actual, err := c.YescryptMCF(14, 8, salt, in)
require.NoError(t, err)
assert.Contains(t, actual, string(yescryptEncode64([]byte(salt))))
})

t.Run("wrong arg count", func(t *testing.T) {
t.Parallel()
_, err := c.YescryptMCF()
require.Error(t, err)
_, err = c.YescryptMCF(14, in) // 2 args
require.Error(t, err)
})

t.Run("hash changes with different salts", func(t *testing.T) {
t.Parallel()
a1, _ := c.YescryptMCF(in)
a2, _ := c.YescryptMCF(in)
assert.NotEqual(t, a1, a2)
})

t.Run("hash verifies", func(t *testing.T) {
t.Parallel()
hash, err := c.YescryptMCF(14, 8, "salt", in)
require.NoError(t, err)
parts := strings.Split(hash, "$")
require.GreaterOrEqual(t, len(parts), 4)
settings := strings.Join(parts[:4], "$") // "$y$jF7$<salt-b64>"
verify, err := yescrypt.Hash([]byte(in), []byte(settings))
require.NoError(t, err)
assert.Equal(t, hash, string(verify))
})
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

There is no test case for the 3-argument case of YescryptMCF, which handles cost, blockSize, input (see line 231-242 in crypto.go). A test case should be added to verify this code path works correctly, e.g., c.YescryptMCF(14, 8, "password") and verify the result has the expected cost and blockSize encoded in the hash prefix.

Copilot uses AI. Check for mistakes.
salt = conv.ToString(args[2])
input = conv.ToString(args[3])
default:
return "", fmt.Errorf("wrong number of args: want 1 or 4, got %d", len(args))
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The error message says "want 1 or 4" but the function actually accepts 1, 3, or 4 arguments (case 3 handles cost, blockSize, and input). The error message should be updated to say "want 1, 3, or 4" to accurately reflect the accepted argument counts.

Suggested change
return "", fmt.Errorf("wrong number of args: want 1 or 4, got %d", len(args))
return "", fmt.Errorf("wrong number of args: want 1, 3, or 4, got %d", len(args))

Copilot uses AI. Check for mistakes.
@vic1707
Copy link
Author

vic1707 commented Mar 13, 2026

Hi @hairyhenderson,
PR name changed ✅
Rebased on main ✅
Applied copilot suggestions ✅

@vic1707 vic1707 mentioned this pull request Mar 13, 2026
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.

Feature request: crypto.Yescrypt

3 participants