Password Encryption and User Authentication

 

Password Encryption and User Authentication
Chapter 8 Part 4 — How crypt() Works and Writing a Login Authenticator in C
Part 4
of 4
Intermediate
Level
~25 min
Read Time
What You Will Learn

Every time you type your password to log in to Linux, the system does not compare your typed password directly against a stored copy. Instead, it encrypts what you typed and compares that encrypted version against the stored encrypted version. If they match, you are in.

This part explains the one-way encryption algorithm behind Linux passwords, how the crypt() function works, what salts are and why they matter, and how to write a complete password authentication program in C — the same logic that the standard login program uses.

Key Concepts in This Tutorial
One-Way Encryption crypt() function Salt DES Algorithm SHA-512 Hashing Dictionary Attack Rainbow Table getpass() Cleartext Zeroing -lcrypt link flag
The Core Idea: One-Way Encryption

Imagine a special paper shredder. You put a document in, it produces a unique pile of confetti. Given the same document, it always produces the exact same confetti pattern. But given the confetti, you cannot reconstruct the original document. That is one-way encryption.

Linux password authentication works exactly on this principle:

/* The authentication flow */

/* Step 1: When you set your password */
your_password = "MySecurePass123"
stored_hash   = one_way_encrypt(your_password + random_salt)
/* stored_hash is what goes into /etc/shadow */

/* Step 2: When you log in later */
typed_password = "MySecurePass123"  /* (or whatever you type) */
candidate_hash = one_way_encrypt(typed_password + same_salt)

/* Step 3: Compare */
if (candidate_hash == stored_hash):
    LOGIN SUCCESSFUL
else:
    ACCESS DENIED

/* The system NEVER decrypts the stored hash back to plaintext */
/* It has no way to — and that is the whole point */

Even the system administrator cannot find out your password. They can only reset it. This is fundamentally different from encryption-based storage where a key could theoretically decrypt the data.

What Is a Salt and Why Is It Needed?

A salt is a random string mixed into your password before hashing. Without a salt, a weakness emerges: if two users have the same password, their hashes would be identical. An attacker who sees the hash file could immediately spot duplicate passwords.

Worse, an attacker could precompute the hashes of millions of common passwords (a rainbow table) and then look up any stolen hash instantly.

/* WITHOUT salt — two users with same password have same hash */
alice: hash("password123") = "a7b9c3..."
bob:   hash("password123") = "a7b9c3..."
/* An attacker sees these are the same immediately */

/* WITH salt — same password produces completely different hashes */
alice: salt="Kx8r2m", hash("password123" + "Kx8r2m") = "f3a9b1..."
bob:   salt="Pq7nYt", hash("password123" + "Pq7nYt") = "2c8e5d..."
/* Completely different hashes — no pattern visible */

The salt is not secret — it is stored alongside the hash in /etc/shadow. Its purpose is to make precomputation impractical, not to add secrecy. An attacker must crack each password individually, from scratch, because the salt is different for every account.

The crypt() Function

The crypt() function is the standard POSIX interface for password hashing. It takes the plaintext key and a salt string, and returns the hash.

#define _XOPEN_SOURCE   /* Required to expose crypt() declaration */
#include <unistd.h>

char *crypt(const char *key, const char *salt);

/* key  = the plaintext password to hash */
/* salt = the salt string (format depends on algorithm) */
/* Returns: pointer to a static string containing the hash */
/*          or NULL on error */

The salt format tells crypt() which algorithm to use:

/* Salt format: $id$saltvalue$ */

/* Old DES: just 2 printable characters from [a-zA-Z0-9./] */
crypt("mypassword", "aB")
/* Returns: 13-character string starting with "aB" */

/* MD5: $1$  (outdated, avoid in new code) */
crypt("mypassword", "$1$randomsalt")
/* Returns: string starting with $1$randomsalt$ */

/* SHA-256: $5$ */
crypt("mypassword", "$5$randomsalt")

/* SHA-512: $6$ (recommended for modern Linux) */
crypt("mypassword", "$6$randomsalt")
/* Returns: 86-character hash prefixed with $6$randomsalt$ */

A crucial trick: the stored hash in /etc/shadow already contains the salt as its prefix. So when authenticating, you can pass the stored hash itself as the salt argument, and crypt() will extract the salt automatically from the first few characters.

/* Authenticating a user: pass the stored hash as the salt */
const char *stored_hash = "$6$Kx8r2mYp$LongHashString...";
const char *typed_password = "what_user_typed";

/* crypt() reads the $6$Kx8r2mYp$ prefix from stored_hash
   and uses it as the salt — same salt = same hash if password matches */
char *computed = crypt(typed_password, stored_hash);

if (strcmp(computed, stored_hash) == 0)
    printf("Password correct!\n");
else
    printf("Wrong password!\n");
Linking Against the Crypt Library

On Linux, crypt() is in a separate library. You must link against it with -lcrypt:

/* Compile command — note the -lcrypt flag */
$ gcc -o auth_program auth_program.c -lcrypt

/* Without -lcrypt you get a linker error like: */
/* undefined reference to `crypt' */

/* On newer glibc (2.28+), crypt() moved to libxcrypt */
/* The -lcrypt flag still works as libxcrypt is backward-compatible */

/* On Ubuntu/Debian you may need to install it first */
$ sudo apt install libxcrypt-dev
Reading a Password Without Echo: getpass()

When a user types their password, you do not want it to appear on the screen. The getpass() function handles this by temporarily disabling terminal echo, reading the password, and then restoring normal terminal settings.

#include <unistd.h>

char *getpass(const char *prompt);
/* prompt = the string to display (e.g. "Password: ") */
/* Returns: pointer to static buffer containing the password (no newline) */
/*          or NULL on error */

/* Usage */
char *typed = getpass("Enter your password: ");
/* The cursor waits, nothing is echoed as user types */
/* Returns the typed string once user presses Enter */

There is an important security responsibility that comes with getpass(). The returned buffer holds the plaintext password in memory. You must zero it out immediately after use, before losing the pointer.

/* Secure pattern: zero the cleartext immediately after hashing */
char *typed = getpass("Password: ");

/* Hash it right away */
char *hash = crypt(typed, stored_hash);

/* IMMEDIATELY overwrite the plaintext in memory */
char *p = typed;
while (*p != '\0')
    *p++ = '\0';

/* Now compare the hash */
if (strcmp(hash, stored_hash) == 0)
    printf("Authentication successful\n");

Why bother zeroing? The plaintext sits in memory even after you stop using it. A privileged process reading /dev/mem, or a swap file on disk, could expose it if not cleared. Zeroing immediately minimizes the window of exposure.

Complete Authentication Program

Here is a complete, working user authentication program that reads a username, retrieves the shadow password record, prompts for the password, and validates it using crypt(). This is essentially what the standard login(1) program does at its core.

#define _XOPEN_SOURCE    /* For crypt() */
#define _BSD_SOURCE      /* For getpass() on older glibc */
#define _DEFAULT_SOURCE  /* For getpass() on newer glibc */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pwd.h>
#include <shadow.h>
#include <errno.h>

int main(void) {
    char username[256];
    struct passwd *pwd;
    struct spwd   *spwd;
    char *typed_pw;
    char *encrypted;
    int  auth_ok;
    char *p;

    /* Step 1: Ask for username */
    printf("Username: ");
    fflush(stdout);
    if (fgets(username, sizeof(username), stdin) == NULL)
        return 1;

    /* Remove trailing newline from fgets */
    size_t len = strlen(username);
    if (len > 0 && username[len - 1] == '\n')
        username[len - 1] = '\0';

    /* Step 2: Get public user info from /etc/passwd */
    pwd = getpwnam(username);
    if (pwd == NULL) {
        fprintf(stderr, "Error: no such user '%s'\n", username);
        return 1;
    }

    /* Step 3: Get shadow password record from /etc/shadow */
    errno = 0;
    spwd = getspnam(username);
    if (spwd == NULL) {
        if (errno == EACCES) {
            fprintf(stderr, "Error: permission denied reading shadow file\n");
            fprintf(stderr, "Hint: run this program as root\n");
            return 1;
        }
        /* Shadow not found — fall back to passwd field */
        /* (only works if shadow is disabled, rare on modern systems) */
    }

    /* Use shadow password if available */
    if (spwd != NULL)
        pwd->pw_passwd = spwd->sp_pwdp;

    /* Step 4: Read password without echoing to terminal */
    typed_pw = getpass("Password: ");
    if (typed_pw == NULL) {
        fprintf(stderr, "Error reading password\n");
        return 1;
    }

    /* Step 5: Encrypt the typed password using the stored hash as salt */
    /* crypt() extracts the algorithm and salt from pwd->pw_passwd prefix */
    encrypted = crypt(typed_pw, pwd->pw_passwd);

    /* Step 6: Zero the cleartext immediately — security requirement */
    for (p = typed_pw; *p != '\0'; )
        *p++ = '\0';

    if (encrypted == NULL) {
        perror("crypt");
        return 1;
    }

    /* Step 7: Compare encrypted result with stored hash */
    auth_ok = (strcmp(encrypted, pwd->pw_passwd) == 0);

    if (!auth_ok) {
        printf("Authentication failed: incorrect password\n");
        return 1;
    }

    /* Success */
    printf("Authentication successful\n");
    printf("Welcome %s (UID=%d)\n", pwd->pw_name, (int)pwd->pw_uid);

    /* From here: do whatever privileged work needs doing */

    return 0;
}
/* Compile */
$ gcc -o auth auth.c -lcrypt

/* Run as root (needed to read /etc/shadow) */
$ sudo ./auth
Username: alice
Password:        (nothing echoed)
Authentication successful
Welcome alice (UID=1001)

/* Wrong password */
$ sudo ./auth
Username: alice
Password:
Authentication failed: incorrect password
Understanding the Salt in the Hash String

Let us trace through a concrete example to make the salt mechanism crystal clear:

/* Suppose alice's shadow entry is: */
alice:$6$Kx8r2mYpABCD$VeryLongHashOf86Characters...:19500:0:90:7:::

/* Breaking down the password field */
$6$           = algorithm: SHA-512
Kx8r2mYpABCD = the salt (random, generated when password was set)
$             = separator
VeryLong...   = the 86-character SHA-512 hash of ("password" + salt)

/* When alice types "wrongpassword": */
input = crypt("wrongpassword", "$6$Kx8r2mYpABCD$VeryLong...")
      = "$6$Kx8r2mYpABCD$TOTALLYDIFFERENTHASH..."
compare("$6$Kx8r2mYpABCD$TOTALLYDIFFERENTHASH...",
        "$6$Kx8r2mYpABCD$VeryLong...")
= NOT EQUAL → wrong password

/* When alice types "correctpassword" (the real one): */
input = crypt("correctpassword", "$6$Kx8r2mYpABCD$VeryLong...")
      = "$6$Kx8r2mYpABCD$VeryLong..."  /* same output */
compare(same, same) = EQUAL → login granted
Why Wiping Cleartext Matters: Security Threats

After calling getpass() and then crypt(), the plaintext password is briefly alive in RAM. Several attack vectors can expose it if it is not erased immediately:

Core dump — program crash dumps memory to disk including the password Swap file — OS may page the memory to disk before it is freed /dev/mem — a privileged process can read raw physical memory Memory scraping — malware or debugging tools reading process memory
/* BAD: password sits in memory until the variable goes out of scope */
char *pw = getpass("Password: ");
char *hash = crypt(pw, stored);
/* ...pw still contains "MySecret123" here... */
if (strcmp(hash, stored) == 0) { /* ... */ }
/* ...still in memory here... */
return 0;  /* ...and even here until overwritten by OS */

/* GOOD: zero immediately after hashing */
char *pw = getpass("Password: ");
char *hash = crypt(pw, stored);
for (char *p = pw; *p; p++) *p = '\0';  /* wipe NOW */
/* From this point, "MySecret123" is gone from RAM */
if (strcmp(hash, stored) == 0) { /* ... */ }

Note: some compilers may optimize away a simple memset to zero if they detect the buffer is not used afterwards. Use explicit_bzero() or SecureZeroMemory() on platforms that support it:

#include <string.h>

/* explicit_bzero is guaranteed NOT to be optimized away */
explicit_bzero(pw, strlen(pw));
Algorithm Comparison: DES vs MD5 vs SHA-512
/* Algorithm evolution and characteristics */

/* DES (legacy) */
salt   = 2 characters from [a-zA-Z0-9./]
output = 13-character string
status = AVOID — considered cryptographically weak

/* MD5 ($1$) */
salt   = up to 8 characters, prefixed with $1$
output = 34-character string starting with $1$
status = WEAK — fast to compute, easy to brute-force today

/* SHA-256 ($5$) */
salt   = up to 16 characters, prefixed with $5$
output = 43-character string
status = ACCEPTABLE — much stronger than MD5

/* SHA-512 ($6$) */
salt   = up to 16 characters, prefixed with $6$
output = 86-character string
status = RECOMMENDED — standard on most modern Linux distros

/* yescrypt ($y$) — newest */
status = BEST — memory-hard, designed to resist GPU cracking
         Used on Ubuntu 22.04+, Fedora 35+

You can verify which algorithm your system uses by looking at the hash prefix in /etc/shadow:

$ sudo grep alice /etc/shadow | cut -d: -f2 | cut -c1-10
$6$Kx8r2m    (SHA-512 — the $ 6 $ prefix tells you)

/* Or check the system default */
$ grep ENCRYPT_METHOD /etc/login.defs
ENCRYPT_METHOD SHA512
Chapter 8 Complete Summary

Here is everything covered across all four parts of this chapter:

Every user has a unique UID, every group has a unique GID The kernel uses numeric IDs internally — names are for humans /etc/passwd: 7-field colon-separated public user database /etc/shadow: root-only file holding encrypted password hashes /etc/group: 4-field file defining group names, GIDs, and members getpwnam/getpwuid: lookup passwd record by name or UID getgrnam/getgrgid: lookup group record by name or GID getpwent/setpwent/endpwent: sequential scan of all users Static buffer trap: successive calls overwrite the returned struct crypt(): one-way hash function for password encryption Salt: random prefix that makes precomputation attacks infeasible getpass(): read password without echoing to terminal Security: always zero cleartext password immediately after hashing
/* Complete authentication recipe — summarized */
#include <pwd.h>
#include <shadow.h>
#include <unistd.h>
#include <string.h>

/* 1. Get username from user */
/* 2. getpwnam(username) — get public info */
/* 3. getspnam(username) — get encrypted hash */
/* 4. getpass("Password: ") — read without echo */
/* 5. crypt(typed, stored_hash) — hash with same salt */
/* 6. Immediately zero the cleartext */
/* 7. strcmp(result, stored_hash) — compare hashes */
/* Compile: gcc program.c -lcrypt */
Chapter Exercises
Exercise 1: Implement getpwnam() using setpwent, getpwent, endpwent Exercise 2: Write a program listing all users with UID above 1000 Exercise 3: Write a program that checks if a given group name exists and prints its members Exercise 4: Modify the auth program to count failed attempts and exit after 3 Exercise 5: Explain why the exercise 8-1 bug from the book prints the same UID twice
/* Starter: Implement getpwnam() yourself using iteration */
struct passwd *my_getpwnam(const char *target) {
    struct passwd *pwd;
    setpwent();
    while ((pwd = getpwent()) != NULL) {
        if (strcmp(pwd->pw_name, target) == 0) {
            endpwent();
            return pwd;
        }
    }
    endpwent();
    return NULL;  /* not found */
}

/* Test it */
struct passwd *p = my_getpwnam("alice");
if (p) printf("Found: UID=%d\n", p->pw_uid);
Chapter 8 Complete

You have finished all four parts of Chapter 8 on Linux Users and Groups. You now understand the full stack — from configuration files to C library functions to cryptographic authentication.

Back to Part 3 Explore More Courses

Leave a Reply

Your email address will not be published. Required fields are marked *