diff --git a/README.md b/README.md index 65a957d0f..b7a37af24 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,8 @@ d8 user lock test-user 10m --timeout 5m d8 user unlock test-user --timeout 5m # Reset password (bcrypt hash is required) -HASH="$(echo -n 'Test12345!' | htpasswd -BinC 10 \"\" | cut -d: -f2 | tr -d '\n')" +# d8 tools htpasswd is a drop-in analog of Apache htpasswd, so no external tool is needed +HASH="$(echo -n 'Test12345!' | d8 tools htpasswd -BinC 10 \"\" | cut -d: -f2 | tr -d '\n')" d8 user reset-password test-user "$HASH" --timeout 5m ``` diff --git a/cmd/d8/root.go b/cmd/d8/root.go index e6edb65f1..787066947 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -204,6 +204,15 @@ func execute() { fmt.Fprintf(os.Stderr, "Error executing command: %v\n", err) } - os.Exit(1) + // Commands may attach an htpasswd-style exit code via an ExitCode() + // method (see internal/tools/htpasswd); everything else exits 1. + exitCode := 1 + + var coder interface{ ExitCode() int } + if errors.As(err, &coder) { + exitCode = coder.ExitCode() + } + + os.Exit(exitCode) } } diff --git a/docs/mirror-bundle-layout.md b/docs/mirror-bundle-layout.md new file mode 100644 index 000000000..f73004ef9 --- /dev/null +++ b/docs/mirror-bundle-layout.md @@ -0,0 +1,246 @@ +# `d8 mirror` Bundle Layout + +How `d8 mirror pull` lays out the archives it writes, why it lays them out that way, and what `d8 mirror push` requires in order to route every artifact back into the correct registry repository. + +## The one rule that ties `pull` and `push` together + +The bundle is a **path-preserving snapshot of the source registry tree**. Every archive contains one or more [OCI image layouts](https://github.com/opencontainers/image-spec/blob/main/image-layout.md), and the path of a layout *inside the tar* is exactly the registry segment that layout must be pushed to. `push` performs **no path translation**: whatever relative path a layout ends up at after unpacking becomes its path in the destination registry (see the package doc comment on `PushService` in [push.go](../internal/mirror/push.go)). + +Everything below is a consequence of this single rule. `pull` encodes the destination into the archive contents; `push` reads it back out positionally. + +--- + +## 1. How `d8 mirror pull` builds the bundle + +`pull` downloads the platform, installer, security databases, modules and packages, and writes them into the bundle directory (the first positional argument) as a set of tar archives. The orchestration lives in [`PullService.Pull`](../internal/mirror/pull.go); each component is pulled by its own service under [`internal/mirror/`](../internal/mirror/) (`platform`, `installer`, `security`, `modules`, `packages`). + +### 1.1. Archives written to the bundle directory + +| Archive | Produced by | One per… | Contains (registry segments) | +|---|---|---|---| +| `platform.tar` | `platform` service | bundle | repo root, `install/`, `install-standalone/`, `release-channel/` | +| `installer.tar` | `installer` service | bundle | `installer/` | +| `security.tar` | `security` service | bundle | `security/trivy-db/`, `security/trivy-bdu/`, `security/trivy-java-db/`, `security/trivy-checks/` | +| `module-.tar` | `modules` service | module | `modules//` (main + `release/` + `extra//`) | +| `package-.tar` | `packages` service | package | `packages//` (main + `version/` + `extra//`) | +| `package-versions.tar` | `packages` service | bundle | `packages//version/` for every package | + +The segment constants are defined once in [internal/layout.go](../internal/layout.go) and reused by both `pull` and `push`, so the two sides can never drift. + +> Note the difference between `install` and `installer`. `platform.tar` carries `install/` and `install-standalone/` (the in-cluster installer images that ship with the platform), while `installer.tar` carries `installer/` (the standalone `d8` installer image family). They are distinct repositories. + +### 1.2. What each archive contains + +**`platform.tar`** — packed from the platform working directory with no prefix ([platform.go](../internal/mirror/platform/platform.go), `bundle.Pack`). The sub-layouts are already arranged on disk in their final registry shape: + +``` +platform.tar +├── index.json # Deckhouse main images -> : +├── blobs/ +├── install/ # in-cluster installer -> /install: +├── install-standalone/ # standalone in-cluster installer-> /install-standalone: +└── release-channel/ # channel + version metadata -> /release-channel: +``` + +**`installer.tar`** — the standalone installer layout, packed from a working dir whose only entry is `installer/`: + +``` +installer.tar +└── installer/ # -> /installer: + ├── index.json + └── blobs/ +``` + +**`security.tar`** — the four Trivy databases, each its own layout under `security/`: + +``` +security.tar +└── security/ + ├── trivy-db/ # -> /security/trivy-db: + ├── trivy-bdu/ + ├── trivy-java-db/ + └── trivy-checks/ +``` + +**`module-.tar`** — one archive per module, packed with the prefix `modules/` ([modules.go](../internal/mirror/modules/modules.go), `bundle.PackWithPrefix`): + +``` +module-.tar +└── modules/ + └── / + ├── index.json # module main image -> /modules/: + ├── blobs/ + ├── release/ # module channels -> /modules//release: + └── extra/ + └── / # extra images -> /modules//extra/: +``` + +**`package-.tar`** — the package analogue of `module-.tar`, prefixed with `packages/`. The release segment is named `version/` instead of `release/`: + +``` +package-.tar +└── packages/ + └── / + ├── index.json # -> /packages/: + ├── version/ # -> /packages//version: + └── extra// # -> /packages//extra/: +``` + +**`package-versions.tar`** — an aggregate of the `version/` (release-channel) layout of *every* package, built in one pass with `bundle.PackSourcesWithPrefix`, each source prefixed with `packages//version` ([packages.go](../internal/mirror/packages/packages.go)): + +``` +package-versions.tar +└── packages/ + ├── /version/ + └── /version/ +``` + +### 1.3. Naming rules and why + +- **Monolithic components get one fixed-name archive.** `platform`, `installer` and `security` are always pulled as a whole, so each is a single archive with a stable name. This keeps the bundle predictable and lets a user pass a single archive to `push --file platform.tar`. +- **Modules and packages get one archive per item** (`module-.tar`, `package-.tar`). Independent archives are what make `--include-module`/`--exclude-module` (and the package equivalents) cheap: adding or removing an item adds or removes exactly one file, without repacking anything else. It also makes a bundle incrementally extensible — you can pull one more module later and drop its archive next to the others. +- **`package-versions.tar` is always written**, regardless of `--no-packages` or any filter (`PullService.Pull` calls `PullPackageVersions` unconditionally). The package release-channel catalog is bundle-level metadata that must stay in sync on every pull, so it is never skipped. + +### 1.4. Where the registry segment comes from (prefixes) + +There are two ways the path inside the tar acquires its registry segment, and both produce the same result: + +- **On-disk placement** — `platform`, `installer` and `security` build their sub-layouts directly under the final segment names on disk (`install/`, `installer/`, `security/trivy-db/`, …) and pack the working directory with **no prefix**. The path is baked into the directory tree. +- **Pack-time prefix** — `modules` and `packages` stage each item in a bare per-item working directory and inject the segment with `PackWithPrefix("modules/")` / `PackWithPrefix("packages/")` at pack time. This is necessary precisely because each item is packed separately into its own archive. + +Either way, the invariant from the top of this document holds: **tar path == registry segment.** + +### 1.5. Atomic writes — no stub archives + +Every archive is written through [`pack.Bundle`](../internal/mirror/pack/pack.go), which stages the payload to a temporary name (`.tmp`, or `.NNNN.chunk.tmp` when chunking) and renames it to the final name **only after a successful, non-cancelled pack**. On any error — including `Ctrl+C` / timeout — the staged files are deleted. This is what guarantees the bundle directory never contains half-written or empty (`~5 KiB`) archives left over from an interrupted pull. Cancellation is also propagated cleanly through the puller so an aborted download can never be silently misread as "tag not found" and turned into a stub (see [puller.go](../internal/mirror/puller/puller.go)). + +### 1.6. Chunking + +When `--images-bundle-chunk-size` is set, `pack.Bundle` splits each archive into fixed-size parts named `.tar.NNNN.chunk` (zero-padded, 4+ digits — e.g. `platform.tar.0000.chunk`, `platform.tar.0001.chunk`), via [chunk_writer.go](../internal/mirror/chunked/chunk_writer.go). Chunking is a transport concern only: the parts concatenate back into the exact same tar stream, so the logical archive and its contents are unchanged. + +### 1.7. GOST checksums + +With `--gost-digest`, `pull` writes a `.gostsum` file next to every `.tar` and `.chunk` after the bundle is complete. These are integrity checksums for the transfer; they are not part of the archive and are ignored by `push`. + +### 1.8. Shared blobs and `index.json` merging + +Several archives can legitimately target the **same** OCI layout path. They share blobs (named by content hash, so there is never a collision) but each carries its own `index.json` that lists only the tags it contributed. When such archives are unpacked into a shared directory, a plain overwrite of `index.json` would drop every tag the last archive did not include. To prevent that, [bundle.go](../pkg/libmirror/bundle/bundle.go) **merges** `index.json` manifest lists (deduplicating by digest + `ref.name`) instead of overwriting. This merge behavior is what makes it safe to split a registry tree across many independent archives in the first place. + +### 1.9. Completeness check + +After pulling a layout, the puller cross-checks the download plan against the resulting `index.json`: every planned image must be present under its short tag, or the pull fails (`verifyPlannedImagesLanded` in [puller.go](../internal/mirror/puller/puller.go)). An incomplete bundle fails loudly at pull time instead of surfacing later as `ImagePullBackOff` in an air-gapped cluster. + +--- + +## 2. What `d8 mirror push` expects and how it routes artifacts + +`push` takes the bundle (a directory, a single archive, or files passed via `--file`), unpacks everything into one unified tree, and pushes each layout to the segment its path dictates. The orchestration is [`PushService.Push`](../internal/mirror/push.go). + +### 2.1. How push discovers archives + +Archive discovery is purely by **file extension**, not by name ([validation.go](../internal/mirror/cmd/push/validation.go), `isPackageFile`): + +- any regular file ending in `.tar`, **or** +- any chunk part matching `.tar.NNNN.chunk` (which is collapsed to its canonical `.tar` so all parts are reassembled together). + +Everything matching is treated as a package to unpack. **The file name does not determine where the contents go** — routing is decided later, from the paths *inside* the archive. The only place a file name matters is the legacy `module-.tar` special case (§2.7). You can therefore rename archives freely, or push a hand-built archive with any name, as long as its internal paths are correct. + +### 2.2. Unpack into a unified tree + +All discovered archives are unpacked into a single `unified/` working directory, preserving their internal paths. Because multiple archives can share a layout path, `index.json` files are merged on collision (§1.8), never overwritten. After this step the unified tree looks exactly like the registry tree the bundle represents. + +### 2.3. The routing principle: the path *is* the destination + +`push` then walks the unified tree, treats **every directory that contains an `index.json` as an OCI layout**, and pushes it to `registry/` — where `` is that directory's path relative to the unified root (`findLayouts` + `pushSingleLayout` in [push.go](../internal/mirror/push.go)). No mapping table, no per-component logic: **the relative path of the layout is the registry segment, verbatim.** A layout at the root pushes to the repo root; a layout at `security/trivy-db` pushes to `/security/trivy-db`. + +### 2.4. Routing table + +| Layout path in the unified tree | Pushed to | Comes from | +|---|---|---| +| `` (root) | `:` | `platform.tar` (Deckhouse main) | +| `install/` | `/install` | `platform.tar` | +| `install-standalone/` | `/install-standalone` | `platform.tar` | +| `release-channel/` | `/release-channel` | `platform.tar` | +| `installer/` | `/installer` | `installer.tar` | +| `security//` | `/security/` | `security.tar` | +| `modules//` (+ `release/`, `extra//`) | `/modules/[/…]` | `module-.tar` | +| `packages//` (+ `version/`, `extra//`) | `/packages/[/…]` | `package-.tar`, `package-versions.tar` | + +### 2.5. The `short_tag` annotation decides the image tag + +Within a layout, `push` does not invent tags. It reads the destination tag from each manifest's `io.deckhouse.image.short_tag` annotation (`AnnotationImageShortTag` in [layout.go](../pkg/registry/image/layout.go)). Descriptors without that annotation are **skipped with a warning**; when two descriptors carry the same short tag, the last one wins (`dedupManifestsByShortTag` in [pusher.go](../internal/mirror/pusher/pusher.go)). So the section is chosen by layout path, and the tag within that section is chosen by the annotation — both are read from the archive, never derived from the file name. + +### 2.6. Discovery index tags + +After pushing the layouts, `push` creates lightweight discovery tags so the platform can enumerate what is available: + +- for every directory directly under `modules/`, it pushes a tiny placeholder image to `/modules:`; +- for every directory directly under `packages/`, it pushes one to `/packages:`. + +These tags are what `ListTags` on `/modules` and `/packages` returns, so a module/package is only discoverable if its layout sits at `modules//` / `packages//`. This is the practical reason the `modules/` and `packages/` prefixes are mandatory (`createModulesIndex` / `createPackagesIndex` in [push.go](../internal/mirror/push.go)). + +### 2.7. Special case: legacy `module-.tar` + +Older bundles packed a module's contents at the **root** of `module-.tar`, without an inner `modules//` prefix. `push` keeps working with them: `bundle.Unpack` notices a package name starting with `module-` and, if the archive carries no `modules/` entries, relocates the unpacked contents under `modules//` so routing still lands correctly ([bundle.go](../pkg/libmirror/bundle/bundle.go)). This is the one and only case where the **file name** influences routing. New bundles produced by current `pull` always carry the prefix internally and do not rely on it. + +### 2.8. Special case: `--modules-path-suffix` + +Modules are **always** stored under `modules/` inside the bundle. If you push them to a non-default location, `--modules-path-suffix` rewrites the leading `modules` segment at push time (`remapModulesSegment` in [push.go](../internal/mirror/push.go); `NormalizeModulesPath` in [service.go](../pkg/registry/service/service.go)). The remap happens only on push — the bundle contents are unchanged — and the discovery index tags (§2.6) follow the same remapped path. + +--- + +## 3. Checklist: a push-compatible archive + +An archive (whatever its name) is processed correctly by `push` if: + +1. It is a valid tar, or a complete set of `.tar.NNNN.chunk` parts with none missing. +2. Every image lives in a valid OCI layout — a directory containing `index.json` plus a `blobs/` tree. +3. The layout's path inside the tar equals the target registry segment (`install/`, `installer/`, `security/trivy-db/`, `modules//`, `packages//version/`, …). Root-level `index.json` targets the repo root. +4. Every manifest to be pushed carries the `io.deckhouse.image.short_tag` annotation; that value becomes its tag. +5. Modules are under `modules//` and packages under `packages//`, otherwise the discovery index tags and `--modules-path-suffix` remap will not apply to them. + +Archives that share a layout path may each carry a partial `index.json`; the tags are unioned on unpack, so nothing is lost. + +--- + +## 4. End-to-end example + +A `pull` of the platform, one module and one package produces: + +``` +bundle/ +├── platform.tar +├── installer.tar +├── security.tar +├── module-stronghold.tar +├── package-deckhouse-cli.tar +└── package-versions.tar +``` + +`push bundle/ registry.example.com/deckhouse/fe` unpacks all six into one tree: + +``` +unified/ +├── index.json # platform main +├── install/ install-standalone/ release-channel/ +├── installer/ +├── security/{trivy-db,trivy-bdu,trivy-java-db,trivy-checks}/ +├── modules/stronghold/{,release/,extra//} +└── packages/deckhouse-cli/{,version/,extra//} +``` + +and pushes each layout to the segment its path names: + +``` +registry.example.com/deckhouse/fe <- unified/index.json +registry.example.com/deckhouse/fe/install <- unified/install +registry.example.com/deckhouse/fe/installer <- unified/installer +registry.example.com/deckhouse/fe/security/trivy-db <- unified/security/trivy-db +registry.example.com/deckhouse/fe/modules/stronghold <- unified/modules/stronghold +registry.example.com/deckhouse/fe/packages/deckhouse-cli <- unified/packages/deckhouse-cli +… +registry.example.com/deckhouse/fe/modules:stronghold <- discovery index tag +registry.example.com/deckhouse/fe/packages:deckhouse-cli <- discovery index tag +``` + +The bundle carried no routing table and `push` consulted none: every destination above was read straight out of the paths the archives were built with. diff --git a/internal/tools/htpasswd/README.md b/internal/tools/htpasswd/README.md new file mode 100644 index 000000000..81208f5ab --- /dev/null +++ b/internal/tools/htpasswd/README.md @@ -0,0 +1,70 @@ +# htpasswd + +A self-contained, pure-Go analog of Apache `htpasswd`. It manages password files and hashes passwords without requiring the external `htpasswd` binary (from `apache2-utils` / `httpd-tools`). It is a drop-in for the common `htpasswd` invocations and interoperates with real htpasswd, nginx, Apache, and Dex in both directions. + +## Usage + +``` +d8 tools htpasswd [-cbdps... -C cost -r rounds] passwordfile username +d8 tools htpasswd -b [...] passwordfile username password +d8 tools htpasswd -n [-bmBdps...] [username] +d8 tools htpasswd -D passwordfile username +d8 tools htpasswd -v passwordfile username +``` + +## Flags + +Every Apache htpasswd flag is supported, including flag bundling (`-nbB`). Three flags are **d8 extensions** with no Apache htpasswd equivalent: the SHA-crypt algorithm flags `-2` (SHA-256) and `-5` (SHA-512), and `-r` (rounds). + +| Flag | Meaning | +|------|---------| +| `-c` | Create a new password file, overwriting any existing one. | +| `-n` | Do not update a file; print the result to stdout. | +| `-D` | Delete the given user from the password file. | +| `-v` | Verify the given password for the user. | +| `-b` | Batch mode: take the password from the command line. | +| `-i` | Read the password from stdin without confirmation. | +| `-C` | bcrypt cost/work factor (4–31); only with `-B`. | +| `-r` | SHA-256/512 rounds (1000–999999999); only with `-2`/`-5`. **d8 extension.** | + +## Algorithms + +Select one; the default is bcrypt. + +| Flag | Scheme | Notes | +|------|--------|-------| +| `-B` | bcrypt (`$2y$`) | Secure. The default. | +| `-m` | Apache MD5 / apr1 (`$apr1$`) | Legacy htpasswd default. | +| `-2` | SHA-256 crypt (`$5$`) | Secure. **d8 extension** (not in Apache htpasswd). | +| `-5` | SHA-512 crypt (`$6$`) | Secure. **d8 extension** (not in Apache htpasswd). | +| `-d` | CRYPT / DES | **Insecure**: only the first 8 characters are used. | +| `-s` | SHA-1 (`{SHA}`) | **Insecure**: unsalted. | +| `-p` | plaintext | **Insecure**: no hashing. | + +## Differences from Apache htpasswd + +Apache htpasswd defaults to apr1-MD5 (and, for `-B`, to bcrypt cost 5). `d8 tools htpasswd` defaults to **bcrypt at cost 10** so the output is strong and directly usable by `d8 iam user create` / `d8 iam user reset-password`. d8 also adds extensions Apache htpasswd lacks: with `-n` and no username it prints the bare hash (Apache htpasswd always requires a username and prints `username:hash`), which is exactly what `--password-hash` expects; and the SHA-crypt algorithms `-2`/`-5` plus the `-r` rounds flag (Apache htpasswd has no `-2`, `-5`, or `-r`). Each algorithm flag Apache htpasswd also defines (`-B`, `-m`, `-d`, `-s`, `-p`) behaves identically, and bcrypt output uses the `$2y$` identifier for byte-level parity. One further divergence: d8 allows bcrypt `-C` up to 31 (Apache caps it at 17). Otherwise d8 mirrors Apache htpasswd's exit codes — 2 (usage/syntax), 3 (verification failure), 5 (over-long username), 6 (bad or missing user), and 1 (file-access errors). + +Parity is verified against Apache httpd `htpasswd` 2.4.58 (`apache2-utils`); the crypt-family hash outputs are additionally cross-checked byte-for-byte against OpenSSL 3.0.13, libxcrypt 4.4.36, and Python 3.12.3 `crypt`. + +## Examples + +```bash +# Create a file and add a user (prompts for the password) +d8 tools htpasswd -c users.htpasswd alice + +# Add/update a user non-interactively (password from stdin) +echo -n 'S3cret!' | d8 tools htpasswd -i users.htpasswd bob + +# Verify a password, then delete the user +d8 tools htpasswd -bv users.htpasswd bob 'S3cret!' +d8 tools htpasswd -D users.htpasswd bob + +# Print a bcrypt hash for 'd8 iam user ... --password-hash' +HASH="$(echo -n 'Test12345!' | d8 tools htpasswd -ni)" +d8 iam user reset-password test-user --password-hash "$HASH" + +# apr1 line for an Ingress basic-auth secret; SHA-512 crypt with custom rounds +d8 tools htpasswd -nbm admin 'S3cret!' +d8 tools htpasswd -nb5 -r 100000 admin 'S3cret!' +``` diff --git a/internal/tools/htpasswd/cmd/flags.go b/internal/tools/htpasswd/cmd/flags.go new file mode 100644 index 000000000..d5cdff5ee --- /dev/null +++ b/internal/tools/htpasswd/cmd/flags.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/pflag" + + "github.com/deckhouse/deckhouse-cli/internal/tools/htpasswd" +) + +// addFlags wires up the Apache htpasswd flag surface. Long names are added for +// readability; the single-character shorthands match htpasswd exactly so +// existing muscle memory and scripts keep working (including bundling like +// -nbB and digit algorithm flags -2 / -5). +func addFlags(flags *pflag.FlagSet) { + // Operation mode. + flags.BoolP(htpasswd.FlagCreate, "c", false, "Create a new password file, overwriting any existing one.") + flags.BoolP(htpasswd.FlagStdout, "n", false, "Do not update a file; print the result to stdout.") + flags.BoolP(htpasswd.FlagDelete, "D", false, "Delete the given user from the password file.") + flags.BoolP(htpasswd.FlagVerify, "v", false, "Verify the given password for the user.") + + // Password source. + flags.BoolP(htpasswd.FlagBatch, "b", false, "Batch mode: take the password from the command line.") + flags.BoolP(htpasswd.FlagStdin, "i", false, "Read the password from stdin without verification.") + + // Algorithm selection (default: bcrypt). + flags.BoolP(htpasswd.FlagBcrypt, "B", false, "Use bcrypt (secure; the default).") + flags.BoolP(htpasswd.FlagMD5, "m", false, "Use Apache MD5 (apr1).") + flags.BoolP(htpasswd.FlagSHA256, "2", false, "Use SHA-256 crypt (secure).") + flags.BoolP(htpasswd.FlagSHA512, "5", false, "Use SHA-512 crypt (secure).") + flags.BoolP(htpasswd.FlagCrypt, "d", false, "Use CRYPT (DES; INSECURE, 8-char limit).") + flags.BoolP(htpasswd.FlagSHA1, "s", false, "Use SHA-1 (INSECURE, unsalted).") + flags.BoolP(htpasswd.FlagPlaintext, "p", false, "Store the password in plaintext (INSECURE).") + + // Algorithm parameters. + flags.IntP(htpasswd.FlagCost, "C", htpasswd.DefaultBcryptCost, "bcrypt cost/work factor (4-31); only with -B.") + flags.IntP(htpasswd.FlagRounds, "r", 0, "SHA-256/512 rounds (1000-999999999); only with -2/-5.") +} diff --git a/internal/tools/htpasswd/cmd/htpasswd.go b/internal/tools/htpasswd/cmd/htpasswd.go new file mode 100644 index 000000000..cd4242c9a --- /dev/null +++ b/internal/tools/htpasswd/cmd/htpasswd.go @@ -0,0 +1,87 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/tools/htpasswd" +) + +// htpasswdLong is a verbatim string rather than templates.LongDesc so the +// usage forms and the algorithm table keep their line breaks (LongDesc reflows +// text and would collapse them into paragraphs). +const htpasswdLong = `Manage password files and hash passwords, a self-contained analog of Apache htpasswd. No external htpasswd binary is required. + +Usage mirrors htpasswd: + + d8 tools htpasswd [-cbdps... -C cost -r rounds] passwordfile username + d8 tools htpasswd -b [...] passwordfile username password + d8 tools htpasswd -n [-bmBdps...] [username] + d8 tools htpasswd -D passwordfile username + d8 tools htpasswd -v passwordfile username + +Algorithms (choose one; default bcrypt): + + -B bcrypt secure, the default + -m apr1 MD5 Apache MD5, legacy htpasswd default + -2 SHA-256 crypt secure + -5 SHA-512 crypt secure + -d CRYPT (DES) INSECURE, only first 8 chars used + -s SHA-1 INSECURE, unsalted + -p plaintext INSECURE, no hashing + +Unlike Apache htpasswd (apr1 at cost 5 by default), d8 defaults to bcrypt at cost 10, so 'd8 tools htpasswd -n ' produces a strong hash ready for 'd8 iam user create/reset-password --password-hash'. With -n and no username the bare hash is printed, which is exactly what --password-hash expects. The -2 and -5 SHA-crypt algorithms and the -r rounds flag are d8 extensions; Apache htpasswd has no -2, -5, or -r flag. + +© Flant JSC 2026` + +var htpasswdExample = templates.Examples(` + # Create a file and add a user (prompts for the password) + d8 tools htpasswd -c users.htpasswd alice + + # Add/update a user non-interactively (password from stdin) + echo -n 'S3cret!' | d8 tools htpasswd -i users.htpasswd bob + + # Verify a password, then delete the user + d8 tools htpasswd -bv users.htpasswd bob 'S3cret!' + d8 tools htpasswd -D users.htpasswd bob + + # Print a bcrypt hash for 'd8 iam user ... --password-hash' + HASH="$(echo -n 'Test12345!' | d8 tools htpasswd -ni)" + d8 iam user reset-password test-user --password-hash "$HASH" + + # Other algorithms: apr1 line for an Ingress basic-auth secret; SHA-512 crypt + d8 tools htpasswd -nbm admin 'S3cret!' + d8 tools htpasswd -nb5 -r 100000 admin 'S3cret!'`) + +func NewCommand() *cobra.Command { + htpasswdCmd := &cobra.Command{ + Use: "htpasswd [flags] [passwordfile] [username] [password]", + Short: "Manage password files and hash passwords (Apache htpasswd analog)", + Long: htpasswdLong, + Example: htpasswdExample, + Args: cobra.ArbitraryArgs, + SilenceUsage: true, + SilenceErrors: false, + RunE: htpasswd.Htpasswd, + } + + addFlags(htpasswdCmd.Flags()) + + return htpasswdCmd +} diff --git a/internal/tools/htpasswd/cmd/htpasswd_cli_test.go b/internal/tools/htpasswd/cmd/htpasswd_cli_test.go new file mode 100644 index 000000000..82f5a97c5 --- /dev/null +++ b/internal/tools/htpasswd/cmd/htpasswd_cli_test.go @@ -0,0 +1,357 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +// Test_cli_FlagBundling verifies pflag shorthand bundling — including the digit +// algorithm shorthands -2/-5 — parses and routes to the right algorithm. These +// bundle forms are accepted by real htpasswd too (except -2/-5, which are d8 +// extensions); parsing them is the parity surface this locks in. +func Test_cli_FlagBundling(t *testing.T) { + // -nbB: bcrypt via a bundle, password from the command line. + out, err := run(t, "", "-nbB", "-C", "4", "u", "p") + require.NoError(t, err) + _, hash, found := strings.Cut(strings.TrimSpace(out), ":") + require.True(t, found, "want user:hash line, got %q", out) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash), []byte("p"))) + + // -nbm -> apr1. + out, err = run(t, "", "-nbm", "u", "p") + require.NoError(t, err) + require.Contains(t, out, "u:$apr1$") + + // -nb2 / -nb5 -> SHA-256 / SHA-512 crypt (d8 extensions; digit shorthands + // must still bundle). -r must be honoured only alongside these. + out, err = run(t, "", "-nb2", "-r", "1000", "u", "p") + require.NoError(t, err) + require.Contains(t, out, "u:$5$") + + out, err = run(t, "", "-nb5", "-r", "1000", "u", "p") + require.NoError(t, err) + require.Contains(t, out, "u:$6$") + + // -BinC 4 u: the README drop-in bundle (-B -i -n -C 4), password on stdin. + out, err = run(t, "p", "-BinC", "4", "u") + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimSpace(out), "u:$2y$04$"), + "want u:$2y$04$ line, got %q", out) +} + +// Test_cli_ConflictingFlagsRejected covers the mutually-exclusive matrix. d8 +// rejects exactly the combinations real htpasswd rejects with +// "only one of -c -n -v -D may be specified" / usage (exit 2); d8 collapses all +// to a generic error (exit 1 at the root — see finding F2). +func Test_cli_ConflictingFlagsRejected(t *testing.T) { + pairs := [][]string{ + {"-c", "-n", "f", "u"}, + {"-n", "-D", "f", "u"}, + {"-n", "-v", "f", "u"}, + {"-v", "-c", "f", "u"}, + {"-v", "-D", "f", "u"}, + {"-c", "-D", "f", "u"}, + {"-b", "-i", "f", "u", "p"}, + } + for _, args := range pairs { + _, err := run(t, "", args...) + require.Error(t, err, "expected rejection for %v", args) + } +} + +// Test_cli_MultipleAlgorithmsRejected: d8 is stricter than htpasswd here. +// Real htpasswd accepts several algorithm flags and applies the last one +// (exit 0); d8 rejects any second algorithm flag (finding F5). +func Test_cli_MultipleAlgorithmsRejected(t *testing.T) { + _, err := run(t, "", "-nb", "-B", "-m", "u", "p") + require.Error(t, err) + _, err = run(t, "", "-nb", "-m", "-B", "u", "p") // order-independent in d8 + require.Error(t, err) +} + +// Test_cli_CostRequiresBcrypt: -C with an explicit non-bcrypt algorithm is an +// error in d8. DIVERGENCE (F4): real htpasswd warns "Ignoring -C argument for +// this algorithm" and succeeds (exit 0). The second case documents the other +// side of the same coin: because bcrypt is d8's default, -C works WITHOUT -B +// (real htpasswd would ignore -C without -B). +func Test_cli_CostRequiresBcrypt(t *testing.T) { + _, err := run(t, "", "-nb", "-m", "-C", "8", "u", "p") + require.Error(t, err) + + out, err := run(t, "p", "-n", "-C", "4") // default alg is bcrypt + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimSpace(out), "$2y$04$"), + "default-bcrypt -C should apply, got %q", out) +} + +// Test_cli_RoundsRequiresShaCrypt: -r is valid only with -2/-5 (a d8 extension +// with no htpasswd equivalent at all). +func Test_cli_RoundsRequiresShaCrypt(t *testing.T) { + _, err := run(t, "", "-nb", "-r", "5000", "u", "p") // default bcrypt + require.Error(t, err) + _, err = run(t, "", "-nb", "-B", "-r", "5000", "u", "p") + require.Error(t, err) + + out, err := run(t, "", "-nb", "-2", "-r", "5000", "u", "p") + require.NoError(t, err) + require.Contains(t, out, "u:$5$") +} + +// Test_cli_CostRangeBounds: d8 accepts bcrypt cost 4..31 (bcrypt.MinCost..MaxCost). +// NOTE (F7): real htpasswd only accepts 4..17 and rejects 18..31 (exit 3); we +// test d8's own documented bounds and keep the accepted case at cost 4 for speed. +func Test_cli_CostRangeBounds(t *testing.T) { + _, err := run(t, "", "-nb", "-B", "-C", "3", "u", "p") // below min + require.Error(t, err) + _, err = run(t, "", "-nb", "-B", "-C", "32", "u", "p") // above max + require.Error(t, err) + + out, err := run(t, "", "-nb", "-B", "-C", "4", "u", "p") + require.NoError(t, err) + require.Contains(t, out, "u:$2y$04$") +} + +// Test_cli_RoundsRangeBounds: d8 bounds SHA-crypt rounds at 1000..999999999. +func Test_cli_RoundsRangeBounds(t *testing.T) { + _, err := run(t, "", "-nb", "-5", "-r", "999", "u", "p") + require.Error(t, err) + _, err = run(t, "", "-nb", "-5", "-r", "1000000000", "u", "p") + require.Error(t, err) + + out, err := run(t, "", "-nb", "-5", "-r", "1000", "u", "p") + require.NoError(t, err) + require.Contains(t, out, "u:$6$rounds=1000$") +} + +// Test_cli_StdoutForms exercises the three -n username forms and the batch +// arg-count rules. +func Test_cli_StdoutForms(t *testing.T) { + // Bare (1 batch arg = password): no colon, hash matches the password. + out, err := run(t, "", "-nb", "-C", "4", "solopass") + require.NoError(t, err) + bare := strings.TrimSpace(out) + require.False(t, strings.Contains(bare, ":"), "bare hash must have no colon, got %q", bare) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(bare), []byte("solopass"))) + + // Username present (2 batch args): "user:hash". + out, err = run(t, "", "-nb", "-C", "4", "alice", "p") + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimSpace(out), "alice:$2y$04$"), "got %q", out) + + // Explicit empty username: ":hash", exactly like `htpasswd -n ""` (exit 0). + out, err = run(t, "", "-nb", "-C", "4", "", "p") + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimSpace(out), ":$2y$04$"), "got %q", out) + + // Too many args for non-batch -n, and zero args for -nb: both rejected. + _, err = run(t, "", "-n", "-i", "a", "b") + require.Error(t, err) + _, err = run(t, "", "-nb", "-C", "4") + require.Error(t, err) +} + +// Test_cli_BatchEmptyPassword: PARITY. Real `htpasswd -nb user ""` succeeds +// (exit 0) and so must d8 — the batch path takes the password argument verbatim +// with no empty-string check (finding F3, batch side). +func Test_cli_BatchEmptyPassword(t *testing.T) { + out, err := run(t, "", "-nb", "-C", "4", "user", "") + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimSpace(out), "user:$2y$04$"), "got %q", out) +} + +// Test_cli_StdinEmptyPasswordRejected documents finding F3 (stdin side): d8 +// rejects an empty password read from stdin/-i, whereas real htpasswd ACCEPTS +// it (`printf ” | htpasswd -ni user` -> exit 0). run() swaps stdin only for a +// non-empty string, so "\n" yields a pipe whose first line is empty. +func Test_cli_StdinEmptyPasswordRejected(t *testing.T) { + _, err := run(t, "\n", "-n", "-i", "-C", "4") + require.Error(t, err) + require.Contains(t, err.Error(), "empty") +} + +// Test_cli_UsernameRules: ':' and control chars rejected; the byte-length cap. +// NOTE (F9): the limit is bytes, so 128 two-byte runes (256 bytes) is rejected +// though it is only 128 characters; and real htpasswd rejects a 255-byte name +// ("resultant record too long", exit 5) that d8 accepts. +func Test_cli_UsernameRules(t *testing.T) { + _, err := run(t, "", "-nb", "-C", "4", "us:er", "p") + require.Error(t, err) + require.Contains(t, err.Error(), ":") + + _, err = run(t, "", "-nb", "-C", "4", "a\tb", "p") + require.Error(t, err) + + _, err = run(t, "", "-nb", "-C", "4", strings.Repeat("u", 256), "p") + require.Error(t, err) + + _, err = run(t, "", "-nb", "-C", "4", strings.Repeat("é", 128), "p") // 256 bytes + require.Error(t, err) + + // 255 bytes is accepted by d8 (htpasswd would reject as record-too-long). + out, err := run(t, "", "-nb", "-C", "4", strings.Repeat("u", 255), "p") + require.NoError(t, err) + require.Contains(t, out, ":$2y$04$") +} + +// Test_cli_MissingFileErrors: add/verify/delete against a nonexistent file all +// error (real htpasswd: "cannot modify file X; use '-c' to create it", exit 1). +func Test_cli_MissingFileErrors(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope.htpasswd") + + _, err := run(t, "", "-b", "-C", "4", missing, "u", "p") + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + require.Contains(t, err.Error(), "-c") + + _, err = run(t, "", "-bv", missing, "u", "p") + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + + _, err = run(t, "", "-D", missing, "u") + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") +} + +// Test_cli_LifecycleMessages locks the add/update/verify/delete messages, which +// match real htpasswd verbatim ("Adding/Updating/Deleting password for user X", +// "Password for user X correct."). run() folds stderr into the returned string. +func Test_cli_LifecycleMessages(t *testing.T) { + file := filepath.Join(t.TempDir(), "u.htpasswd") + + out, err := run(t, "", "-cb", "-C", "4", file, "alice", "p1") + require.NoError(t, err) + require.Contains(t, out, "Adding password for user alice") + + out, err = run(t, "", "-b", "-C", "4", file, "alice", "p2") + require.NoError(t, err) + require.Contains(t, out, "Updating password for user alice") + + out, err = run(t, "", "-bv", file, "alice", "p2") + require.NoError(t, err) + require.Contains(t, out, "Password for user alice correct.") + + _, err = run(t, "", "-bv", file, "alice", "WRONG") + require.Error(t, err) + require.Contains(t, err.Error(), "verification failed") + + _, err = run(t, "", "-bv", file, "ghost", "x") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + + out, err = run(t, "", "-D", file, "alice") + require.NoError(t, err) + require.Contains(t, out, "Deleting password for user alice") + + _, err = run(t, "", "-D", file, "ghost") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +// Test_cli_EmptyUsernameRejectedForFileOps covers the fix for the empty-username +// collision: add/update, delete and verify against a file now reject an empty +// username (which would otherwise clobber blank lines in the file), while the +// '-n' stdout mode still accepts an explicit empty username and prints ":hash" +// exactly like `htpasswd -n ""`. +func Test_cli_EmptyUsernameRejectedForFileOps(t *testing.T) { + file := filepath.Join(t.TempDir(), "u.htpasswd") + + // -c create (batch) with an empty username is rejected before the file is written. + _, err := run(t, "", "-cb", "-C", "4", file, "", "pw") + require.Error(t, err) + require.Contains(t, err.Error(), "username must not be empty") + + // The plaintext form from the original bug report is rejected too. + _, err = run(t, "", "-cb", "-p", file, "", "pw") + require.Error(t, err) + require.Contains(t, err.Error(), "username must not be empty") + + // Seed a real user, then confirm delete and verify also reject "". + _, err = run(t, "", "-cb", "-C", "4", file, "alice", "pw") + require.NoError(t, err) + + _, err = run(t, "", "-D", file, "") + require.Error(t, err) + require.Contains(t, err.Error(), "username must not be empty") + + _, err = run(t, "", "-bv", file, "", "pw") + require.Error(t, err) + require.Contains(t, err.Error(), "username must not be empty") + + // -n stdout mode still allows an explicit empty username (":hash"). + out, err := run(t, "", "-nb", "-C", "4", "", "pw") + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimSpace(out), ":$2y$04$"), "got %q", out) +} + +// cli_exitCode extracts the process exit code a command's error carries via the +// ExitCode() method (see internal/tools/htpasswd/exit.go). Errors without one — +// e.g. file-access failures — map to 1, exactly as cmd/d8/root.go decides. +func cli_exitCode(t *testing.T, err error) int { + t.Helper() + require.Error(t, err) + + var coder interface{ ExitCode() int } + if errors.As(err, &coder) { + return coder.ExitCode() + } + + return 1 +} + +// Test_cli_ExitCodes locks the Apache-htpasswd-compatible exit codes d8 now +// returns: 2 usage/syntax, 3 verification failure, 5 over-long username, 6 +// bad/absent user, and 1 for file-access errors. +func Test_cli_ExitCodes(t *testing.T) { + file := filepath.Join(t.TempDir(), "u.htpasswd") + _, err := run(t, "", "-cb", "-C", "4", file, "alice", "pw") + require.NoError(t, err) + + cases := []struct { + name string + want int + stdin string + args []string + }{ + {"conflicting_flags", 2, "", []string{"-c", "-n", "f", "u"}}, + {"two_algorithms", 2, "", []string{"-nb", "-B", "-m", "u", "p"}}, + {"cost_without_bcrypt", 2, "", []string{"-nb", "-m", "-C", "8", "u", "p"}}, + {"cost_out_of_range", 2, "", []string{"-nb", "-B", "-C", "99", "u", "p"}}, + {"rounds_without_shacrypt", 2, "", []string{"-nb", "-r", "5000", "u", "p"}}, + {"colon_in_username", 6, "", []string{"-nb", "-C", "4", "a:b", "p"}}, + {"username_too_long", 5, "", []string{"-nb", "-C", "4", strings.Repeat("u", 256), "p"}}, + {"verify_wrong_password", 3, "", []string{"-bv", file, "alice", "WRONG"}}, + {"verify_user_not_found", 6, "", []string{"-bv", file, "ghost", "x"}}, + {"empty_username_file", 2, "", []string{"-cb", "-C", "4", file, "", "pw"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := run(t, c.stdin, c.args...) + require.Equal(t, c.want, cli_exitCode(t, err), "exit code for %v", c.args) + }) + } + + // A missing password file (no -c) is a file-access error: exit 1, unwrapped. + _, err = run(t, "", "-b", "-C", "4", filepath.Join(t.TempDir(), "nope"), "u", "p") + require.Equal(t, 1, cli_exitCode(t, err), "missing file must be exit 1") +} diff --git a/internal/tools/htpasswd/cmd/htpasswd_test.go b/internal/tools/htpasswd/cmd/htpasswd_test.go new file mode 100644 index 000000000..453aab42f --- /dev/null +++ b/internal/tools/htpasswd/cmd/htpasswd_test.go @@ -0,0 +1,181 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +// run executes a fresh command with the given args and optional stdin, and +// returns stdout+stderr combined. +func run(t *testing.T, stdin string, args ...string) (string, error) { + t.Helper() + + if stdin != "" { + restore := withStdin(t, stdin) + defer restore() + } + + cmd := NewCommand() + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + + err := cmd.Execute() + + return out.String(), err +} + +func Test_Stdout_BareHashAndUserLine(t *testing.T) { + // Bare hash (no username) — the form 'd8 iam user --password-hash' consumes. + out, err := run(t, "Test12345!", "-n", "-i", "-C", "4") + require.NoError(t, err) + + bare := strings.TrimSpace(out) + require.True(t, strings.HasPrefix(bare, "$2y$"), "want bcrypt hash, got %q", bare) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(bare), []byte("Test12345!"))) + + // With a username — the htpasswd "user:hash" line. + out, err = run(t, "Test12345!", "-n", "-i", "-C", "4", "admin") + require.NoError(t, err) + + user, hash, found := strings.Cut(strings.TrimSpace(out), ":") + require.True(t, found) + require.Equal(t, "admin", user) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash), []byte("Test12345!"))) +} + +// Test_Stdout_HtpasswdDropInRecipe locks in the README recipe: the exact Apache +// htpasswd invocation must work verbatim with 'd8 tools htpasswd' substituted +// for 'htpasswd', including the bundled flags (-BinC) and the empty-username +// ":hash" output that 'cut -d: -f2' relies on. +// +// echo -n 'Test12345!' | d8 tools htpasswd -BinC 10 "" | cut -d: -f2 | tr -d '\n' +func Test_Stdout_HtpasswdDropInRecipe(t *testing.T) { + out, err := run(t, "Test12345!", "-BinC", "10", "") + require.NoError(t, err) + + // Like real htpasswd with an empty username, the line begins with ':'. + line := strings.TrimRight(out, "\n") + require.True(t, strings.HasPrefix(line, ":"), "want leading colon like htpasswd, got %q", line) + + // Emulate 'cut -d: -f2 | tr -d \n'. + _, hash, _ := strings.Cut(line, ":") + require.True(t, strings.HasPrefix(hash, "$2y$"), "want bcrypt $2y$ hash, got %q", hash) + + cost, err := bcrypt.Cost([]byte(hash)) + require.NoError(t, err) + require.Equal(t, 10, cost, "-C 10 should yield cost 10") + + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash), []byte("Test12345!"))) +} + +func Test_File_CreateVerifyDeleteLifecycle(t *testing.T) { + file := filepath.Join(t.TempDir(), "users.htpasswd") + + // Create the file and add alice (batch mode, low cost for speed). + _, err := run(t, "", "-c", "-b", "-C", "4", file, "alice", "AlicePass1") + require.NoError(t, err) + + // Add a second user, preserving the first. + _, err = run(t, "", "-b", "-C", "4", file, "bob", "BobPass1") + require.NoError(t, err) + + content, err := os.ReadFile(file) + require.NoError(t, err) + require.Contains(t, string(content), "alice:$2y$") + require.Contains(t, string(content), "bob:$2y$") + + // Verify correct and incorrect passwords. + _, err = run(t, "", "-bv", file, "alice", "AlicePass1") + require.NoError(t, err) + + _, err = run(t, "", "-bv", file, "alice", "WrongPass") + require.Error(t, err) + + // Delete bob; alice remains. + _, err = run(t, "", "-D", file, "bob") + require.NoError(t, err) + + content, err = os.ReadFile(file) + require.NoError(t, err) + require.Contains(t, string(content), "alice:") + require.NotContains(t, string(content), "bob:") + + // Deleting a missing user is an error. + _, err = run(t, "", "-D", file, "ghost") + require.Error(t, err) +} + +func Test_File_AlgorithmsAndConflicts(t *testing.T) { + dir := t.TempDir() + + // apr1 (-m) and SHA-512 (-5) entries land with the expected prefixes. + fileM := filepath.Join(dir, "m.htpasswd") + _, err := run(t, "", "-c", "-b", "-m", fileM, "u", "pw123456") + require.NoError(t, err) + content, _ := os.ReadFile(fileM) + require.Contains(t, string(content), "u:$apr1$") + + file5 := filepath.Join(dir, "s5.htpasswd") + _, err = run(t, "", "-c", "-b", "-5", "-r", "1000", file5, "u", "pw123456") + require.NoError(t, err) + content, _ = os.ReadFile(file5) + require.Contains(t, string(content), "u:$6$rounds=1000$") + + // Verify one of them round-trips through the CLI. + _, err = run(t, "", "-bv", file5, "u", "pw123456") + require.NoError(t, err) + + // Conflicting flags are rejected. + _, err = run(t, "", "-c", "-n", "x") + require.Error(t, err) + + _, err = run(t, "", "-B", "-m", "-n", "x") + require.Error(t, err) + + // -C without -B is rejected. + _, err = run(t, "", "-m", "-C", "8", "-n", "x") + require.Error(t, err) +} + +// withStdin swaps os.Stdin for a pipe preloaded with content and returns a +// restore func. +func withStdin(t *testing.T, content string) func() { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + + _, err = w.WriteString(content + "\n") + require.NoError(t, err) + require.NoError(t, w.Close()) + + orig := os.Stdin + os.Stdin = r + + return func() { os.Stdin = orig } +} diff --git a/internal/tools/htpasswd/crypt64.go b/internal/tools/htpasswd/crypt64.go new file mode 100644 index 000000000..defc1078c --- /dev/null +++ b/internal/tools/htpasswd/crypt64.go @@ -0,0 +1,54 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "crypto/rand" + "fmt" +) + +// crypt64 is the non-standard base64 alphabet shared by the crypt(3) family +// (DES, MD5/apr1, SHA-256/512). Note the ordering: './0-9A-Za-z', which differs +// from RFC 4648 and must not be swapped for encoding/base64. +const crypt64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// to64 appends n crypt64 characters encoding the low 6*n bits of v, least +// significant group first. This is the byte order used by the MD5 and SHA +// crypt output assembly. +func to64(dst []byte, v uint32, n int) []byte { + for ; n > 0; n-- { + dst = append(dst, crypt64[v&0x3f]) + v >>= 6 + } + + return dst +} + +// randomSalt returns n random characters drawn from the crypt64 alphabet, used +// to seed a fresh hash. It draws from crypto/rand so salts are unpredictable. +func randomSalt(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generating salt: %w", err) + } + + for i := range buf { + buf[i] = crypt64[int(buf[i])%len(crypt64)] + } + + return string(buf), nil +} diff --git a/internal/tools/htpasswd/crypt_parity_test.go b/internal/tools/htpasswd/crypt_parity_test.go new file mode 100644 index 000000000..dcd7969b1 --- /dev/null +++ b/internal/tools/htpasswd/crypt_parity_test.go @@ -0,0 +1,266 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// All expected values below were produced independently by the system reference +// implementations and cross-checked against the Go port: +// - DES: libxcrypt 4.4.36 crypt(3) (via a C harness) + perl crypt +// - $1$: python3 crypt (glibc/libxcrypt) + `openssl passwd -1` +// - apr1: `openssl passwd -apr1` + Apache `htpasswd -vb` round-trip +// - $5$/$6$: python3 crypt (SHA256/SHA512) + `openssl passwd -5/-6` +// A mismatch means the port diverged from the canonical algorithm. +// Reference versions: Apache htpasswd 2.4.58 (apache2-utils), OpenSSL 3.0.13, +// libxcrypt 4.4.36, Python 3.12.3. + +func Test_crypt_DESEdgeCases(t *testing.T) { + cases := []struct{ name, password, salt, want string }{ + {"empty_pw", "", "ab", "abmF1QH4PEr.E"}, + {"len_lt_8", "abc", "ab", "abFZSxKKdq5s6"}, + {"len_eq_8", "abcdefgh", "ab", "abYH7TYgEKz2Q"}, + {"len_gt_8_first8_only", "abcdefghIJKL", "ab", "abYH7TYgEKz2Q"}, + {"salt_dotdot", "password", "..", "..UZoIyj/Hy/c"}, + {"salt_slashslash", "password", "//", "//TIk/siaNpyQ"}, + {"salt_zz", "password", "zz", "zzXUHfURnGg8I"}, + {"password_ab", "password", "ab", "abJnggxhB/yWI"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.want, desCrypt(c.password, c.salt)) + }) + } + + // Only the first 8 password bytes matter: >8 must equal the 8-byte prefix. + require.Equal(t, + desCrypt("abcdefgh", "ab"), + desCrypt("abcdefghIJKL", "ab"), + "DES must ignore password bytes beyond the 8th") + + // A 1-char salt is padded with '.' (internal-consistency; libxcrypt rejects + // a 1-char salt outright, so this pins the port's lenient behavior). + require.Equal(t, + desCrypt("password", "a."), + desCrypt("password", "a"), + "1-char DES salt should be padded to \"a.\"") +} + +func Test_crypt_MD5EdgeCases(t *testing.T) { + cases := []struct{ name, password, salt, magic, want string }{ + {"apr1_basic", "password", "SsFduAdd", magicAPR1, "$apr1$SsFduAdd$N8RB421wyIBb686LI12ko."}, + {"apr1_test123", "Test123!", "abcdefgh", magicAPR1, "$apr1$abcdefgh$gj2HqWsjGbOdAts0DpThK."}, + {"apr1_empty_pw", "", "abcdefgh", magicAPR1, "$apr1$abcdefgh$L.PT565ESX4Tp2bqNs7Ie."}, + {"apr1_short_salt", "password", "abc", magicAPR1, "$apr1$abc$mehJE/UcwZsj.w5DYe.b5."}, + // salt longer than 8 is truncated to 8 both here and in the reference. + {"apr1_salt_trunc8", "password", "abcdefghij", magicAPR1, "$apr1$abcdefgh$FBwExRW4dCc8aL.OvjpIE1"}, + // multibyte/high-bit password ("päss" UTF-8: 70 c3 a4 73 73). + {"apr1_utf8", "päss", "saltsalt", magicAPR1, "$apr1$saltsalt$f74.33Z0f34Aak2NYQJTa0"}, + {"md5_basic", "password", "saltsalt", magicMD5, "$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/"}, + {"md5_empty_pw", "", "saltsalt", magicMD5, "$1$saltsalt$5Jhcit4zN9UlGiA0txPkO0"}, + {"md5_utf8", "päss", "saltsalt", magicMD5, "$1$saltsalt$npHVA9aR/rej/t9wc6U4V0"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.want, md5Crypt(c.password, c.salt, c.magic)) + }) + } + + // salt > 8 must hash identically to the 8-byte prefix. + require.Equal(t, + md5Crypt("password", "abcdefgh", magicAPR1), + md5Crypt("password", "abcdefghij", magicAPR1), + "MD5 salt must be truncated to 8 bytes") +} + +func Test_crypt_SHA256Vectors(t *testing.T) { + cases := []struct { + name, password, salt string + rounds int + want string + }{ + {"default", "password", "saltsalt", 0, "$5$saltsalt$gOjOtoMpVhru2uyjeJSEc/JaLQWOXMNmlOnj6T4AtC."}, + {"rounds_10000", "password", "saltsalt", 10000, "$5$rounds=10000$saltsalt$a6WJS3V6B3leg7T3.ELC5.vcUmHOyFDvLaurLBy.mc8"}, + {"rounds_1000", "password", "saltsalt", 1000, "$5$rounds=1000$saltsalt$azOwbpkvuuBKkE82dQPwTsQE8JyT9Fflpr9aKid3aT9"}, + {"long_pw_40b", "0123456789012345678901234567890123456789", "saltsalt", 0, "$5$saltsalt$wFv09ZBxx3UoYQk5Z1RCJ5ZTGv13Sp1r1vIjpms6Bv7"}, + // salt > 16 truncated to 16 (both here and in the reference). + {"salt_trunc16", "password", "abcdefghijklmnopqrstuvwxyz", 0, "$5$abcdefghijklmnop$ieyonWfl7MR75BuN79Fkt2PqhPI43TsNZYGUObDGVI/"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.want, shaCrypt(c.password, c.salt, c.rounds, false)) + }) + } +} + +func Test_crypt_SHA512Vectors(t *testing.T) { + cases := []struct { + name, password, salt string + rounds int + want string + }{ + {"default", "password", "saltsalt", 0, "$6$saltsalt$qFmFH.bQmmtXzyBY0s9v7Oicd2z4XSIecDzlB5KiA2/jctKu9YterLp8wwnSq.qc.eoxqOmSuNp2xS0ktL3nh/"}, + {"rounds_10000", "password", "saltsalt", 10000, "$6$rounds=10000$saltsalt$ZqOTO2O04D/DgwZlm.rZTgWxvBaIf4LQsZKtXFEu9UHJ4CvgmdLAGxKUzJ0mPO98OevETdY6oK/Oac6j2Axxq/"}, + {"salt16_test123", "Test123!", "abcdefghijklmnop", 0, "$6$abcdefghijklmnop$Vthr3YXPXseV5egL67KCgMNLr7uYIxy6j/lec/PGvO5oJWeGG/ZXLCHkfFp9nryV.VdKV/0fzFJwmOSHHocNf1"}, + {"long_pw_70b", "0123456789012345678901234567890123456789012345678901234567890123456789", "saltsalt", 0, "$6$saltsalt$jj0/1v8ZaK/nhCM8sBULJrCvrRsaVIUaM1mgnnIfKp4etInK1E7h1ZmhgswwKEFEYCI2mnQlNIglg0yUILTDv."}, + {"salt_trunc16", "password", "abcdefghijklmnopqrstuvwxyz", 0, "$6$abcdefghijklmnop$0aenUFHf897F9u0tURIHOeACWajSuVGa7jgJGyq.DKZm/WXl/IZFvPbneFydBjomEOgM.Sh1m0L3KsS1.H5b//"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.want, shaCrypt(c.password, c.salt, c.rounds, true)) + }) + } +} + +// SHA-crypt omits the rounds= field at the default (rounds arg == 0) but emits +// it whenever rounds are given explicitly - even when the explicit value equals +// the 5000 default. The checksum is identical either way (same work factor); +// only the prefix differs. This matches glibc/libxcrypt's rounds_custom flag. +func Test_crypt_SHARoundsFormatting(t *testing.T) { + def5 := shaCrypt("password", "saltsalt", 0, false) + exp5 := shaCrypt("password", "saltsalt", 5000, false) + require.Equal(t, "$5$saltsalt$gOjOtoMpVhru2uyjeJSEc/JaLQWOXMNmlOnj6T4AtC.", def5) + require.Equal(t, "$5$rounds=5000$saltsalt$gOjOtoMpVhru2uyjeJSEc/JaLQWOXMNmlOnj6T4AtC.", exp5) + require.NotContains(t, def5, "rounds=", "default must omit the rounds= field") + require.Contains(t, exp5, "rounds=5000$", "explicit rounds must be emitted even when == default") + + // Same for $6$. + def6 := shaCrypt("password", "saltsalt", 0, true) + exp6 := shaCrypt("password", "saltsalt", 5000, true) + require.NotContains(t, def6, "rounds=") + require.Contains(t, exp6, "rounds=5000$") + // Identical checksum tail regardless of the rounds= prefix. + require.Equal(t, + strings.TrimPrefix(def6, "$6$saltsalt$"), + strings.TrimPrefix(exp6, "$6$rounds=5000$saltsalt$")) +} + +// The port CLAMPS rounds to [1000, 999999999] per Drepper's SHA-crypt spec and +// classic glibc. (Note: libxcrypt 4.4.x instead REJECTS out-of-range rounds, +// returning "*0" - a documented divergence for out-of-range inputs only.) +func Test_crypt_SHARoundsClamp(t *testing.T) { + // Below the minimum clamps up to 1000. + require.Equal(t, + shaCrypt("password", "saltsalt", 1000, false), + shaCrypt("password", "saltsalt", 500, false)) + require.Equal(t, + "$5$rounds=1000$saltsalt$azOwbpkvuuBKkE82dQPwTsQE8JyT9Fflpr9aKid3aT9", + shaCrypt("password", "saltsalt", 500, false)) + // NOTE: the upper clamp (rounds > 999999999 -> 999999999) is verified by + // inspection of shacrypt.go only; executing it would run ~1e9 SHA rounds. +} + +func Test_crypt_To64Encoding(t *testing.T) { + // to64 appends n crypt64 chars encoding the low 6*n bits, least-significant + // group first. crypt64 = "./0-9A-Za-z", so index 0='.', 1='/', 63='z'. + require.Equal(t, ".", string(to64(nil, 0, 1))) + require.Equal(t, "z", string(to64(nil, 63, 1))) + require.Equal(t, ".", string(to64(nil, 64, 1))) // 64 & 0x3f == 0 + require.Equal(t, "/.", string(to64(nil, 1, 2))) // LSB group first: 1 -> '/', then 0 -> '.' + require.Equal(t, "zzzz", string(to64(nil, 0xffffff, 4))) +} + +func Test_crypt_Crypt64Alphabet(t *testing.T) { + require.Equal(t, 64, len(crypt64)) + require.Equal(t, "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", crypt64) + require.Equal(t, 0, crypt64Index('.')) + require.Equal(t, 1, crypt64Index('/')) + require.Equal(t, 2, crypt64Index('0')) + require.Equal(t, 12, crypt64Index('A')) + require.Equal(t, 63, crypt64Index('z')) + // Non-alphabet bytes silently map to 0 (same slot as '.'). + require.Equal(t, 0, crypt64Index('!')) + require.Equal(t, 0, crypt64Index('=')) + + require.True(t, isCrypt64("abJnggxhB/yWI")) + require.True(t, isCrypt64("")) + require.False(t, isCrypt64("abc!")) + require.False(t, isCrypt64("ab==")) +} + +// randomSalt is unbiased: len(crypt64)==64 divides 256 exactly, so byte%64 maps +// exactly 4 source bytes to each of the 64 symbols - no modulo bias. +func Test_crypt_RandomSaltUnbiased(t *testing.T) { + const draws = 4000 + seen := map[rune]int{} + for range draws { + s, err := randomSalt(16) + require.NoError(t, err) + require.Len(t, s, 16) + require.True(t, isCrypt64(s), "salt %q must be all-crypt64", s) + for _, r := range s { + seen[r]++ + } + } + // Every one of the 64 symbols must appear across 64k samples. + require.Len(t, seen, 64, "all 64 crypt64 symbols should be reachable") +} + +func Test_crypt_ParseMD5RoundTrip(t *testing.T) { + for _, tc := range []struct{ pw, salt, magic string }{ + {"password", "SsFduAdd", magicAPR1}, + {"password", "saltsalt", magicMD5}, + {"", "abcdefgh", magicAPR1}, + } { + h := md5Crypt(tc.pw, tc.salt, tc.magic) + magic, salt, ok := parseMD5Crypt(h) + require.True(t, ok) + require.Equal(t, tc.magic, magic) + require.Equal(t, h, md5Crypt(tc.pw, salt, magic), "re-hash must reproduce the hash") + } + + // Malformed / unsupported inputs are rejected. + _, _, ok := parseMD5Crypt("$2y$notmd5") + require.False(t, ok) + _, _, ok = parseMD5Crypt("$apr1$noDollarSalt") + require.False(t, ok, "missing checksum separator must be rejected") +} + +func Test_crypt_ParseShaRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + hash string + wantR int + wantS string + want5 bool + }{ + {"s5_no_rounds", shaCrypt("password", "saltsalt", 0, false), 0, "saltsalt", false}, + {"s5_rounds", shaCrypt("password", "saltsalt", 10000, false), 10000, "saltsalt", false}, + {"s6_no_rounds", shaCrypt("password", "saltsalt", 0, true), 0, "saltsalt", true}, + {"s6_rounds", shaCrypt("password", "abcdefghijklmnop", 10000, true), 10000, "abcdefghijklmnop", true}, + } { + t.Run(tc.name, func(t *testing.T) { + rounds, salt, is512, ok := parseShaCrypt(tc.hash) + require.True(t, ok) + require.Equal(t, tc.wantR, rounds) + require.Equal(t, tc.wantS, salt) + require.Equal(t, tc.want5, is512) + // Re-hash with the parsed params must reproduce the original. + require.Equal(t, tc.hash, shaCrypt("password", salt, rounds, is512)) + }) + } + + // Malformed inputs. + _, _, _, ok := parseShaCrypt("$7$saltsalt$xxxx") + require.False(t, ok, "unknown scheme must be rejected") + _, _, _, ok = parseShaCrypt("$5$rounds=notanumber$saltsalt$xxxx") + require.False(t, ok, "non-numeric rounds must be rejected") +} diff --git a/internal/tools/htpasswd/descrypt.go b/internal/tools/htpasswd/descrypt.go new file mode 100644 index 000000000..884df55bb --- /dev/null +++ b/internal/tools/htpasswd/descrypt.go @@ -0,0 +1,311 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +// Traditional Unix DES crypt(3): the legacy 13-character hash ('htpasswd -d'). +// It is a modified DES that encrypts a zero block 25 times, with the 12-bit +// salt perturbing the expansion permutation. Insecure (8-char password limit, +// trivially brute-forced) and provided only for htpasswd parity. +// +// The tables below are the standard FIPS-46 DES permutations and S-boxes; the +// algorithm works on bits as 0/1 bytes for clarity rather than speed — a CLI +// hashing one password does not need the packed-word optimizations. + +var desIP = [64]int{ + 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, + 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8, + 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, + 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7, +} + +var desFP = [64]int{ + 40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31, + 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, + 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, + 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25, +} + +var desE = [48]int{ + 32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, + 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17, + 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, + 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1, +} + +var desP = [32]int{ + 16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, + 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25, +} + +var desPC1 = [56]int{ + 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, + 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36, + 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, + 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4, +} + +var desPC2 = [48]int{ + 14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, + 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2, + 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, + 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32, +} + +var desShifts = [16]int{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1} + +var desS = [8][64]int{ + { + 14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, + 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, + 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, + 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13, + }, + { + 15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, + 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5, + 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, + 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9, + }, + { + 10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, + 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, + 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, + 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12, + }, + { + 7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, + 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, + 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, + 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14, + }, + { + 2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, + 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, + 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, + 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3, + }, + { + 12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, + 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, + 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, + 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13, + }, + { + 4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, + 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, + 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, + 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12, + }, + { + 13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, + 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, + 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, + 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11, + }, +} + +// desSubkeys derives the 16 round subkeys (each 48 bits, as 0/1 bytes) from an +// 8-byte key block (also expressed as 64 0/1 bits). +func desSubkeys(keyBits []byte) [16][48]byte { + // PC1: 64 -> 56 bits, split into halves c and d. + var c, d [28]byte + for i := 0; i < 28; i++ { + c[i] = keyBits[desPC1[i]-1] + d[i] = keyBits[desPC1[i+28]-1] + } + + var subkeys [16][48]byte + + for round := 0; round < 16; round++ { + c = rotl28(c, desShifts[round]) + d = rotl28(d, desShifts[round]) + + // PC2: pick 48 bits out of the concatenated 56-bit c||d. + for i := 0; i < 48; i++ { + pos := desPC2[i] - 1 + if pos < 28 { + subkeys[round][i] = c[pos] + } else { + subkeys[round][i] = d[pos-28] + } + } + } + + return subkeys +} + +func rotl28(in [28]byte, n int) [28]byte { + var out [28]byte + for i := 0; i < 28; i++ { + out[i] = in[(i+n)%28] + } + + return out +} + +// desEncryptZero encrypts the all-zero block with the given subkeys, applying +// the salt perturbation to the expansion each round, and returns the 64-bit +// output as 0/1 bytes. crypt(3) iterates this 25 times. +func desEncryptZero(block [64]byte, subkeys [16][48]byte, salt uint32) [64]byte { + // Initial permutation. + var perm [64]byte + for i := 0; i < 64; i++ { + perm[i] = block[desIP[i]-1] + } + + var left, right [32]byte + copy(left[:], perm[:32]) + copy(right[:], perm[32:]) + + for round := 0; round < 16; round++ { + f := desFeistel(right, subkeys[round], salt) + + var next [32]byte + for i := 0; i < 32; i++ { + next[i] = left[i] ^ f[i] + } + + left = right + right = next + } + + // Preoutput is right||left (halves swapped), then the final permutation. + var pre [64]byte + copy(pre[:32], right[:]) + copy(pre[32:], left[:]) + + var out [64]byte + for i := 0; i < 64; i++ { + out[i] = pre[desFP[i]-1] + } + + return out +} + +// desFeistel is the DES round function f(R, K) with the crypt(3) salt twist: +// after expanding R to 48 bits, bit i is swapped with bit i+24 when bit i of +// the salt is set (i in 0..11). +func desFeistel(right [32]byte, subkey [48]byte, salt uint32) [32]byte { + var expanded [48]byte + for i := 0; i < 48; i++ { + expanded[i] = right[desE[i]-1] + } + + for i := 0; i < 24; i++ { + if salt&(1<> uint(3-bit)) & 1) + } + } + + var out [32]byte + for i := 0; i < 32; i++ { + out[i] = sout[desP[i]-1] + } + + return out +} + +// desCrypt implements the classic crypt(3): a 2-character salt followed by 11 +// characters encoding the 64-bit result. Only the first 8 bytes of the password +// contribute; each contributes its low 7 bits. +func desCrypt(password, salt string) string { + for len(salt) < 2 { + salt += "." + } + + salt = salt[:2] + + // Decode the 12-bit salt from its two crypt64 characters. + var saltVal uint32 + for i := 1; i >= 0; i-- { + saltVal = saltVal<<6 | uint32(crypt64Index(salt[i])) + } + + // Build the 64-bit key: 8 bytes, each password char's low 7 bits shifted + // left by one (the low parity bit is left zero). + var keyBits [64]byte + + for j := 0; j < 8; j++ { + var ch byte + if j < len(password) { + ch = password[j] + } + + kb := (ch & 0x7f) << 1 + for bit := 0; bit < 8; bit++ { + keyBits[j*8+bit] = (kb >> uint(7-bit)) & 1 + } + } + + subkeys := desSubkeys(keyBits[:]) + + var block [64]byte + for i := 0; i < 25; i++ { + block = desEncryptZero(block, subkeys, saltVal) + } + + // Encode the 64 output bits into 11 crypt64 chars, big-endian, zero-padded + // on the final (2-bit) group. + out := make([]byte, 0, 11) + pos := 0 + + for group := 0; group < 11; group++ { + var v int + for k := 0; k < 6; k++ { + v <<= 1 + if pos < 64 { + v |= int(block[pos]) + } + + pos++ + } + + out = append(out, crypt64[v]) + } + + return salt + string(out) +} + +// crypt64Index returns the value of a crypt64 character, or 0 if it is not in +// the alphabet. +func crypt64Index(c byte) int { + for i := 0; i < len(crypt64); i++ { + if crypt64[i] == c { + return i + } + } + + return 0 +} diff --git a/internal/tools/htpasswd/exit.go b/internal/tools/htpasswd/exit.go new file mode 100644 index 000000000..4b35a0291 --- /dev/null +++ b/internal/tools/htpasswd/exit.go @@ -0,0 +1,72 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import "fmt" + +// Apache htpasswd process exit codes. d8 mirrors the ones a script is likely to +// branch on so 'd8 tools htpasswd' can stand in for 'htpasswd'. File-access +// failures (missing file, permission, I/O) keep the default exit code 1 by +// being returned unwrapped, which also matches htpasswd. +// See https://httpd.apache.org/docs/current/programs/htpasswd.html. +const ( + exitUsage = 2 // usage/syntax: bad flags, conflicting flags, wrong arg count, bad flag value + exitVerify = 3 // password verification failed, prompt mismatch, or hash-encode failure + exitOverflow = 5 // username too long + exitBadUser = 6 // illegal character in username, or user not found +) + +// exitError wraps an error with a process exit code. The CLI root +// (cmd/d8/root.go) looks for an ExitCode() int method via errors.As and exits +// with that status; errors without it exit 1 as before, so this only affects +// 'd8 tools htpasswd'. +type exitError struct { + code int + err error +} + +func (e *exitError) Error() string { return e.err.Error() } +func (e *exitError) Unwrap() error { return e.err } +func (e *exitError) ExitCode() int { return e.code } + +// coded wraps err with the given exit code. A nil err returns nil so it is safe +// to wrap a call result directly. +func coded(code int, err error) error { + if err == nil { + return nil + } + + return &exitError{code: code, err: err} +} + +// usageErr, verifyErr, overflowErr and badUserErr build a coded error from a +// printf-style message, one per htpasswd exit-code class. +func usageErr(format string, a ...any) error { + return &exitError{exitUsage, fmt.Errorf(format, a...)} +} + +func verifyErr(format string, a ...any) error { + return &exitError{exitVerify, fmt.Errorf(format, a...)} +} + +func overflowErr(format string, a ...any) error { + return &exitError{exitOverflow, fmt.Errorf(format, a...)} +} + +func badUserErr(format string, a ...any) error { + return &exitError{exitBadUser, fmt.Errorf(format, a...)} +} diff --git a/internal/tools/htpasswd/hash.go b/internal/tools/htpasswd/hash.go new file mode 100644 index 000000000..9da416132 --- /dev/null +++ b/internal/tools/htpasswd/hash.go @@ -0,0 +1,171 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "crypto/sha1" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// algorithm identifies one of htpasswd's password schemes. +type algorithm string + +const ( + algBcrypt algorithm = "bcrypt" // -B, $2y$ + algAPR1 algorithm = "md5" // -m, $apr1$ + algSHA256 algorithm = "sha256" // -2, $5$ + algSHA512 algorithm = "sha512" // -5, $6$ + algDES algorithm = "crypt" // -d, legacy 13-char + algSHA1 algorithm = "sha1" // -s, {SHA} + algPlain algorithm = "plain" // -p, verbatim +) + +// hashOptions carries the algorithm selection plus its tunable parameters. +type hashOptions struct { + alg algorithm + cost int // bcrypt work factor + rounds int // SHA-256/512 rounds; 0 means the scheme default +} + +// generateHash hashes password with a fresh random salt appropriate to the +// selected algorithm. +func generateHash(password string, o hashOptions) (string, error) { + switch o.alg { + case algBcrypt: + return bcryptHash(password, o.cost) + case algAPR1: + salt, err := randomSalt(8) + if err != nil { + return "", err + } + + return md5Crypt(password, salt, magicAPR1), nil + case algSHA256: + salt, err := randomSalt(16) + if err != nil { + return "", err + } + + return shaCrypt(password, salt, o.rounds, false), nil + case algSHA512: + salt, err := randomSalt(16) + if err != nil { + return "", err + } + + return shaCrypt(password, salt, o.rounds, true), nil + case algDES: + salt, err := randomSalt(2) + if err != nil { + return "", err + } + + return desCrypt(password, salt), nil + case algSHA1: + return sha1Hash(password), nil + case algPlain: + return password, nil + } + + return "", fmt.Errorf("unknown algorithm %q", o.alg) +} + +// bcryptHash returns a bcrypt hash rewritten to the '$2y$' identifier that +// Apache htpasswd emits. Go's bcrypt produces the equivalent '$2a$' and can +// verify either, so the rewrite is purely cosmetic parity. +func bcryptHash(password string, cost int) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", fmt.Errorf("hashing password: %w", err) + } + + return "$2y$" + string(h[4:]), nil +} + +// sha1Hash returns the '{SHA}' scheme: base64 of the unsalted SHA-1 digest. +func sha1Hash(password string) string { + sum := sha1.Sum([]byte(password)) + + return "{SHA}" + base64.StdEncoding.EncodeToString(sum[:]) +} + +// verifyHash reports whether password matches an existing hash, auto-detecting +// the scheme from the stored value. Unknown-prefix values are tried as DES and +// then as plaintext, mirroring how htpasswd -v probes a password file. +func verifyHash(password, stored string) (bool, error) { + switch { + case strings.HasPrefix(stored, "$2a$"), strings.HasPrefix(stored, "$2b$"), + strings.HasPrefix(stored, "$2x$"), strings.HasPrefix(stored, "$2y$"), + strings.HasPrefix(stored, "$2$"): + err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) + if err == nil { + return true, nil + } + + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + + return false, err + case strings.HasPrefix(stored, magicAPR1), strings.HasPrefix(stored, magicMD5): + magic, salt, ok := parseMD5Crypt(stored) + if !ok { + return false, fmt.Errorf("malformed MD5 hash") + } + + return constEq(md5Crypt(password, salt, magic), stored), nil + case strings.HasPrefix(stored, "$5$"), strings.HasPrefix(stored, "$6$"): + rounds, salt, is512, ok := parseShaCrypt(stored) + if !ok { + return false, fmt.Errorf("malformed SHA-crypt hash") + } + + return constEq(shaCrypt(password, salt, rounds, is512), stored), nil + case strings.HasPrefix(stored, "{SHA}"): + return constEq(sha1Hash(password), stored), nil + } + + // No recognizable prefix. A 13-character crypt64 string is almost certainly + // DES crypt; anything else can only be a plaintext ('-p') entry. + if len(stored) == 13 && isCrypt64(stored) && constEq(desCrypt(password, stored[:2]), stored) { + return true, nil + } + + return constEq(password, stored), nil +} + +// constEq compares two strings in constant time (length mismatch returns false). +func constEq(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// isCrypt64 reports whether every byte of s is a member of the crypt64 alphabet. +func isCrypt64(s string) bool { + for i := 0; i < len(s); i++ { + if !strings.ContainsRune(crypt64, rune(s[i])) { + return false + } + } + + return true +} diff --git a/internal/tools/htpasswd/hash_parity_test.go b/internal/tools/htpasswd/hash_parity_test.go new file mode 100644 index 000000000..d31a3aa96 --- /dev/null +++ b/internal/tools/htpasswd/hash_parity_test.go @@ -0,0 +1,335 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +// Test_hash_BcryptRewriteAcrossCosts confirms the $2a$ -> $2y$ rewrite in +// bcryptHash (hash.go:102, `"$2y$" + string(h[4:])`) is correct at every cost. +// Go's bcrypt always emits a fixed 4-byte "$2a$" prefix (major '2' + minor 'a' +// are constants, x/crypto bcrypt.go:57-58,144-145) followed by a zero-padded +// two-digit cost (bcrypt.go:254, fmt.Sprintf("%02d", cost)), so slicing h[4:] +// drops exactly "$2a$" for all costs 4..31 and the rewrite is length-preserving +// (60 bytes total). +// +// NOTE: cost 31 is deliberately NOT exercised at runtime: bcrypt at cost 31 runs +// 2^31 key-setup rounds and takes many minutes-to-hours, which would hang CI. +// Its correctness follows structurally from the fixed prefix + %02d cost cited +// above (verified against x/crypto v0.54.0 source), so costs 4 and 10 are +// representative. If you must cover a high two-digit cost cheaply, use 18. +func Test_hash_BcryptRewriteAcrossCosts(t *testing.T) { + const password = "Test12345!" + for _, cost := range []int{bcrypt.MinCost, DefaultBcryptCost} { // 4, 10 + t.Run(fmt.Sprintf("cost%d", cost), func(t *testing.T) { + h, err := bcryptHash(password, cost) + require.NoError(t, err) + + // Structure parity with Apache `htpasswd -nbB`: $2y$ + 2-digit cost + + // $ + 22-char salt + 31-char hash = 60 bytes. + require.True(t, strings.HasPrefix(h, "$2y$"), "prefix, got %q", h) + require.Len(t, h, 60, "bcrypt hash must be 60 bytes") + require.Equal(t, fmt.Sprintf("%02d", cost), h[4:6], "zero-padded 2-digit cost") + require.Equal(t, byte('$'), h[6], "cost delimiter") + + // Encoded cost round-trips and the rewritten $2y$ still verifies. + got, err := bcrypt.Cost([]byte(h)) + require.NoError(t, err) + require.Equal(t, cost, got) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(h), []byte(password)), + "rewritten $2y$ hash must verify with the underlying bcrypt lib") + }) + } +} + +// Test_hash_BcryptPasswordLength pins the >72-byte divergence from Apache. +// +// Go's bcrypt.GenerateFromPassword returns bcrypt.ErrPasswordTooLong for any +// password > 72 bytes (x/crypto bcrypt.go:96-98), so bcryptHash surfaces an +// error and produces no hash. Apache `htpasswd -nbB` instead SILENTLY TRUNCATES +// the password to 72 bytes and emits a hash (verified with the system binary: +// a 73-byte password verifies against a hash generated from its 72-byte prefix). +// +// This asserts the ACTUAL d8 behavior (error at 73+), which is the safer of the +// two: silent truncation collides distinct long passwords onto one credential. +// The divergence is a documented parity gap, not a bug. +func Test_hash_BcryptPasswordLength(t *testing.T) { + // <= 72 bytes: accepted. + for _, n := range []int{71, 72} { + h, err := bcryptHash(strings.Repeat("a", n), bcrypt.MinCost) + require.NoErrorf(t, err, "len=%d must hash", n) + require.NotEmpty(t, h) + } + // > 72 bytes: rejected with ErrPasswordTooLong (Apache would truncate). + for _, n := range []int{73, 100} { + h, err := bcryptHash(strings.Repeat("a", n), bcrypt.MinCost) + require.Errorf(t, err, "len=%d must be rejected", n) + require.ErrorIs(t, err, bcrypt.ErrPasswordTooLong) + require.Empty(t, h) + } +} + +// Test_hash_Sha1ExactVector pins the unsalted {SHA} scheme to exact vectors that +// match both `htpasswd -nbs` and +// `printf %s pw | openssl dgst -sha1 -binary | openssl base64`. +func Test_hash_Sha1ExactVector(t *testing.T) { + // htpasswd -nbs user password -> {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= + require.Equal(t, "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=", sha1Hash("password")) + // SHA-1 of the empty string. + require.Equal(t, "{SHA}2jmj7l5rSw0yVb/vlWAYkK/YBwk=", sha1Hash("")) + + // And it verifies through the auto-detecting verifier. + ok, err := verifyHash("password", "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=") + require.NoError(t, err) + require.True(t, ok) +} + +// Test_hash_VerifyAutoDetectPerScheme extends the existing foreign-hash set with +// the two schemes it omits (bcrypt straight from Apache htpasswd, and {SHA}) and +// re-confirms each prefix routes to the right verifier. Every vector below is +// for the password "password"; the exact producing command is in the comment. +func Test_hash_VerifyAutoDetectPerScheme(t *testing.T) { + cases := []struct{ name, stored string }{ + // htpasswd -nbB -C 5 user password (salt is random; any one capture works) + {"bcrypt_2y_from_htpasswd", "$2y$05$3wSYOLuQydmAcw/bACP03uc04PZQ2zAVU6qvmAnhJLN7Jul0kin6e"}, + // A $2a$ variant must route to bcrypt too (Go's own identifier). + {"bcrypt_2a", hash_mustBcrypt2a(t, "password")}, + // htpasswd -nbs user password == printf %s password|openssl dgst -sha1 -binary|openssl base64 + {"sha1_SHA", "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g="}, + // openssl passwd -apr1 -salt SsFduAdd password + {"apr1", "$apr1$SsFduAdd$N8RB421wyIBb686LI12ko."}, + // openssl passwd -1 -salt saltsalt password + {"md5_1", "$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/"}, + // python3 -c "import crypt;print(crypt.crypt('password','$5$saltsalt'))" + {"sha256_5", "$5$saltsalt$gOjOtoMpVhru2uyjeJSEc/JaLQWOXMNmlOnj6T4AtC."}, + // python3 crypt with an explicit rounds= field + {"sha256_5_rounds", "$5$rounds=10000$saltsalt$a6WJS3V6B3leg7T3.ELC5.vcUmHOyFDvLaurLBy.mc8"}, + // python3 -c "import crypt;print(crypt.crypt('password','$6$saltsalt'))" + {"sha512_6", "$6$saltsalt$qFmFH.bQmmtXzyBY0s9v7Oicd2z4XSIecDzlB5KiA2/jctKu9YterLp8wwnSq.qc.eoxqOmSuNp2xS0ktL3nh/"}, + // python3 -c "import crypt;print(crypt.crypt('password','ab'))" (13-char DES) + {"des", "abJnggxhB/yWI"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ok, err := verifyHash("password", c.stored) + require.NoError(t, err) + require.True(t, ok, "correct password must verify against %q", c.stored) + + // "wrongpass" differs in the first 8 bytes so DES (which ignores + // bytes past 8) also rejects it. + ok, err = verifyHash("wrongpass", c.stored) + require.NoError(t, err) + require.False(t, ok, "wrong password must be rejected by %q", c.stored) + }) + } +} + +// hash_mustBcrypt2a returns a raw Go bcrypt hash keeping its native "$2a$" +// identifier, to prove verifyHash routes $2a$ (not only the rewritten $2y$) to +// bcrypt. +func hash_mustBcrypt2a(t *testing.T, password string) string { + t.Helper() + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(h), "$2a$")) + return string(h) +} + +// Test_hash_Verify13CharPlaintextAmbiguity proves the DES-then-plaintext +// fallthrough in verifyHash (hash.go:148-152) is correct. +// +// - A 13-char crypt64 value that is NOT DES(password) falls through the DES +// branch and is compared as plaintext -> a genuine 13-char '-p' password +// verifies. (Real `htpasswd -v` CANNOT do this: it treats any bare 13-char +// crypt64 string as DES and so fails to verify a 13-char plaintext entry. +// d8 only ever accepts MORE than htpasswd here, never rejects a valid one.) +// - A 13-char value that IS DES(password, salt) is accepted via the DES branch, +// matching `htpasswd -v` exactly. +func Test_hash_Verify13CharPlaintextAmbiguity(t *testing.T) { + // Sanity: the plaintext value is 13 crypt64 chars yet is not its own DES hash, + // so the DES branch must miss and control must reach the plaintext compare. + const plain = "abcdefghijklm" + require.Len(t, plain, 13) + require.True(t, isCrypt64(plain)) + require.NotEqual(t, plain, desCrypt(plain, plain[:2]), + "precondition: plaintext must not collide with its own DES hash") + + // It is produced by generateHash(algPlain) verbatim, and verifies. + stored, err := generateHash(plain, hashOptions{alg: algPlain}) + require.NoError(t, err) + require.Equal(t, plain, stored) + + ok, err := verifyHash(plain, stored) + require.NoError(t, err) + require.True(t, ok, "13-char plaintext password must verify via fallthrough") + + // A different 13-char crypt64 password must be rejected. + ok, err = verifyHash("nopqrstuvwxyz", stored) + require.NoError(t, err) + require.False(t, ok) + + // DES-interpretation branch: "abJnggxhB/yWI" == DES("password","ab"), so + // verifyHash("password", ...) is accepted via the DES branch (parity with + // `htpasswd -v`, which treats this bare 13-char value as DES too). + ok, err = verifyHash("password", "abJnggxhB/yWI") + require.NoError(t, err) + require.True(t, ok) +} + +// Test_hash_VerifyMalformed confirms malformed stored values never panic and +// never false-accept. The universal invariant is (ok == false) with no panic. +// +// A subtlety worth pinning: the error contract is NOT uniform. parseMD5Crypt +// (md5crypt.go:132-135) requires the "salt$hash" separator and so makes +// verifyHash return (false, err) for "$apr1$"; but parseShaCrypt +// (shacrypt.go:241-250) accepts a bare "$5$" as an empty-salt/no-checksum hash +// and returns ok=true, so verifyHash returns (false, NIL) for "$5$". Both are +// safe (no panic, no false-accept); they merely differ in whether an error is +// reported. wantErr below documents the actual, observed behavior of each input. +func Test_hash_VerifyMalformed(t *testing.T) { + cases := []struct { + stored string + wantErr bool + }{ + {"$apr1$", true}, // md5: no salt/hash after magic + {"$apr1$onlysalt", true}, // md5: missing '$hash' segment + {"$1$", true}, // md5: empty + {"$5$", false}, // sha-crypt: lenient -> (false,nil) + {"$6$nodollarafterthis", false}, // sha-crypt: whole tail taken as salt + {"$5$rounds=$saltsalt$deadbeef", true}, // sha-crypt: empty rounds value + {"$5$rounds=notanumber$salt$hash", true}, // sha-crypt: non-numeric rounds + {"$2y$", true}, // bcrypt: truncated + {"$2y$10$tooshort", true}, // bcrypt: below minimum hash size + {"$2a$99$" + strings.Repeat("x", 53), true}, // bcrypt: cost out of range + } + for _, c := range cases { + t.Run(c.stored, func(t *testing.T) { + var ( + ok bool + err error + ) + require.NotPanics(t, func() { ok, err = verifyHash("password", c.stored) }) + require.False(t, ok, "malformed %q must never verify", c.stored) + if c.wantErr { + require.Error(t, err, "malformed %q must return an error", c.stored) + } + }) + } +} + +// Test_hash_VerifyBcryptMismatchVsError distinguishes the two bcrypt paths in +// verifyHash (hash.go:117-127): a plain password mismatch is (false, nil), while +// a structurally invalid hash is (false, non-nil err). +func Test_hash_VerifyBcryptMismatchVsError(t *testing.T) { + good, err := bcryptHash("password", bcrypt.MinCost) + require.NoError(t, err) + + // Mismatch: valid hash, wrong password -> (false, nil). + ok, err := verifyHash("WRONGpassword", good) + require.NoError(t, err) + require.False(t, ok) + + // Correct password -> (true, nil). + ok, err = verifyHash("password", good) + require.NoError(t, err) + require.True(t, ok) + + // Structurally invalid bcrypt -> (false, err), not a bare mismatch. + ok, err = verifyHash("password", "$2y$10$not-a-valid-bcrypt-hash") + require.Error(t, err) + require.False(t, ok) +} + +// Test_hash_ConstEqAndIsCrypt64 pins the two helpers. +func Test_hash_ConstEqAndIsCrypt64(t *testing.T) { + require.True(t, constEq("abc", "abc")) + require.False(t, constEq("abc", "abd")) + require.False(t, constEq("abc", "abcd"), "length mismatch must be false") + require.False(t, constEq("", "x")) + require.True(t, constEq("", "")) + + require.True(t, isCrypt64("abJnggxhB/yWI")) + require.True(t, isCrypt64("./09AZaz")) + require.False(t, isCrypt64("="), "'=' (base64 padding) is not in crypt64") + require.False(t, isCrypt64("$1$")) + require.False(t, isCrypt64("has space")) + require.False(t, isCrypt64("colon:here")) +} + +// Test_hash_GenerateDispatch confirms each algorithm emits the expected scheme +// marker, plaintext is verbatim, and an unknown algorithm errors. +func Test_hash_GenerateDispatch(t *testing.T) { + const password = "Test12345!" + checks := []struct { + alg algorithm + verify func(t *testing.T, h string) + }{ + {algBcrypt, func(t *testing.T, h string) { require.True(t, strings.HasPrefix(h, "$2y$")) }}, + {algAPR1, func(t *testing.T, h string) { require.True(t, strings.HasPrefix(h, "$apr1$")) }}, + {algSHA256, func(t *testing.T, h string) { require.True(t, strings.HasPrefix(h, "$5$")) }}, + {algSHA512, func(t *testing.T, h string) { require.True(t, strings.HasPrefix(h, "$6$")) }}, + {algSHA1, func(t *testing.T, h string) { require.True(t, strings.HasPrefix(h, "{SHA}")) }}, + {algDES, func(t *testing.T, h string) { + require.Len(t, h, 13) + require.True(t, isCrypt64(h)) + }}, + {algPlain, func(t *testing.T, h string) { require.Equal(t, password, h) }}, + } + for _, c := range checks { + t.Run(string(c.alg), func(t *testing.T) { + h, err := generateHash(password, hashOptions{alg: c.alg, cost: DefaultBcryptCost}) + require.NoError(t, err) + c.verify(t, h) + }) + } + + _, err := generateHash(password, hashOptions{alg: algorithm("bogus")}) + require.Error(t, err, "unknown algorithm must error") +} + +// Test_hash_VerifyLegacyBcryptIdentifiers confirms verifyHash routes the legacy +// bcrypt identifiers $2$ and $2x$ (alongside $2a$/$2b$/$2y$) to the bcrypt +// verifier. Go's bcrypt accepts any minor-version byte and computes the same +// hash regardless of the identifier, so these verify (empirically confirmed with +// x/crypto v0.54.0); before the fix they fell through to the plaintext branch +// and failed. Each variant is built from a fresh $2a$ hash by rewriting only the +// identifier, which does not change what bcrypt verifies for an ASCII password. +func Test_hash_VerifyLegacyBcryptIdentifiers(t *testing.T) { + base, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(base), "$2a$")) + + rest := string(base)[len("$2a$"):] // "04$" + for _, id := range []string{"$2$", "$2b$", "$2x$", "$2y$"} { + stored := id + rest + t.Run(id, func(t *testing.T) { + ok, err := verifyHash("password", stored) + require.NoError(t, err) + require.True(t, ok, "correct password must verify against %q", stored) + + ok, err = verifyHash("wrongpassword", stored) + require.NoError(t, err) + require.False(t, ok, "wrong password must be rejected by %q", stored) + }) + } +} diff --git a/internal/tools/htpasswd/htpasswd.go b/internal/tools/htpasswd/htpasswd.go new file mode 100644 index 000000000..6810a6824 --- /dev/null +++ b/internal/tools/htpasswd/htpasswd.go @@ -0,0 +1,488 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +// DefaultBcryptCost is the bcrypt work factor used when -C is not given. It +// deliberately differs from Apache htpasswd (whose default is cost 5, and whose +// default algorithm is apr1-MD5): d8 defaults to bcrypt at cost 10 so the hash +// is strong and directly usable by 'd8 iam user ... --password-hash'. Every +// individual algorithm flag still behaves exactly like htpasswd. +const DefaultBcryptCost = 10 + +// Flag names. Each maps to an Apache htpasswd short flag in flags.go. +const ( + FlagCreate = "create" // -c + FlagStdout = "stdout" // -n + FlagBatch = "batch" // -b + FlagStdin = "stdin" // -i + FlagMD5 = "md5" // -m + FlagSHA256 = "sha256" // -2 + FlagSHA512 = "sha512" // -5 + FlagBcrypt = "bcrypt" // -B + FlagCrypt = "des" // -d + FlagSHA1 = "sha1" // -s + FlagPlaintext = "plaintext" // -p + FlagDelete = "delete" // -D + FlagVerify = "verify" // -v + FlagCost = "cost" // -C + FlagRounds = "rounds" // -r +) + +// options is the resolved view of the command-line flags. +type options struct { + create bool + stdout bool + batch bool + stdinPw bool + delete bool + verify bool + + cost int + costSet bool + rounds int + roundsSet bool + + alg algorithm +} + +// Htpasswd is the cobra RunE entry point: it resolves flags, validates the +// combination, and dispatches to the stdout or file-management flow. +func Htpasswd(cmd *cobra.Command, args []string) error { + o, err := parseOptions(cmd) + if err != nil { + return err + } + + if err := o.validate(); err != nil { + return err + } + + if o.stdout { + return runStdout(cmd, o, args) + } + + if o.delete { + return runDelete(cmd, o, args) + } + + if o.verify { + return runVerify(cmd, o, args) + } + + return runAddUpdate(cmd, o, args) +} + +func parseOptions(cmd *cobra.Command) (*options, error) { + f := cmd.Flags() + + o := &options{} + o.create, _ = f.GetBool(FlagCreate) + o.stdout, _ = f.GetBool(FlagStdout) + o.batch, _ = f.GetBool(FlagBatch) + o.stdinPw, _ = f.GetBool(FlagStdin) + o.delete, _ = f.GetBool(FlagDelete) + o.verify, _ = f.GetBool(FlagVerify) + o.cost, _ = f.GetInt(FlagCost) + o.rounds, _ = f.GetInt(FlagRounds) + o.costSet = f.Changed(FlagCost) + o.roundsSet = f.Changed(FlagRounds) + + selected := []struct { + flag string + alg algorithm + }{ + {FlagBcrypt, algBcrypt}, + {FlagMD5, algAPR1}, + {FlagSHA256, algSHA256}, + {FlagSHA512, algSHA512}, + {FlagCrypt, algDES}, + {FlagSHA1, algSHA1}, + {FlagPlaintext, algPlain}, + } + + o.alg = algBcrypt + + found := false + + for _, s := range selected { + if on, _ := f.GetBool(s.flag); on { + if found { + return nil, usageErr("only one hashing algorithm flag may be given (-B, -m, -2, -5, -d, -s, -p)") + } + + o.alg = s.alg + found = true + } + } + + return o, nil +} + +func (o *options) validate() error { + switch { + case o.create && o.stdout: + return usageErr("-c (create) and -n (stdout) cannot be combined") + case o.stdout && o.delete: + return usageErr("-n (stdout) and -D (delete) cannot be combined") + case o.stdout && o.verify: + return usageErr("-n (stdout) and -v (verify) cannot be combined") + case o.verify && o.create: + return usageErr("-v (verify) and -c (create) cannot be combined") + case o.verify && o.delete: + return usageErr("-v (verify) and -D (delete) cannot be combined") + case o.create && o.delete: + return usageErr("-c (create) and -D (delete) cannot be combined") + case o.batch && o.stdinPw: + return usageErr("-b (batch) and -i (stdin) cannot be combined") + case o.delete && (o.batch || o.stdinPw): + return usageErr("-D (delete) does not take a password") + } + + if o.costSet && o.alg != algBcrypt { + return usageErr("-C (cost) is only valid with -B (bcrypt)") + } + + if o.roundsSet && o.alg != algSHA256 && o.alg != algSHA512 { + return usageErr("-r (rounds) is only valid with -2 or -5") + } + + if o.alg == algBcrypt && (o.cost < bcrypt.MinCost || o.cost > bcrypt.MaxCost) { + return usageErr("bcrypt cost must be between %d and %d, got %d", bcrypt.MinCost, bcrypt.MaxCost, o.cost) + } + + if o.roundsSet && (o.rounds < shaCryptMinRounds || o.rounds > shaCryptMaxRounds) { + return usageErr("rounds must be between %d and %d, got %d", shaCryptMinRounds, shaCryptMaxRounds, o.rounds) + } + + return nil +} + +func (o *options) hashOptions() hashOptions { + return hashOptions{alg: o.alg, cost: o.cost, rounds: o.rounds} +} + +// runStdout implements '-n': hash a password and print it, never touching a +// file. When a username argument is present it prints the htpasswd +// "username:hash" line, so an explicit empty username ("") prints ":hash" +// exactly like Apache htpasswd — this makes 'd8 tools htpasswd -BinC 10 ""' a +// drop-in for 'htpasswd -BinC 10 ""'. When no username argument is given at all +// (an extension htpasswd lacks) it prints just the bare hash, which is what +// 'd8 iam user ... --password-hash' consumes. +func runStdout(cmd *cobra.Command, o *options, args []string) error { + var username, password string + + hasUsername := false + havePassword := false + + switch { + case o.batch: + switch len(args) { + case 1: + password = args[0] + case 2: + username, password, hasUsername = args[0], args[1], true + default: + return usageErr("usage: -n -b [username] ") + } + + havePassword = true + case len(args) > 1: + return usageErr("usage: -n [username]") + case len(args) == 1: + username, hasUsername = args[0], true + } + + if err := validateUsername(username); err != nil { + return err + } + + if !havePassword { + p, err := readPassword(cmd, o, true) + if err != nil { + return err + } + + password = p + } + + hash, err := generateHash(password, o.hashOptions()) + if err != nil { + return coded(exitVerify, err) + } + + line := hash + if hasUsername { + line = username + ":" + hash + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), line) + + return err +} + +// runAddUpdate adds or replaces a user's entry in a password file. +func runAddUpdate(cmd *cobra.Command, o *options, args []string) error { + file, username, password, err := fileUserPassword(cmd, o, args, true) + if err != nil { + return err + } + + var pf *passwdFile + + if o.create { + pf = newPasswdFile(file) + } else { + pf, err = loadPasswdFile(file) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("password file %q does not exist; pass -c to create it", file) + } + + return err + } + } + + hash, err := generateHash(password, o.hashOptions()) + if err != nil { + return coded(exitVerify, err) + } + + existed := pf.upsert(username, hash) + + if err := pf.save(); err != nil { + return err + } + + action := "Adding" + if existed { + action = "Updating" + } + + fmt.Fprintf(cmd.ErrOrStderr(), "%s password for user %s\n", action, username) + + return nil +} + +// runDelete removes a user from a password file. +func runDelete(cmd *cobra.Command, _ *options, args []string) error { + if len(args) != 2 { + return usageErr("usage: -D ") + } + + file, username := args[0], args[1] + + if username == "" { + return usageErr("username must not be empty") + } + + pf, err := loadPasswdFile(file) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("password file %q does not exist", file) + } + + return err + } + + if !pf.remove(username) { + return badUserErr("user %s not found in %q", username, file) + } + + if err := pf.save(); err != nil { + return err + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Deleting password for user %s\n", username) + + return nil +} + +// runVerify checks a password against the stored hash for a user. +func runVerify(cmd *cobra.Command, o *options, args []string) error { + file, username, password, err := fileUserPassword(cmd, o, args, false) + if err != nil { + return err + } + + pf, err := loadPasswdFile(file) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("password file %q does not exist", file) + } + + return err + } + + stored, ok := pf.get(username) + if !ok { + return badUserErr("user %s not found in %q", username, file) + } + + match, err := verifyHash(password, stored) + if err != nil { + return err + } + + if !match { + return verifyErr("password verification failed for user %s", username) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Password for user %s correct.\n", username) + + return nil +} + +// fileUserPassword parses the " [password]" positional +// form shared by add/update and verify, and obtains the password from -b, -i, +// or an interactive prompt. confirm controls whether an interactive prompt asks +// for the password twice (true for setting a password, false for verifying). +func fileUserPassword(cmd *cobra.Command, o *options, args []string, confirm bool) (string, string, string, error) { + var file, username, password string + + if o.batch { + if len(args) != 3 { + return "", "", "", usageErr("usage: -b ") + } + + file, username, password = args[0], args[1], args[2] + } else { + if len(args) != 2 { + return "", "", "", usageErr("usage: ") + } + + file, username = args[0], args[1] + + p, err := readPassword(cmd, o, confirm) + if err != nil { + return "", "", "", err + } + + password = p + } + + if err := validateUsername(username); err != nil { + return "", "", "", err + } + + if username == "" { + return "", "", "", usageErr("username must not be empty") + } + + return file, username, password, nil +} + +// validateUsername enforces the htpasswd constraints: no colon (the field +// separator), no control characters, and at most 255 bytes. +func validateUsername(username string) error { + if strings.Contains(username, ":") { + return badUserErr("username must not contain a ':' character") + } + + if strings.ContainsAny(username, "\n\r\t") { + return badUserErr("username must not contain control characters") + } + + if len(username) > 255 { + return overflowErr("username must be at most 255 characters") + } + + return nil +} + +// readPassword obtains the password from stdin (with -i, or whenever stdin is +// not a terminal) or from an interactive hidden prompt. An interactive prompt +// asks twice and checks the two entries match when confirm is set. +func readPassword(cmd *cobra.Command, o *options, confirm bool) (string, error) { + if o.stdinPw || !term.IsTerminal(int(os.Stdin.Fd())) { + return readPasswordLine(os.Stdin) + } + + return readPasswordPrompt(cmd.ErrOrStderr(), int(os.Stdin.Fd()), confirm) +} + +// readPasswordLine reads and returns a single line, stripping the trailing +// newline so both `printf pw` and `echo pw` yield the same password. +func readPasswordLine(r io.Reader) (string, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 4096), 1024*1024) + + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("reading password from stdin: %w", err) + } + + return "", errors.New("no password provided on stdin") + } + + password := strings.TrimRight(scanner.Text(), "\r\n") + if password == "" { + return "", errors.New("password must not be empty") + } + + return password, nil +} + +// readPasswordPrompt reads a hidden password from the terminal, optionally +// asking a second time and checking the two match. +func readPasswordPrompt(prompts io.Writer, stdinFd int, confirm bool) (string, error) { + fmt.Fprint(prompts, "New password: ") + + first, err := term.ReadPassword(stdinFd) + + fmt.Fprintln(prompts) + + if err != nil { + return "", fmt.Errorf("reading password: %w", err) + } + + if len(first) == 0 { + return "", errors.New("password must not be empty") + } + + if !confirm { + return string(first), nil + } + + fmt.Fprint(prompts, "Re-type new password: ") + + second, err := term.ReadPassword(stdinFd) + + fmt.Fprintln(prompts) + + if err != nil { + return "", fmt.Errorf("reading password confirmation: %w", err) + } + + if string(first) != string(second) { + return "", verifyErr("passwords do not match") + } + + return string(first), nil +} diff --git a/internal/tools/htpasswd/htpasswd_test.go b/internal/tools/htpasswd/htpasswd_test.go new file mode 100644 index 000000000..6902a18b4 --- /dev/null +++ b/internal/tools/htpasswd/htpasswd_test.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +// Test_generateHash_RoundTrip checks that every algorithm produces a hash that +// verifyHash accepts for the right password and rejects for a wrong one. +func Test_generateHash_RoundTrip(t *testing.T) { + const ( + password = "Test12345!" + // wrong must differ within the first 8 bytes so it is also rejected by + // DES crypt, which ignores everything past byte 8. + wrong = "Xest12345?" + ) + + algs := []algorithm{algBcrypt, algAPR1, algSHA256, algSHA512, algDES, algSHA1, algPlain} + for _, alg := range algs { + t.Run(string(alg), func(t *testing.T) { + hash, err := generateHash(password, hashOptions{alg: alg, cost: DefaultBcryptCost}) + require.NoError(t, err) + require.NotEmpty(t, hash) + + ok, err := verifyHash(password, hash) + require.NoError(t, err) + require.True(t, ok, "correct password should verify against %q", hash) + + // DES only considers the first 8 bytes, so pick a wrong password + // that differs within that window. + ok, err = verifyHash(wrong, hash) + require.NoError(t, err) + require.False(t, ok, "wrong password should not verify against %q", hash) + }) + } +} + +func Test_bcryptHash_EmitsHtpasswdIdentifierAndCost(t *testing.T) { + hash, err := bcryptHash("Test12345!", 12) + require.NoError(t, err) + require.True(t, strings.HasPrefix(hash, "$2y$"), "expected $2y$ prefix, got %q", hash) + + cost, err := bcrypt.Cost([]byte(hash)) + require.NoError(t, err) + require.Equal(t, 12, cost) + + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash), []byte("Test12345!"))) +} + +// Test_verifyHash_AcceptsForeignHashes verifies against hashes produced by the +// system reference tools, proving cross-implementation compatibility. +func Test_verifyHash_AcceptsForeignHashes(t *testing.T) { + cases := []struct{ name, stored string }{ + {"apr1", "$apr1$SsFduAdd$N8RB421wyIBb686LI12ko."}, + {"md5", "$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/"}, + {"sha256", "$5$saltsalt$gOjOtoMpVhru2uyjeJSEc/JaLQWOXMNmlOnj6T4AtC."}, + {"sha512", "$6$saltsalt$qFmFH.bQmmtXzyBY0s9v7Oicd2z4XSIecDzlB5KiA2/jctKu9YterLp8wwnSq.qc.eoxqOmSuNp2xS0ktL3nh/"}, + {"des", "abJnggxhB/yWI"}, + {"sha256-rounds", "$5$rounds=10000$saltsalt$a6WJS3V6B3leg7T3.ELC5.vcUmHOyFDvLaurLBy.mc8"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ok, err := verifyHash("password", c.stored) + require.NoError(t, err) + require.True(t, ok) + + ok, err = verifyHash("wrongpass", c.stored) + require.NoError(t, err) + require.False(t, ok) + }) + } +} + +func Test_validateUsername(t *testing.T) { + require.NoError(t, validateUsername("alice")) + require.NoError(t, validateUsername("")) // allowed: bare-hash stdout mode + require.Error(t, validateUsername("ali:ce")) + require.Error(t, validateUsername("ali\nce")) + require.Error(t, validateUsername(strings.Repeat("x", 256))) +} + +func Test_readPasswordLine(t *testing.T) { + pw, err := readPasswordLine(strings.NewReader("Test12345!\n")) + require.NoError(t, err) + require.Equal(t, "Test12345!", pw) + + _, err = readPasswordLine(strings.NewReader("\n")) + require.Error(t, err) + + _, err = readPasswordLine(strings.NewReader("")) + require.Error(t, err) +} diff --git a/internal/tools/htpasswd/md5crypt.go b/internal/tools/htpasswd/md5crypt.go new file mode 100644 index 000000000..e443e365b --- /dev/null +++ b/internal/tools/htpasswd/md5crypt.go @@ -0,0 +1,142 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "crypto/md5" + "strings" +) + +// magicAPR1 is Apache's MD5 variant marker (htpasswd -m); magicMD5 is the +// original FreeBSD/PHK md5crypt marker. The two produce different hashes for +// the same password because the magic is folded into the digest. +const ( + magicAPR1 = "$apr1$" + magicMD5 = "$1$" +) + +// md5Crypt implements the Poul-Henning Kamp MD5-based crypt algorithm used by +// both '$1$' (magicMD5) and Apache's '$apr1$' (magicAPR1). The salt is capped +// at 8 characters, matching the reference implementation. Returns the full +// "$" string. +func md5Crypt(password, salt, magic string) string { + if len(salt) > 8 { + salt = salt[:8] + } + + pw := []byte(password) + + // Primary digest: password, magic, salt. + primary := md5.New() + primary.Write(pw) + primary.Write([]byte(magic)) + primary.Write([]byte(salt)) + + // Alternate digest: password, salt, password. Its bytes are folded back + // into the primary digest, once per password byte. + alt := md5.New() + alt.Write(pw) + alt.Write([]byte(salt)) + alt.Write(pw) + altSum := alt.Sum(nil) + + for i := len(pw); i > 0; i -= 16 { + if i > 16 { + primary.Write(altSum[:16]) + } else { + primary.Write(altSum[:i]) + } + } + + // Fold the password length into the digest one bit at a time: a NUL byte + // for a 1 bit, the first password byte for a 0 bit. This is the historical + // quirk that makes md5crypt md5crypt. + for i := len(pw); i > 0; i >>= 1 { + if i&1 != 0 { + primary.Write([]byte{0}) + } else { + primary.Write(pw[:1]) + } + } + + sum := primary.Sum(nil) + + // 1000 strengthening rounds, each permuting which of password/salt/previous + // digest are mixed in and in what order. + for i := 0; i < 1000; i++ { + c := md5.New() + + if i&1 != 0 { + c.Write(pw) + } else { + c.Write(sum[:16]) + } + + if i%3 != 0 { + c.Write([]byte(salt)) + } + + if i%7 != 0 { + c.Write(pw) + } + + if i&1 != 0 { + c.Write(sum[:16]) + } else { + c.Write(pw) + } + + sum = c.Sum(nil) + } + + var out []byte + + out = to64(out, uint32(sum[0])<<16|uint32(sum[6])<<8|uint32(sum[12]), 4) + out = to64(out, uint32(sum[1])<<16|uint32(sum[7])<<8|uint32(sum[13]), 4) + out = to64(out, uint32(sum[2])<<16|uint32(sum[8])<<8|uint32(sum[14]), 4) + out = to64(out, uint32(sum[3])<<16|uint32(sum[9])<<8|uint32(sum[15]), 4) + out = to64(out, uint32(sum[4])<<16|uint32(sum[10])<<8|uint32(sum[5]), 4) + out = to64(out, uint32(sum[11]), 2) + + return magic + salt + "$" + string(out) +} + +// parseMD5Crypt splits a "$" string into its magic and +// salt so a candidate password can be re-hashed with the same parameters. +func parseMD5Crypt(hash string) (string, string, bool) { + var magic string + + switch { + case strings.HasPrefix(hash, magicAPR1): + magic = magicAPR1 + case strings.HasPrefix(hash, magicMD5): + magic = magicMD5 + default: + return "", "", false + } + + salt, _, found := strings.Cut(hash[len(magic):], "$") + if !found { + return "", "", false + } + + if len(salt) > 8 { + salt = salt[:8] + } + + return magic, salt, true +} diff --git a/internal/tools/htpasswd/passwdfile.go b/internal/tools/htpasswd/passwdfile.go new file mode 100644 index 000000000..031d7d884 --- /dev/null +++ b/internal/tools/htpasswd/passwdfile.go @@ -0,0 +1,175 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +// defaultFileMode is applied to a freshly created password file. Apache htpasswd +// instead requests 0666 and lets the umask reduce it (so 0644 under a 022 umask, +// 0664 under 002); d8 sets 0644 directly. On an update the existing file's mode +// is preserved instead (see loadPasswdFile). +const defaultFileMode os.FileMode = 0o644 + +// passwdFile is an in-memory view of an htpasswd file. Lines that do not match +// the user being edited — other users, comments, blank lines — are preserved +// verbatim, so editing one entry never disturbs the rest of the file. +type passwdFile struct { + path string + lines []string + mode os.FileMode +} + +// loadPasswdFile reads an existing htpasswd file, preserving its permission bits. +func loadPasswdFile(path string) (*passwdFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + pf := &passwdFile{path: path, mode: defaultFileMode} + if info, statErr := os.Stat(path); statErr == nil { + pf.mode = info.Mode().Perm() + } + + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + + for scanner.Scan() { + pf.lines = append(pf.lines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + return pf, nil +} + +// newPasswdFile returns an empty file view for the '-c' create flow. +func newPasswdFile(path string) *passwdFile { + return &passwdFile{path: path, mode: defaultFileMode} +} + +// lineUser returns the username field (everything before the first colon). +func lineUser(line string) string { + user, _, _ := strings.Cut(line, ":") + + return user +} + +// get returns the stored hash for username. +func (pf *passwdFile) get(username string) (string, bool) { + for _, line := range pf.lines { + if lineUser(line) == username { + _, hash, _ := strings.Cut(line, ":") + + return hash, true + } + } + + return "", false +} + +// upsert sets username's hash, replacing every existing entry for that user in +// place (matching real htpasswd, which rewrites all duplicate lines) or +// appending a new one when there is none. It reports whether the user already +// existed. +func (pf *passwdFile) upsert(username, hash string) bool { + newLine := username + ":" + hash + found := false + + for i, line := range pf.lines { + if lineUser(line) == username { + pf.lines[i] = newLine + found = true + } + } + + if !found { + pf.lines = append(pf.lines, newLine) + } + + return found +} + +// remove deletes username's entry, reporting whether it existed. +func (pf *passwdFile) remove(username string) bool { + kept := make([]string, 0, len(pf.lines)) + found := false + + for _, line := range pf.lines { + if lineUser(line) == username { + found = true + + continue + } + + kept = append(kept, line) + } + + pf.lines = kept + + return found +} + +// save writes the file atomically: it renders to a temp file in the same +// directory and renames it into place, so a crash mid-write can never leave a +// truncated password file. +func (pf *passwdFile) save() error { + var buf bytes.Buffer + for _, line := range pf.lines { + buf.WriteString(line) + buf.WriteByte('\n') + } + + dir := filepath.Dir(pf.path) + + tmp, err := os.CreateTemp(dir, ".htpasswd-*") + if err != nil { + return fmt.Errorf("creating temp file in %s: %w", dir, err) + } + + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(buf.Bytes()); err != nil { + tmp.Close() + + return fmt.Errorf("writing temp file: %w", err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Chmod(tmpName, pf.mode); err != nil { + return fmt.Errorf("setting file mode: %w", err) + } + + if err := os.Rename(tmpName, pf.path); err != nil { + return fmt.Errorf("replacing %s: %w", pf.path, err) + } + + return nil +} diff --git a/internal/tools/htpasswd/passwdfile_test.go b/internal/tools/htpasswd/passwdfile_test.go new file mode 100644 index 000000000..fb2f06f30 --- /dev/null +++ b/internal/tools/htpasswd/passwdfile_test.go @@ -0,0 +1,342 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// These unit tests exercise the password-FILE handling in passwdfile.go +// (loadPasswdFile / newPasswdFile / get / upsert / remove / save) and pin down +// its behavior against real Apache htpasswd. Identifiers are prefixed `pf_` to +// avoid collisions with the other _test.go files in package htpasswd. + +// pf_write creates a file with exact permission bits (chmod after write so the +// umask cannot mask the mode we assert on) and returns its path. +func pf_write(t *testing.T, dir, name, content string, mode os.FileMode) string { + t.Helper() + + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), mode)) + require.NoError(t, os.Chmod(path, mode)) + + return path +} + +// pf_read returns a file's contents as a string. +func pf_read(t *testing.T, path string) string { + t.Helper() + + b, err := os.ReadFile(path) + require.NoError(t, err) + + return string(b) +} + +// Test_pf_LineUser pins lineUser = substring before the first ':'. Note the two +// edge cases that matter for the empty-username collision below: a blank line +// has user "" and a comment keeps its whole text as the "user". +func Test_pf_LineUser(t *testing.T) { + cases := []struct{ line, want string }{ + {"alice:$apr1$x", "alice"}, + {"", ""}, // blank line -> user "" + {"# comment", "# comment"}, // comment -> whole line + {"nocolon", "nocolon"}, + {":hashonly", ""}, + {"a:b:c", "a"}, + } + for _, c := range cases { + require.Equal(t, c.want, lineUser(c.line), "lineUser(%q)", c.line) + } +} + +// Test_pf_UpsertPreservesOtherLines is the core parity case: updating one user +// leaves every other line — users, a comment, a blank line — byte-for-byte in +// place, exactly like real htpasswd. upsert of an existing user returns true. +func Test_pf_UpsertPreservesOtherLines(t *testing.T) { + dir := t.TempDir() + path := pf_write(t, dir, "users", "# top comment\nalice:AAA\n\n# mid comment\nbob:BBB\n", 0o644) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + + existed := pf.upsert("alice", "NEWALICE") + require.True(t, existed, "alice already existed, upsert must report true") + + require.NoError(t, pf.save()) + + require.Equal(t, + "# top comment\nalice:NEWALICE\n\n# mid comment\nbob:BBB\n", + pf_read(t, path), + "only alice's line changes; comment, blank line, and bob stay in place", + ) +} + +// Test_pf_UpsertAppendsNewUser: a brand-new user is appended after existing +// lines and upsert returns false (did not exist). +func Test_pf_UpsertAppendsNewUser(t *testing.T) { + dir := t.TempDir() + path := pf_write(t, dir, "users", "alice:AAA\n", 0o644) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + + existed := pf.upsert("bob", "BBB") + require.False(t, existed, "bob is new, upsert must report false") + + require.NoError(t, pf.save()) + require.Equal(t, "alice:AAA\nbob:BBB\n", pf_read(t, path)) +} + +// Test_pf_GetFirstMatchAndMissing: get returns the first match's hash and true; +// a missing user yields ("", false). +func Test_pf_GetFirstMatchAndMissing(t *testing.T) { + pf := &passwdFile{lines: []string{"alice:AAA", "bob:BBB"}} + + hash, ok := pf.get("bob") + require.True(t, ok) + require.Equal(t, "BBB", hash) + + hash, ok = pf.get("carol") + require.False(t, ok) + require.Equal(t, "", hash) +} + +// Test_pf_RemoveReturnsFoundAndAbsent: remove reports true and drops the line +// when present, false and leaves the slice intact when absent. +func Test_pf_RemoveReturnsFoundAndAbsent(t *testing.T) { + pf := &passwdFile{lines: []string{"alice:AAA", "bob:BBB"}} + + require.True(t, pf.remove("alice")) + require.Equal(t, []string{"bob:BBB"}, pf.lines) + + require.False(t, pf.remove("ghost")) + require.Equal(t, []string{"bob:BBB"}, pf.lines, "a no-op remove must not disturb the file") +} + +// Test_pf_DuplicateUser_RemoveDropsAll documents that remove deletes EVERY +// matching line. This MATCHES real htpasswd -D on a duplicate-user file. +func Test_pf_DuplicateUser_RemoveDropsAll(t *testing.T) { + pf := &passwdFile{lines: []string{"alice:AAA", "bob:BBB", "alice:CCC"}} + + require.True(t, pf.remove("alice")) + require.Equal(t, []string{"bob:BBB"}, pf.lines, "both alice entries removed, like real htpasswd -D") +} + +// Test_pf_DuplicateUser_UpsertReplacesAll pins the parity-fixed behavior: upsert +// rewrites EVERY matching line for a user (not just the first), matching real +// htpasswd. A password rotation on a duplicate-user file therefore leaves no +// stale old-hash line behind. +func Test_pf_DuplicateUser_UpsertReplacesAll(t *testing.T) { + pf := &passwdFile{lines: []string{"alice:AAA", "bob:BBB", "alice:CCC"}} + + existed := pf.upsert("alice", "UPDATED") + require.True(t, existed) + + require.Equal(t, + []string{"alice:UPDATED", "bob:BBB", "alice:UPDATED"}, + pf.lines, + "both alice lines are updated, like real htpasswd; no stale duplicate remains", + ) + + hash, ok := pf.get("alice") + require.True(t, ok) + require.Equal(t, "UPDATED", hash) +} + +// Test_pf_EmptyUsernameCollidesWithBlankLine pins a low-level quirk of the +// passwdFile methods: because a blank line's user is "", an empty-username +// get/upsert/remove collides with blank lines (real htpasswd would instead +// append a ":hash" line and preserve the blank line). This is no longer +// reachable from the CLI — the file flows reject an empty username up front (see +// Test_cli_EmptyUsernameRejectedForFileOps) — but the methods themselves still +// behave this way, so the guard at the flow layer must stay. +func Test_pf_EmptyUsernameCollidesWithBlankLine(t *testing.T) { + dir := t.TempDir() + // Leading blank line, a comment, then a real user. + path := pf_write(t, dir, "users", "\n# comment\nalice:AAA\n", 0o644) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + require.Equal(t, []string{"", "# comment", "alice:AAA"}, pf.lines) + + // get("") matches the blank line and falsely reports the empty user exists. + hash, ok := pf.get("") + require.True(t, ok, "quirk: get(\"\") matches a blank line") + require.Equal(t, "", hash) + + // upsert("") OVERWRITES the blank line instead of appending, and reports + // existed=true (so the CLI prints "Updating" rather than "Adding"). + existed := pf.upsert("", "ZZZ") + require.True(t, existed, "quirk: upsert(\"\") reports the empty user pre-existed") + require.Equal(t, + []string{":ZZZ", "# comment", "alice:AAA"}, + pf.lines, + "quirk: the blank line is clobbered; real htpasswd would keep it and append :ZZZ", + ) + + // remove("") drops all blank lines. + pf2, err := loadPasswdFile(path) + require.NoError(t, err) + require.True(t, pf2.remove(""), "quirk: remove(\"\") deletes blank lines") + require.Equal(t, []string{"# comment", "alice:AAA"}, pf2.lines) +} + +// Test_pf_PermissionBitsPreservedOnUpdate: loadPasswdFile captures the file's +// mode via os.Stat and save re-applies it, so an update keeps the original +// permission bits (0o600 here) even though save writes a brand-new inode. +func Test_pf_PermissionBitsPreservedOnUpdate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits") + } + + dir := t.TempDir() + path := pf_write(t, dir, "users", "alice:AAA\n", 0o600) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), pf.mode) + + pf.upsert("bob", "BBB") + require.NoError(t, pf.save()) + + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "update must preserve the file's mode") +} + +// Test_pf_NewFile_OverwritesContentAndResetsMode covers the '-c' flow: +// newPasswdFile does not read the existing file, so save OVERWRITES its content +// and resets the mode to the hardcoded default 0o644 (Chmod is exact — it +// ignores umask and the file's previous 0o600), regardless of what was there. +func Test_pf_NewFile_OverwritesContentAndResetsMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission bits") + } + + dir := t.TempDir() + path := pf_write(t, dir, "users", "old:OLD\nkeep:KEEP\n", 0o600) + + pf := newPasswdFile(path) + pf.upsert("fresh", "NEW") + require.NoError(t, pf.save()) + + require.Equal(t, "fresh:NEW\n", pf_read(t, path), "-c discards all prior content") + + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o644), info.Mode().Perm(), + "-c resets mode to the hardcoded 0o644 default (ignores umask and the prior 0o600)") +} + +// Test_pf_LoadCRLF_RewrittenAsLF: bufio.Scanner strips \r, so a CRLF input file +// loads with clean hashes and save rewrites it LF-only. Real htpasswd keeps the +// original \r\n bytes on untouched lines; the LF normalization here is an +// acceptable (arguably safer) divergence for htpasswd files. +func Test_pf_LoadCRLF_RewrittenAsLF(t *testing.T) { + dir := t.TempDir() + path := pf_write(t, dir, "users", "alice:AAA\r\nbob:BBB\r\n", 0o644) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + + // The trailing \r is stripped, so the parsed hash is clean. + hash, ok := pf.get("bob") + require.True(t, ok) + require.Equal(t, "BBB", hash, "carriage return must not contaminate the hash field") + + pf.upsert("carol", "CCC") + require.NoError(t, pf.save()) + + out := pf_read(t, path) + require.NotContains(t, out, "\r", "save normalizes line endings to LF") + require.Equal(t, "alice:AAA\nbob:BBB\ncarol:CCC\n", out) +} + +// Test_pf_Save_AddsTrailingNewline: a file with no trailing newline is rewritten +// with exactly one '\n' per line. Real htpasswd instead appends the new record +// directly onto the last (newline-less) line, corrupting it into +// "alice:AAAbob:BBB"; save's per-line rendering is the safer behavior. +func Test_pf_Save_AddsTrailingNewline(t *testing.T) { + dir := t.TempDir() + path := pf_write(t, dir, "users", "alice:AAA", 0o644) // no trailing newline + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + + pf.upsert("bob", "BBB") + require.NoError(t, pf.save()) + + require.Equal(t, "alice:AAA\nbob:BBB\n", pf_read(t, path), + "save renders each entry on its own newline-terminated line") +} + +// Test_pf_Remove_LastUserLeavesEmptyFile: deleting the only user leaves a +// present, 0-byte file rather than removing it — this MATCHES real htpasswd -D. +func Test_pf_Remove_LastUserLeavesEmptyFile(t *testing.T) { + dir := t.TempDir() + path := pf_write(t, dir, "users", "only:AAA\n", 0o644) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + + require.True(t, pf.remove("only")) + require.NoError(t, pf.save()) + + info, err := os.Stat(path) + require.NoError(t, err, "file must still exist") + require.Equal(t, int64(0), info.Size(), "last-user deletion leaves a 0-byte file, like htpasswd") +} + +// Test_pf_Load_MissingFileReturnsOSError: loadPasswdFile surfaces the raw os +// error so callers can detect not-exist and print a friendly message. +func Test_pf_Load_MissingFileReturnsOSError(t *testing.T) { + _, err := loadPasswdFile(filepath.Join(t.TempDir(), "does-not-exist")) + require.Error(t, err) + require.True(t, os.IsNotExist(err), "want an os not-exist error, got %v", err) +} + +// Test_pf_Save_RequiresWritableDirectory documents a PARITY-GAP of the +// temp+rename design: save uses os.CreateTemp in the target's directory, so it +// fails when the DIRECTORY is not writable — even though the target file itself +// is writable. Real htpasswd rewrites the file in place and only needs the file +// writable. Skipped as root (root bypasses directory permissions) and on +// Windows (mode bits do not gate writes the same way). +func Test_pf_Save_RequiresWritableDirectory(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permission gating is unix/non-root only") + } + + dir := t.TempDir() + path := pf_write(t, dir, "users", "alice:AAA\n", 0o644) + + pf, err := loadPasswdFile(path) + require.NoError(t, err) + pf.upsert("alice", "NEW") + + require.NoError(t, os.Chmod(dir, 0o555)) // read+exec, not writable + defer func() { _ = os.Chmod(dir, 0o755) }() // restore so t.TempDir cleanup works + + err = pf.save() + require.Error(t, err, "save cannot create its temp file in a non-writable directory") + require.Contains(t, err.Error(), "creating temp file") +} diff --git a/internal/tools/htpasswd/shacrypt.go b/internal/tools/htpasswd/shacrypt.go new file mode 100644 index 000000000..e8e836af9 --- /dev/null +++ b/internal/tools/htpasswd/shacrypt.go @@ -0,0 +1,251 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "crypto/sha256" + "crypto/sha512" + "hash" + "strconv" + "strings" +) + +// SHA-crypt parameters, matching glibc: rounds default to 5000 and are clamped +// to [1000, 999999999]. The 'rounds=' field is emitted only when it differs +// from the default, so common hashes stay compact. +const ( + shaCryptDefaultRounds = 5000 + shaCryptMinRounds = 1000 + shaCryptMaxRounds = 999999999 +) + +// shaCrypt implements Ulrich Drepper's SHA-256/512 crypt (the '$5$' and '$6$' +// schemes). is512 selects SHA-512 (64-byte digest) over SHA-256 (32-byte). +// Salt is capped at 16 characters. See https://www.akkadia.org/drepper/SHA-crypt.txt. +func shaCrypt(password, salt string, rounds int, is512 bool) string { + if len(salt) > 16 { + salt = salt[:16] + } + + explicitRounds := rounds != 0 + if rounds == 0 { + rounds = shaCryptDefaultRounds + } + + if rounds < shaCryptMinRounds { + rounds = shaCryptMinRounds + } + + if rounds > shaCryptMaxRounds { + rounds = shaCryptMaxRounds + } + + newHash := func() hash.Hash { + if is512 { + return sha512.New() + } + + return sha256.New() + } + + var size int + if is512 { + size = sha512.Size + } else { + size = sha256.Size + } + + pw := []byte(password) + saltB := []byte(salt) + + // Digest B = H(password, salt, password). + b := newHash() + b.Write(pw) + b.Write(saltB) + b.Write(pw) + sumB := b.Sum(nil) + + // Digest A = H(password, salt, B repeated for len(password), then a bit + // pattern of A/password chosen by the bits of len(password)). + a := newHash() + a.Write(pw) + a.Write(saltB) + + for i := len(pw); i > 0; i -= size { + if i > size { + a.Write(sumB) + } else { + a.Write(sumB[:i]) + } + } + + for i := len(pw); i > 0; i >>= 1 { + if i&1 != 0 { + a.Write(sumB) + } else { + a.Write(pw) + } + } + + sumA := a.Sum(nil) + + // Sequence P: H(password)*len(password), tiled to len(password) bytes. + dp := newHash() + for i := 0; i < len(pw); i++ { + dp.Write(pw) + } + + sumDP := dp.Sum(nil) + p := tile(sumDP, len(pw), size) + + // Sequence S: H(salt)*(16 + A[0]), tiled to len(salt) bytes. + ds := newHash() + for i := 0; i < 16+int(sumA[0]); i++ { + ds.Write(saltB) + } + + sumDS := ds.Sum(nil) + s := tile(sumDS, len(saltB), size) + + // Strengthening loop: `rounds` iterations, each mixing P, S and the running + // digest in an order chosen by the round index. + cur := sumA + + for i := 0; i < rounds; i++ { + c := newHash() + + if i&1 != 0 { + c.Write(p) + } else { + c.Write(cur) + } + + if i%3 != 0 { + c.Write(s) + } + + if i%7 != 0 { + c.Write(p) + } + + if i&1 != 0 { + c.Write(cur) + } else { + c.Write(p) + } + + cur = c.Sum(nil) + } + + checksum := shaCryptEncode(cur, is512) + + var prefix string + if is512 { + prefix = "$6$" + } else { + prefix = "$5$" + } + + if explicitRounds || rounds != shaCryptDefaultRounds { + prefix += "rounds=" + strconv.Itoa(rounds) + "$" + } + + return prefix + salt + "$" + checksum +} + +// tile repeats src to fill exactly length bytes (whole copies of size, then a +// final partial copy), producing the P and S sequences. +func tile(src []byte, length, size int) []byte { + out := make([]byte, 0, length) + for length >= size { + out = append(out, src...) + length -= size + } + + return append(out, src[:length]...) +} + +// shaCryptEncode performs the scheme-specific reordering of the final digest +// into crypt64 characters. The index permutation differs between SHA-256 and +// SHA-512 and comes straight from Drepper's reference implementation. +func shaCryptEncode(d []byte, is512 bool) string { + var out []byte + + if is512 { + groups := [][3]int{ + {0, 21, 42}, {22, 43, 1}, {44, 2, 23}, {3, 24, 45}, {25, 46, 4}, + {47, 5, 26}, {6, 27, 48}, {28, 49, 7}, {50, 8, 29}, {9, 30, 51}, + {31, 52, 10}, {53, 11, 32}, {12, 33, 54}, {34, 55, 13}, {56, 14, 35}, + {15, 36, 57}, {37, 58, 16}, {59, 17, 38}, {18, 39, 60}, {40, 61, 19}, + {62, 20, 41}, + } + for _, g := range groups { + out = to64(out, uint32(d[g[0]])<<16|uint32(d[g[1]])<<8|uint32(d[g[2]]), 4) + } + + out = to64(out, uint32(d[63]), 2) + + return string(out) + } + + groups := [][3]int{ + {0, 10, 20}, {21, 1, 11}, {12, 22, 2}, {3, 13, 23}, {24, 4, 14}, + {15, 25, 5}, {6, 16, 26}, {27, 7, 17}, {18, 28, 8}, {9, 19, 29}, + } + for _, g := range groups { + out = to64(out, uint32(d[g[0]])<<16|uint32(d[g[1]])<<8|uint32(d[g[2]]), 4) + } + + out = to64(out, uint32(d[31])<<8|uint32(d[30]), 3) + + return string(out) +} + +// parseShaCrypt extracts the rounds and salt from a '$5$'/'$6$' hash so a +// candidate password can be re-hashed identically for verification. +func parseShaCrypt(hash string) (int, string, bool, bool) { + is512 := strings.HasPrefix(hash, "$6$") + if !is512 && !strings.HasPrefix(hash, "$5$") { + return 0, "", false, false + } + + // fields: ["", "5"|"6", (optional "rounds=N"), salt, checksum] + fields := strings.Split(hash, "$")[2:] + + rounds := 0 + + if len(fields) > 0 && strings.HasPrefix(fields[0], "rounds=") { + n, err := strconv.Atoi(strings.TrimPrefix(fields[0], "rounds=")) + if err != nil { + return 0, "", false, false + } + + rounds = n + fields = fields[1:] + } + + if len(fields) < 1 { + return 0, "", false, false + } + + salt := fields[0] + if len(salt) > 16 { + salt = salt[:16] + } + + return rounds, salt, is512, true +} diff --git a/internal/tools/htpasswd/vectors_test.go b/internal/tools/htpasswd/vectors_test.go new file mode 100644 index 000000000..9ae1a88f9 --- /dev/null +++ b/internal/tools/htpasswd/vectors_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package htpasswd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// These vectors were produced by the system's reference implementations +// (python3 crypt / glibc and `openssl passwd`) so a mismatch means our port +// diverged from the canonical algorithm, not merely from itself. + +func Test_desCrypt_Vectors(t *testing.T) { + cases := []struct{ password, salt, want string }{ + {"password", "ab", "abJnggxhB/yWI"}, + {"Test123!", "xy", "xyZz5eiXOP3r."}, + {"hello", "zz", "zzM3H1GzLNjgA"}, + } + for _, c := range cases { + require.Equal(t, c.want, desCrypt(c.password, c.salt), "desCrypt(%q,%q)", c.password, c.salt) + } +} + +func Test_md5Crypt_Vectors(t *testing.T) { + require.Equal(t, + "$apr1$SsFduAdd$N8RB421wyIBb686LI12ko.", + md5Crypt("password", "SsFduAdd", magicAPR1)) + require.Equal(t, + "$apr1$abcdefgh$gj2HqWsjGbOdAts0DpThK.", + md5Crypt("Test123!", "abcdefgh", magicAPR1)) + require.Equal(t, + "$1$saltsalt$qjXMvbEw8oaL.CzflDtaK/", + md5Crypt("password", "saltsalt", magicMD5)) +} + +func Test_shaCrypt_Vectors(t *testing.T) { + // SHA-256 ($5$) + require.Equal(t, + "$5$saltsalt$gOjOtoMpVhru2uyjeJSEc/JaLQWOXMNmlOnj6T4AtC.", + shaCrypt("password", "saltsalt", 0, false)) + require.Equal(t, + "$5$rounds=10000$saltsalt$a6WJS3V6B3leg7T3.ELC5.vcUmHOyFDvLaurLBy.mc8", + shaCrypt("password", "saltsalt", 10000, false)) + + // SHA-512 ($6$) + require.Equal(t, + "$6$saltsalt$qFmFH.bQmmtXzyBY0s9v7Oicd2z4XSIecDzlB5KiA2/jctKu9YterLp8wwnSq.qc.eoxqOmSuNp2xS0ktL3nh/", + shaCrypt("password", "saltsalt", 0, true)) + require.Equal(t, + "$6$rounds=10000$saltsalt$ZqOTO2O04D/DgwZlm.rZTgWxvBaIf4LQsZKtXFEu9UHJ4CvgmdLAGxKUzJ0mPO98OevETdY6oK/Oac6j2Axxq/", + shaCrypt("password", "saltsalt", 10000, true)) + require.Equal(t, + "$6$abcdefghijklmnop$Vthr3YXPXseV5egL67KCgMNLr7uYIxy6j/lec/PGvO5oJWeGG/ZXLCHkfFp9nryV.VdKV/0fzFJwmOSHHocNf1", + shaCrypt("Test123!", "abcdefghijklmnop", 0, true)) +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index 5664cefca..66250d421 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -22,6 +22,7 @@ import ( farconverter "github.com/deckhouse/deckhouse-cli/internal/tools/farconverter/cmd" gostsum "github.com/deckhouse/deckhouse-cli/internal/tools/gostsum/cmd" + htpasswd "github.com/deckhouse/deckhouse-cli/internal/tools/htpasswd/cmd" imagedigest "github.com/deckhouse/deckhouse-cli/internal/tools/imagedigest/cmd" pki "github.com/deckhouse/deckhouse-cli/internal/tools/pki/cmd" sigmigrate "github.com/deckhouse/deckhouse-cli/internal/tools/sigmigrate/cmd" @@ -43,6 +44,7 @@ func NewCommand() *cobra.Command { toolsCmd.AddCommand( farconverter.NewCommand(), gostsum.NewCommand(), + htpasswd.NewCommand(), imagedigest.NewCommand(), pki.NewCommand(), sigmigrate.NewCommand(),