How To Sign Content with an ed25519 Keypair

Lets go over some bash scripts to help you understand how to sign and verify content with an ed25519 keypair

The idea with this post is to go over how signing content works with an ed25519 keypair (even if you don’t know what that means).

At a high level here is what we will do:

  1. Stub out project files / sub-directories
  2. Generate an ed25519 keypair for signing content
  3. Generate content to sign
  4. Sign the content
  5. Verify the content

Prerequisites

Check out this tutorial on how to install OpenSSH client on your system (mac, linux, windows). It is covered on the first part of the Lazy Article titled How to Generate an ed25519 SSH Key Pair

Project Setup

Lets run a script that stubs out our project structure.

First, begin with a new directory to work in for this tutorial.

mkdir ed25519-signing && cd ed25519-signing
touch init-project.sh

Next, lets update the empty init-project.sh with the following.

init-project.sh
#!/usr/bin/env bash

# Function to display error message and exit
error_exit() {
    echo "Error: $1" >&2
    exit 1
}

# Function to create project structure
create_project_structure() {
    local project_root="$1"

    # Create directories
    mkdir -p "${project_root}"/{content,keys,scripts} || error_exit "Failed to create directories"

    # Stub out script files
    local script_files=("gen-content.sh" "gen-keypair.sh" "init-keys-dir.sh" "sign-content.sh" "verify-content.sh")
    for file in "${script_files[@]}"; do
        touch "${project_root}/scripts/${file}" || error_exit "Failed to create ${file}"
    done
}

# Check if a project root path is provided
if [ $# -eq 0 ]; then
    error_exit "No project root path provided.\nUsage: ${0##*/} <project_root_path>"
fi

# Set the project root path
PROJECT_ROOT="$1"

# Check if the provided path is absolute
if [[ "${PROJECT_ROOT}" != /* ]]; then
    error_exit "Please provide an absolute path for the project root"
fi

# Check if the provided path exists
if [ ! -d "${PROJECT_ROOT}" ]; then
    error_exit "The provided project root path does not exist"
fi

# Change to the project root directory
cd "${PROJECT_ROOT}" || error_exit "Failed to change to project directory"

# Create project structure
create_project_structure "${PROJECT_ROOT}"

# Display success message
success_message="Project structure created successfully in:"
echo "${success_message} $(echo "${PROJECT_ROOT}" | sed "s|^${HOME}|~|")"

# Log the operation
log_file="${PROJECT_ROOT}/.project_init_log"
relative_project_root=$(echo "${PROJECT_ROOT}" | sed "s|^${HOME}|~|")
echo "$(date): Project structure created in ${relative_project_root}" > "${log_file}"

init-project-files.sh requires one argument: The file path to where we want to generate files / folders. In our case since we are already in the folder we want to work in we will use $(pwd) as the argument.

 bash +x init-project.sh $(pwd)

If the script runs successfully you should see a success message and a bunch of new files and folders in your working directory.

Project structure created successfully in: ~/dev/lazydevschool.com/ed25519-signing

Congratulations! You have successfully setup your project. Now we will add code to each script file for the different steps outlined at the beginning of this post.

Generate a keypair

We need to create a keypair if we want to sign and verify content.

The process I am about to show you is hyper specific to this tutorial on signing and verifying content. If you want a more generic resource for understanding keypair generation check out this Lazy Article on How To Generate An ed25519 SSH Keypair.

Never share your private key.

scripts/gen-keypair.sh
#!/usr/bin/env bash

project_path=$1
key_name=$2
identity=$3

private_key=$project_path/keys/$key_name
allowed_signers=$project_path/keys/allowed_signers

# create keypair
ssh-keygen -t ed25519 \
  -C "$identity" \
  -N "" \
  -f $private_key \
  -q

# create allowed_signers file
echo "$identity $(cat $private_key.pub)" > $allowed_signers

Here is a usage example for the gen-keypair.sh script.

bash +x scripts/gen-keypair.sh \
  $(pwd) \
  "id_ed25519" \
  "user@domain"

This will create a keypair and add the public key to the allowed_signers file.

If you open the allowed_signers file you should see the user@domain is associated with the public key.

Now if anyone wonders what identity is associated with certain private key you can start to discover that information by parsing the public key from the private key and then looking up the identity associated with the public key (Last section of this post talks about two ways to do this).

Generate Content (for signing)

Lets run the tree command on our project directory and redirect the output to a file.

Update scripts/gen-content.sh with the following.

scripts/gen-content.sh
#!/usr/bin/env bash

project_path=$1
output_path="$project_path/content/tree.txt"

# Create content directory if it doesn't exist
mkdir -p "$(dirname $output_path)"

# Get the current user's home directory
user_home=$(eval echo ~$USER)

# Generate tree and replace the user's home directory with ~
tree $project_path --noreport | sed "s|$user_home|~|" > $output_path

Example usage of the gen-content.sh script:

bash +x scripts/gen-content.sh $(pwd)

The script will create a file in content/tree.txt with the output of the tree command.

~/dev/lazydevschool.com/ed25519-signing
├── content
│   └── tree.txt
├── init-project.sh
├── keys
│   ├── allowed_signers
│   ├── id_ed25519
│   └── id_ed25519.pub
├── main.sh
└── scripts
    ├── gen-content.sh
    ├── gen-keypair.sh
    ├── init-keys-dir.sh
    ├── sign-content.sh
    └── verify-content.sh

Sign Content

Ok we made it to the fun part.

Lets sign the content using our private key.

The expected output from this step is a signature file. The signature file will become one of the inputs in the verification process.

Update scripts/sign-content.sh with the following.

scripts/sign-content.sh
#!/usr/bin/env bash

project_path=$1
key_name=$2
namespace=$3

content_path=$project_path/content/tree.txt
private_key_path=$project_path/keys/$key_name

cat $content_path | \
ssh-keygen -Y sign \
  -f $private_key_path \
  -n $namespace -q > $content_path.sig

The scripts/sign-content.sh requires 3 arguments.

  1. The project path $(pwd)
  2. The private key name id_ed25519
  3. The namespace author:content

Namespace

The namespace is the least intuitive part of this process (at least it was for me).

Think of the namespace as way to categorize the keys signing the content. For example, if we were running a content platform we might have authors and advertisers on the platform. We want to be able to prove content was signed by an author or signed by a advertiser.

The namespace is how we would accomplish that.

How do you know what namespace to use?

Well that is up to you and your team to agree on. It should be noted that the namespace will be part of the signed content so you will want to make sure it is descriptive and consistent.

Here is an example usage of the sign-content.sh script.

bash +x scripts/sign-content.sh \
  $(pwd) \
  "id_ed25519" \
  "author:content"

Verify Content

Up to this point you should have ed25519 keypair, content, and signature.

At a high level here is how we do the verification.

Invoke ssh-keygen -Y verify with the following arguments:

Update scripts/verify-content.sh with the following.

scripts/verify-content.sh
#!/usr/bin/env bash

project_path=$1
identity=$2
namespace=$3

allowed_signers=$project_path/keys/allowed_signers
content_path=$project_path/content/tree.txt

ssh-keygen -Y verify \
  -f $allowed_signers \
  -I $identity \
  -n $namespace \
  -s $content_path.sig < $content_path

< is a more direct way to send file content to ssh-keygen (Command Redirection). It is the same as running the following command:

cat $content_path | ssh-keygen -Y verify \
  -f $allowed_signers \
  -I $identity \
  -n $namespace \
  -s $content_path.sig

The scripts/verify-content.sh script requires 3 arguments.

  1. The project path $(pwd)
  2. The identity we are told who signed the content user@domain
  3. The namespace author:content

Example usage of the scripts/verify-content.sh script.

bash +x scripts/verify-content.sh \
  $(pwd) \
  "user@domain" \
  "author:content"

If all goes according to plan you should see similar output.

Good "author:content" signature for user@domain with ED25519 key SHA256:XP0TZ1Rcnfnf86+rlZEUTH6W9UBdzNUiYnmkABd9q5U

You can extract the public key from the private key with the following command. See how the public key matches the key in your allowed_signers file?

ssh-keygen -y -f keys/id_ed25519

You can extract the fingerprint of the public key with the following command. See how the fingerprint matches the output from your verification command?

ssh-keygen -lf keys/id_ed25519.pub

Wrapping Up

You made it! You have now signed and verified content with an ed25519 keypair.

If you are still not clear on how the signing works here is what I recomend.

Bonus Step

Try creating a second keypair and sign the same content.

Look at how different the signatures are given the same content.

diff -u content/tree.txt.sig1 content/tree.txt.sig2

Closing Remarks

I hope this post helped you understand a bit more about ed25519 keypairs and how to sign and verify content with them.

A few more ideas from where to go from here: