Merge pull request 'v2 refactor' (#54) from v2 into main

Reviewed-on: #54
This commit is contained in:
Winni Neessen 2024-03-12 21:39:55 +01:00
commit b9b93905b2
59 changed files with 2258 additions and 1117 deletions

View file

@ -1,23 +0,0 @@
container:
image: golang:latest
env:
GOPROXY: https://proxy.golang.org
lint_task:
name: GolangCI Lint
container:
image: golangci/golangci-lint:latest
run_script: golangci-lint run -v --timeout 5m0s --out-format json > lint-report.json
always:
golangci_artifacts:
path: lint-report.json
type: text/json
format: golangci
build_task:
modules_cache:
folder: $GOPATH/pkg/mod
get_script: go get github.com/wneessen/apg-go/cmd/apg
build_script: go build github.com/wneessen/apg-go/cmd/apg
test_script: go test github.com/wneessen/apg-go/cmd/apg

View file

@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: golangci-lint
on:
push:
tags:
- v*
branches:
- main
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
# pull-requests: read
jobs:
golangci:
name: lint
runs-on: docker
steps:
- uses: actions/setup-go@v4
with:
go-version: '1.22'
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: latest
# Optional: working directory, useful for monorepos
# working-directory: somedir
# Optional: golangci-lint command line arguments.
# args: --issues-exit-code=0
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
# Optional: if set to true then the all caching functionality will be complete disabled,
# takes precedence over all other caching options.
# skip-cache: true
# Optional: if set to true then the action don't cache or restore ~/go/pkg.
# skip-pkg-cache: true
# Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
# skip-build-cache: true

View file

@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2023 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: REUSE Compliance Check
on: [push, pull_request]
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v2
- name: REUSE Compliance Check
uses: fsfe/reuse-action@v1

View file

@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: 2023 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: SonarQube
on:
push:
branches:
- main # or the name of your main branch
pull_request:
branches:
- main # or the name of your main branch
env:
API_KEY: ${{ secrets.API_KEY }}
jobs:
build:
name: Build
runs-on: docker
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: '1.22'
- name: Run unit Tests
run: |
go test -v -shuffle=on -race --coverprofile=./cov.out ./...
- name: Install jq
run: |
apt-get update; apt-get -y install jq; which jq
- uses: sonarsource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

4
.github/FUNDING.yml vendored
View file

@ -1,2 +1,6 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
github: wneessen github: wneessen
ko_fi: winni ko_fi: winni

View file

@ -1,3 +1,9 @@
<!--
SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
SPDX-License-Identifier: CC0-1.0
-->
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve about: Create a report to help us improve

View file

@ -1,3 +1,9 @@
<!--
SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
SPDX-License-Identifier: CC0-1.0
-->
--- ---
name: Feature request name: Feature request
about: Suggest an idea for this project about: Suggest an idea for this project

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# To get started with Dependabot version updates, you'll need to specify which # To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located. # package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options: # Please see the documentation for all configuration options:

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# For most projects, this workflow file will not need changing; you simply need # For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository. # to commit it to your repository.
# #

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: Docker name: Docker
# This workflow uses actions that are not certified by GitHub. # This workflow uses actions that are not certified by GitHub.

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: Go name: Go
on: on:

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: Go name: Go
on: on:

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: Go name: Go
on: on:

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: Go name: Go
on: on:

View file

@ -1,3 +1,7 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
name: SonarQube name: SonarQube
on: on:
push: push:

21
.gitignore vendored
View file

@ -1,13 +1,13 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
*.so *.so
*.dylib *.dylib
apg
bin
.go
build
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
@ -17,3 +17,16 @@ build
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
# Local testfiles and auth data
.auth
examples/*
# SonarQube
.scannerwork/
# IDEA specific ignores
.idea/
.idea/.gitignore
dist/

15
.golangci.toml Normal file
View file

@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
[run]
go = "1.22"
tests = true
[linters]
enable = ["stylecheck", "whitespace", "containedctx", "contextcheck", "decorder",
"errname", "errorlint", "gofmt", "gofumpt", "goimports"]
[linters-settings.goimports]
local-prefixes = "src.neessen.cloud/wneessen/apg-go"

62
.goreleaser.yaml Normal file
View file

@ -0,0 +1,62 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
version: 1
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- freebsd
- openbsd
- netbsd
main: ./cmd/apg
binary: apg
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
nfpms:
- vendor: Winni Neessen
maintainer: Winni Neessen <wn@neessen.dev>
formats:
- apk
- deb
- rpm
- termux.deb
- archlinux
dmg:
- replace: true
universal_binaries:

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -1,12 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="GrazieInspection" enabled="false" level="TYPO" enabled_by_default="false" />
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
</profile>
</component>

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/apg-go.iml" filepath="$PROJECT_DIR$/.idea/apg-go.iml" />
</modules>
</component>
</project>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
github+coc@neessen.net.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,20 +0,0 @@
## Build first
FROM golang:latest as builder
RUN mkdir /builddir
ADD . /builddir/
WORKDIR /builddir
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -ldflags '-w -s -extldflags "-static"' -o apg-go \
github.com/wneessen/apg-go/cmd/apg
## Create scratch image
FROM scratch
LABEL maintainer="wn@neessen.net"
COPY ["docker-files/passwd", "/etc/passwd"]
COPY ["docker-files/group", "/etc/group"]
COPY --from=builder ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"]
COPY --chown=apg-go ["LICENSE", "/apg-go/LICENSE"]
COPY --chown=apg-go ["README.md", "/apg-go/README.md"]
COPY --from=builder --chown=apg-go ["/builddir/apg-go", "/apg-go/apg-go"]
WORKDIR /apg-go
USER apg-go
ENTRYPOINT ["/apg-go/apg-go"]

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Winni Neessen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
LICENSES/CC0-1.0.txt Normal file
View file

@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

9
LICENSES/MIT.txt Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,5 +1,13 @@
<!--
SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
SPDX-License-Identifier: CC0-1.0
-->
# A "Automated Password Generator"-clone # A "Automated Password Generator"-clone
[![Go Reference](https://pkg.go.dev/badge/github.com/wneessen/apg-go.svg)](https://pkg.go.dev/github.com/wneessen/apg-go) [![Go Report Card](https://goreportcard.com/badge/github.com/wneessen/apg-go)](https://goreportcard.com/report/github.com/wneessen/apg-go) [![Build Status](https://api.cirrus-ci.com/github/wneessen/apg-go.svg)](https://cirrus-ci.com/github/wneessen/apg-go) ![CodeQL workflow](https://github.com/wneessen/apg-go/actions/workflows/codeql-analysis.yml/badge.svg) <a href="https://ko-fi.com/D1D24V9IX"><img src="https://uploads-ssl.webflow.com/5c14e387dab576fe667689cf/5cbed8a4ae2b88347c06c923_BuyMeACoffee_blue.png" height="20" alt="buy ma a coffee"></a> [![Go Reference](https://pkg.go.dev/badge/src.neessen.cloud/wneessen/apg-go.svg)](https://pkg.go.dev/src.neessen.cloud/wneessen/apg-go)
[![Go Report Card](https://goreportcard.com/badge/src.neessen.cloud/wneessen/apg-go)](https://goreportcard.com/report/src.neessen.cloud/wneessen/apg-go)
<a href="https://ko-fi.com/D1D24V9IX"><img src="https://uploads-ssl.webflow.com/5c14e387dab576fe667689cf/5cbed8a4ae2b88347c06c923_BuyMeACoffee_blue.png" height="20" alt="buy ma a coffee"></a>
_apg-go_ is a simple APG-like password generator written in Go. It tries to replicate the _apg-go_ is a simple APG-like password generator written in Go. It tries to replicate the
functionality of the functionality of the

View file

@ -1,6 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please send a mail to apg-go@pebcak.de when you found a security issue in apg-go, even when you are not 100% certain
that it is actually a security issue. Typically, you will receive an answer within a day or even within a few hours.

38
algo.go Normal file
View file

@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
// Algorithm is a type wrapper for an int type to represent different
// password generation algorithm
type Algorithm int
const (
// AlgoPronounceable represents the algorithm for pronounceable passwords
// (koremutake syllables)
AlgoPronounceable Algorithm = iota
// AlgoRandom represents the algorithm for purely random passwords according
// to the provided password modes/flags
AlgoRandom
// AlgoCoinFlip represents a very simple coinflip algorithm returning "heads"
// or "tails"
AlgoCoinFlip
// AlgoUnsupported represents an unsupported algorithm
AlgoUnsupported
)
// IntToAlgo takes an int value as input and returns the corresponding
// Algorithm
func IntToAlgo(a int) Algorithm {
switch a {
case 0:
return AlgoPronounceable
case 1:
return AlgoRandom
case 2:
return AlgoCoinFlip
default:
return AlgoUnsupported
}
}

28
algo_test.go Normal file
View file

@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import "testing"
func TestIntToAlgo(t *testing.T) {
tt := []struct {
name string
a int
e Algorithm
}{
{"AlgoPronounceable", 0, AlgoPronounceable},
{"AlgoRandom", 1, AlgoRandom},
{"AlgoCoinflip", 2, AlgoCoinFlip},
{"AlgoUnsupported", 3, AlgoUnsupported},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
a := IntToAlgo(tc.a)
if a != tc.e {
t.Errorf("IntToAlgo() failed, expected: %d, got: %d", tc.e, a)
}
})
}
}

24
apg.go Normal file
View file

@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
// VERSION represents the version string
const VERSION = "2.0.0"
// Generator is the password generator type of the APG package
type Generator struct {
// config is a pointer to the apg config instance
config *Config
// syllables holds the single syllables of the lasst generated
// pronounceable password
syllables []string
}
// New returns a new password Generator type
func New(config *Config) *Generator {
return &Generator{
config: config,
}
}

View file

@ -1,32 +0,0 @@
# Maintainer: "Winni Neessen (https://pebcak.de)
pkgname=apg-go
pkgver=0.4.1
pkgrel=1
pkgdesc='A "Automated Password Generator"-clone'
arch=('i686' 'x86_64' 'armv6h' 'armv7h' 'aarch64')
url='https://github.com/wneessen/apg-go'
license=('MIT')
makedepends=('go')
source=("https://github.com/wneessen/${pkgname}/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('64769495843e2c59fc5513a106e58f8751f1649ff8bf6c38cd141322523deea8')
prepare() {
cd "${pkgname}-${pkgver}"
mkdir -p build/
}
build() {
cd "${pkgname}-${pkgver}"
go build -ldflags="-s -w" -o build/${pkgname} github.com/wneessen/apg-go/cmd/apg
}
package() {
# binary
install -D -m755 "${srcdir}/${pkgname}-${pkgver}/build/${pkgname}" \
"${pkgdir}/usr/bin/apg"
# license
install -Dm644 "${srcdir}/${pkgname}-${pkgver}/LICENSE" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -1,21 +0,0 @@
# $OpenBSD$
COMMENT = "automated password generator" clone written in Go
GH_ACCOUNT = wneessen
GH_PROJECT = apg-go
GH_TAGNAME = v0.3.2
CATEGORIES = security
MAINTAINER = Winni Neessen <wn@neessen.net>
# MIT
PERMIT_PACKAGE = Yes
MODULES = lang/go
MODGO_TYPE = bin
ALL_TARGET = wneessen/apg-go/cmd/apg
.include <bsd.port.mk>

View file

@ -1,2 +0,0 @@
SHA256 (apg-go-0.3.2.tar.gz) = QvCC0vVNHLIOHW1jwdkjJVtxEVHJNwQfZBZBgHWM4OQ=
SIZE (apg-go-0.3.2.tar.gz) = 20114

View file

@ -1,14 +0,0 @@
apg-go is a simple APG-like password generator written in Go.
It tries to replicate the functionality of the "Automated Password Generator",
which hasn't been maintained since 2003. Since more and more Unix distributions
are abondoning the tool, I was looking for an alternative. FreeBSD for example
recommends "security/makepasswd", which is written in Perl but requires a lot
of dependency packages and doesn't offer the feature-set/flexibility of APG.
Since FIPS-181 (pronouncable passwords) has been withdrawn in 2015, there is
no use in replicating that feature. Therfore apg-go does not support
pronouncable passwords.
For feature requests or bug reports, please create an issue in the Github
repository at https://github.com/wneessen/apg-go

View file

@ -1,2 +0,0 @@
@comment $OpenBSD: PLIST,v$
@bin bin/apg-go

View file

@ -1,64 +0,0 @@
package chars
import (
"github.com/wneessen/apg-go/config"
"regexp"
)
// PwLowerCharsHuman is the range of lower-case characters in human-readable mode
const PwLowerCharsHuman string = "abcdefghjkmnpqrstuvwxyz"
// PwUpperCharsHuman is the range of upper-case characters in human-readable mode
const PwUpperCharsHuman string = "ABCDEFGHJKMNPQRSTUVWXYZ"
// PwLowerChars is the range of lower-case characters
const PwLowerChars string = "abcdefghijklmnopqrstuvwxyz"
// PwUpperChars is the range of upper-case characters
const PwUpperChars string = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// PwSpecialCharsHuman is the range of special characters in human-readable mode
const PwSpecialCharsHuman string = "\"#%*+-/:;=\\_|~"
// PwSpecialChars is the range of special characters
const PwSpecialChars string = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
// PwNumbersHuman is the range of numbers in human-readable mode
const PwNumbersHuman string = "23456789"
// PwNumbers is the range of numbers
const PwNumbers string = "1234567890"
// GetRange provides the range of available characters based on configured parameters
func GetRange(config *config.Config) string {
pwUpperChars := PwUpperChars
pwLowerChars := PwLowerChars
pwNumbers := PwNumbers
pwSpecialChars := PwSpecialChars
if config.HumanReadable {
pwUpperChars = PwUpperCharsHuman
pwLowerChars = PwLowerCharsHuman
pwNumbers = PwNumbersHuman
pwSpecialChars = PwSpecialCharsHuman
}
var charRange string
if config.UseLowerCase {
charRange = charRange + pwLowerChars
}
if config.UseUpperCase {
charRange = charRange + pwUpperChars
}
if config.UseNumber {
charRange = charRange + pwNumbers
}
if config.UseSpecial {
charRange = charRange + pwSpecialChars
}
if config.ExcludeChars != "" {
regExp := regexp.MustCompile("[" + regexp.QuoteMeta(config.ExcludeChars) + "]")
charRange = regExp.ReplaceAllLiteralString(charRange, "")
}
return charRange
}

View file

@ -1,148 +1,216 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
// Package main is the APG command line client that makes use of the apg-go library
package main package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"github.com/wneessen/apg-go/chars"
"github.com/wneessen/apg-go/config"
"github.com/wneessen/apg-go/random"
"github.com/wneessen/apg-go/spelling"
"github.com/wneessen/go-hibp"
"log"
"os" "os"
"runtime" "runtime"
"strings"
"time" "src.neessen.cloud/wneessen/apg-go"
) )
// VersionString represents the current version of the apg-go CLI // MinimumAmountTooHigh is an error message displayed when a minimum amount of
const VersionString string = "0.4.1" // parameter has been set to a too high value
const MinimumAmountTooHigh = "WARNING: You have selected a minimum amount of characters that is bigger\n" +
"than 50% of the minimum password length to be generated. This can lead\n" +
"to extraordinary calculation times resulting in apg-go never finishing\n" +
"the job. Please consider lowering the value.\n\n"
// Help text func main() {
const usage = `apg-go // A "Automated Password Generator"-clone config := apg.NewConfig()
Copyright (c) 2021 Winni Neessen
// Configure and parse the CLI flags
// See usage() for flag details
var algorithm int
var modeString string
var complexPass, humanReadable, lowerCase, numeric, special, showVer, upperCase bool
flag.IntVar(&algorithm, "a", 1, "")
flag.BoolVar(&complexPass, "C", false, "")
flag.StringVar(&config.ExcludeChars, "E", "", "")
flag.Int64Var(&config.FixedLength, "f", 0, "")
flag.BoolVar(&humanReadable, "H", false, "")
flag.BoolVar(&config.SpellPassword, "l", false, "")
flag.BoolVar(&lowerCase, "L", false, "")
flag.Int64Var(&config.MinLength, "m", config.MinLength, "")
flag.Int64Var(&config.MinLowerCase, "mL", config.MinLowerCase, "")
flag.Int64Var(&config.MinNumeric, "mN", config.MinNumeric, "")
flag.Int64Var(&config.MinSpecial, "mS", config.MinSpecial, "")
flag.Int64Var(&config.MinUpperCase, "mU", config.MinUpperCase, "")
flag.Int64Var(&config.NumberPass, "n", config.NumberPass, "")
flag.StringVar(&modeString, "M", "", "")
flag.BoolVar(&numeric, "N", false, "")
flag.BoolVar(&config.CheckHIBP, "p", false, "")
flag.BoolVar(&special, "S", false, "")
flag.BoolVar(&config.SpellPronounceable, "t", false, "")
flag.BoolVar(&upperCase, "U", false, "")
flag.BoolVar(&showVer, "v", false, "")
flag.Int64Var(&config.MaxLength, "x", config.MaxLength, "")
flag.Usage = usage
flag.Parse()
// Show version and exit
if showVer {
_, _ = os.Stderr.WriteString(`apg-go // A "Automated Password Generator"-clone ` +
`v` + apg.VERSION + "\n")
_, _ = os.Stderr.WriteString("OS: " + runtime.GOOS + " // Arch: " +
runtime.GOARCH + " \n")
_, _ = os.Stderr.WriteString("(C) 2021-2024 by Winni Neessen\n")
os.Exit(0)
}
// Old style character modes
if humanReadable {
config.Mode = apg.MaskToggleMode(config.Mode, apg.ModeHumanReadable)
}
if lowerCase {
config.Mode = apg.MaskToggleMode(config.Mode, apg.ModeLowerCase)
}
if upperCase {
config.Mode = apg.MaskToggleMode(config.Mode, apg.ModeUpperCase)
}
if numeric {
config.Mode = apg.MaskToggleMode(config.Mode, apg.ModeNumeric)
}
if special {
config.Mode = apg.MaskToggleMode(config.Mode, apg.ModeSpecial)
}
if complexPass {
config.Mode = apg.MaskSetMode(config.Mode, apg.ModeLowerCase|apg.ModeNumeric|
apg.ModeSpecial|apg.ModeUpperCase)
config.Mode = apg.MaskClearMode(config.Mode, apg.ModeHumanReadable)
}
// New style character modes (has higher priority than the old style modes)
if modeString != "" {
config.Mode = apg.ModesFromFlags(modeString)
}
// For the "minimum amount of" modes we need to imply at the type
// of character mode is set
if config.MinLowerCase > 0 {
if float64(config.MinLength)/2 < float64(config.MinNumeric) {
_, _ = os.Stderr.WriteString(MinimumAmountTooHigh)
}
config.Mode = apg.MaskSetMode(config.Mode, apg.ModeLowerCase)
}
if config.MinNumeric > 0 {
if float64(config.MinLength)/2 < float64(config.MinLowerCase) {
_, _ = os.Stderr.WriteString(MinimumAmountTooHigh)
}
config.Mode = apg.MaskSetMode(config.Mode, apg.ModeNumeric)
}
if config.MinSpecial > 0 {
if float64(config.MinLength)/2 < float64(config.MinSpecial) {
_, _ = os.Stderr.WriteString(MinimumAmountTooHigh)
}
config.Mode = apg.MaskSetMode(config.Mode, apg.ModeSpecial)
}
if config.MinUpperCase > 0 {
if float64(config.MinLength)/2 < float64(config.MinUpperCase) {
_, _ = os.Stderr.WriteString(MinimumAmountTooHigh)
}
config.Mode = apg.MaskSetMode(config.Mode, apg.ModeUpperCase)
}
// Check if algorithm is supported
config.Algorithm = apg.IntToAlgo(algorithm)
if config.Algorithm == apg.AlgoUnsupported {
_, _ = fmt.Fprintf(os.Stderr, "unsupported algorithm value: %d\n", algorithm)
os.Exit(1)
}
// Generate the password based on the given flags
generator := apg.New(config)
for i := int64(0); i < config.NumberPass; i++ {
password, err := generator.Generate()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to generate password: %s\n", err)
os.Exit(1)
}
if config.Algorithm == apg.AlgoRandom && config.SpellPassword {
spellPass, err := apg.Spell(password)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to spell password: %s\n", err)
}
fmt.Printf("%s (%s)\n", password, spellPass)
continue
}
if config.Algorithm == apg.AlgoPronounceable && config.SpellPronounceable {
pronouncePass, err := generator.Pronounce()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to pronounce password: %s\n", err)
}
fmt.Printf("%s (%s)\n", password, pronouncePass)
continue
}
fmt.Println(password)
if config.CheckHIBP {
pwned, err := apg.HasBeenPwned(password)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "failed to check HIBP database: %s\n", err)
}
if pwned {
fmt.Print("^-- !!WARNING: The previously generated password was found in " +
"HIBP database. Do not use it!!\n")
}
}
}
}
// usage is used by the flag package to display the CLI usage message
func usage() {
// Usage text
const ut = `apg-go v` +
apg.VERSION + "\n" +
`A OSS "Automated Password Generator"-clone -- https://src.neessen.cloud/wneessen/apg-go/
Created 2021-2024 by Winni Neessen (MIT licensed)
apg [-a <algo>] [-m <length>] [-x <length>] [-L] [-U] [-N] [-S] [-H] [-C] apg [-a <algo>] [-m <length>] [-x <length>] [-L] [-U] [-N] [-S] [-H] [-C]
[-l] [-M mode] [-E char_string] [-n num_of_pass] [-v] [-h] [-t] [-l] [-M mode] [-E char_string] [-n num_of_pass] [-mX number] [-t] [-p] [-v] [-h]
Options: Flags:
-a ALGORITH Choose the password generation algorithm (Default: 1) -a ALGORITH Choose the password generation algorithm (Default: 1)
- 0: pronounceable password generation (koremutake syllables) - 0: pronounceable password generation (koremutake syllables)
- 1: random password generation according to password modes/flags - 1: random password generation according to password modes/flags
- 2: coinflip (returns heads or tails)
-m LENGTH Minimum length of the password to be generated (Default: 12) -m LENGTH Minimum length of the password to be generated (Default: 12)
-x LENGTH Maximum length of the password to be generated (Default: 20) -x LENGTH Maximum length of the password to be generated (Default: 20)
-f LENGTH Fixed length of the password to be generated (Ignores -m and -x)
- Note: Due to the way the pronounceable password algorithm works,
this setting might not always apply
-n NUMBER Amount of password to be generated (Default: 6) -n NUMBER Amount of password to be generated (Default: 6)
-E CHARS List of characters to be excluded in the generated password -E CHARS List of characters to be excluded in the generated password
-M [LUNSHClunshc] New style password parameters (upper case: on, lower case: off) -M [LUNSHClunshc] New style password flags
-L Use lower case characters in passwords (Default: on) - Note: new-style flags have higher priority than any of the old-style flags
-U Use upper case characters in passwords (Default: on) -mL NUMBER Minimum amount of lower-case characters (implies -L)
-N Use numeric characters in passwords (Default: on) -mN NUMBER Minimum amount of numeric characters (implies -N)
-S Use special characters in passwords (Default: off) -mS NUMBER Minimum amount of special characters (implies -S)
-mU NUMBER Minimum amount of upper-case characters (implies -U)
- Note: any of the "Minimum amount of" modes may result in
extraordinarily long calculation times
- Note 2: The "minimum amount of" modes do not apply in
pronounceable mode (-a 0)
-C Enable complex password mode (implies -L -U -N -S and disables -H)
-H Avoid ambiguous characters in passwords (i. e.: 1, l, I, O, 0) (Default: off) -H Avoid ambiguous characters in passwords (i. e.: 1, l, I, O, 0) (Default: off)
-C Enable complex password mode (implies -L -U -N -S and disables -H) (Default: off) -L Toggle lower-case characters in passwords (Default: on)
-N Toggle numeric characters in passwords (Default: on)
-S Toggle special characters in passwords (Default: off)
-U Toggle upper-case characters in passwords (Default: on)
- Note: this flag has higher priority than the other old-style flags
-l Spell generated passwords in phonetic alphabet (Default: off) -l Spell generated passwords in phonetic alphabet (Default: off)
-t Spell generated pronounceable passwords with the corresponding
syllables (Default: off)
-p Check the HIBP database if the generated passwords was found in a leak before (Default: off) -p Check the HIBP database if the generated passwords was found in a leak before (Default: off)
- Note: this feature requires internet connectivity - Note: this feature requires internet connectivity
-h Show this help text -h Show this help text
-v Show version string` -v Show version string`
// Main function that generated the passwords and returns them _, _ = os.Stderr.WriteString(ut + "\n\n")
func main() {
// Log configuration
log.SetFlags(log.Ltime | log.Ldate | log.Lshortfile)
// Read and parse flags
flag.Usage = func() { _, _ = fmt.Fprintf(os.Stderr, "%s\n", usage) }
var cfgObj = config.New()
// Show version and exit
if cfgObj.ShowVersion {
_, _ = os.Stderr.WriteString(`apg-go // A "Automated Password Generator"-clone v` + VersionString + "\n")
_, _ = os.Stderr.WriteString("OS: " + runtime.GOOS + " // Arch: " + runtime.GOARCH + " \n")
_, _ = os.Stderr.WriteString("(C) 2021 by Winni Neessen\n")
os.Exit(0)
}
pwList := make([]string, 0)
sylList := map[string][]string{}
// Choose the type of password generation based on the selected algo
for i := 0; i < cfgObj.NumOfPass; i++ {
pwLength := config.GetPwLengthFromParams(&cfgObj)
switch cfgObj.PwAlgo {
case 0:
pwString := ""
pwSyls := make([]string, 0)
charSylSet := chars.KoremutakeSyllables
charSylSet = append(charSylSet,
strings.Split(chars.PwNumbersHuman, "")...)
charSylSet = append(charSylSet,
strings.Split(chars.PwSpecialCharsHuman, "")...)
charSylSetLen := len(charSylSet)
for len(pwString) < pwLength {
randNum, err := random.GetNum(charSylSetLen)
if err != nil {
log.Fatalf("error generating Koremutake syllable: %s", err)
}
nextSyl := charSylSet[randNum]
if random.CoinFlip() {
sylLen := len(nextSyl)
charPos, err := random.GetNum(sylLen)
if err != nil {
log.Fatalf("error generating random number: %s", err)
}
ucChar := string(nextSyl[charPos])
nextSyl = strings.ReplaceAll(nextSyl, ucChar, strings.ToUpper(ucChar))
}
pwString += nextSyl
pwSyls = append(pwSyls, nextSyl)
}
pwList = append(pwList, pwString)
sylList[pwString] = pwSyls
default:
charRange := chars.GetRange(&cfgObj)
pwString, err := random.GetChar(charRange, pwLength)
if err != nil {
log.Fatalf("error generating random character range: %s\n", err)
}
pwList = append(pwList, pwString)
}
}
for _, p := range pwList {
switch cfgObj.OutputMode {
case 1:
spelledPw, err := spelling.String(p)
if err != nil {
log.Fatalf("error spelling out password: %s\n", err)
}
fmt.Printf("%v (%v)\n", p, spelledPw)
case 2:
fmt.Printf("%s", p)
if cfgObj.SpellPron {
spelledPw, err := spelling.Koremutake(sylList[p])
if err != nil {
log.Fatalf("error spelling out password: %s", err)
}
fmt.Printf(" (%s)", spelledPw)
}
fmt.Println()
default:
fmt.Println(p)
}
if cfgObj.CheckHibp {
hc := hibp.New(hibp.WithHTTPTimeout(time.Second*2), hibp.WithPwnedPadding())
pwnObj, _, err := hc.PwnedPassAPI.CheckPassword(p)
if err != nil {
log.Printf("unable to check HIBP database: %v", err)
}
if pwnObj != nil && pwnObj.Count != 0 {
fmt.Print("^-- !!WARNING: The previously generated password was found in HIBP database. Do not use it!!\n")
}
}
}
} }

View file

@ -1,288 +0,0 @@
package main
import (
"github.com/wneessen/apg-go/chars"
"github.com/wneessen/apg-go/config"
"github.com/wneessen/apg-go/random"
"github.com/wneessen/apg-go/spelling"
"testing"
)
var cfgObj config.Config
// Make sure the flags are initialized
var _ = func() bool {
testing.Init()
cfgObj = config.New()
return true
}()
// Test getRandNum with max 1000
func TestGetRandNum(t *testing.T) {
testTable := []struct {
testName string
givenVal int
maxRet int
minRet int
shouldFail bool
}{
{"randNum up to 1000", 1000, 1000, 0, false},
{"randNum should be 1", 1, 1, 0, false},
{"randNum should fail on 0", 0, 0, 0, true},
{"randNum should fail on negative", -1, 0, 0, true},
}
for _, testCase := range testTable {
t.Run(testCase.testName, func(t *testing.T) {
randNum, err := random.GetNum(testCase.givenVal)
if testCase.shouldFail {
if err == nil {
t.Errorf("Random number generation succeeded but was expected to fail. Given: %v, returned: %v",
testCase.givenVal, randNum)
}
} else {
if err != nil {
t.Errorf("Random number generation failed: %v", err.Error())
}
if randNum > testCase.maxRet {
t.Errorf("Random number generation returned too big value. Given %v, expected max: %v, got: %v",
testCase.givenVal, testCase.maxRet, randNum)
}
if randNum < testCase.minRet {
t.Errorf("Random number generation returned too small value. Given %v, expected max: %v, got: %v",
testCase.givenVal, testCase.minRet, randNum)
}
}
})
}
}
// Test Pwlength
func TestGenLength(t *testing.T) {
testTable := []struct {
testName string
minLength int
maxLength int
}{
{"pwLength defaults", config.DefaultMinLength, config.DefaultMaxLength},
{"pwLength 0 to 1", 0, 1},
{"pwLength 1 to 10", 0, 10},
{"pwLength 10 to 100", 10, 100},
}
charRange := chars.GetRange(&cfgObj)
for _, testCase := range testTable {
t.Run(testCase.testName, func(t *testing.T) {
cfgObj.MinPassLen = testCase.minLength
cfgObj.MaxPassLen = testCase.maxLength
pwLength := config.GetPwLengthFromParams(&cfgObj)
for i := 0; i < 1000; i++ {
pwString, err := random.GetChar(charRange, pwLength)
if err != nil {
t.Errorf("getRandChar returned an error: %q", err)
}
retLen := len(pwString)
if retLen > testCase.maxLength {
t.Errorf("Generated password length too long. GivenMin %v, GivenMax: %v, Returned length %v",
testCase.minLength, testCase.maxLength, retLen)
}
if retLen < testCase.minLength {
t.Errorf("Generated password length too short. GivenMin %v, GivenMax: %v, Returned length %v",
testCase.minLength, testCase.maxLength, retLen)
}
}
})
}
}
// Test getRandChar
func TestGetRandChar(t *testing.T) {
t.Run("return_value_is_A_B_or_C", func(t *testing.T) {
charRange := "ABC"
randChar, err := random.GetChar(charRange, 1)
if err != nil {
t.Fatalf("Random character generation failed => %v", err.Error())
}
if randChar != "A" && randChar != "B" && randChar != "C" {
t.Fatalf("Random character generation failed. Expected A, B or C but got: %v", randChar)
}
})
t.Run("return_value_has_specific_length", func(t *testing.T) {
charRange := "ABC"
randChar, err := random.GetChar(charRange, 1000)
if err != nil {
t.Fatalf("Random character generation failed => %v", err.Error())
}
if len(randChar) != 1000 {
t.Fatalf("Generated random characters with 1000 chars returned wrong amount of chars: %v",
len(randChar))
}
})
t.Run("fail", func(t *testing.T) {
charRange := "ABC"
randChar, err := random.GetChar(charRange, -2000)
if err == nil {
t.Fatalf("Generated random characters expected to fail, but returned a value => %v",
randChar)
}
})
}
// Test getCharRange() with different cfgObj settings
func TestGetCharRange(t *testing.T) {
lowerCaseBytes := []int{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x', 'y', 'z'}
lowerCaseHumanBytes := []int{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x', 'y', 'z'}
upperCaseBytes := []int{'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'}
upperCaseHumanBytes := []int{'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q',
'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'}
numberBytes := []int{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
numberHumanBytes := []int{'2', '3', '4', '5', '6', '7', '8', '9'}
specialBytes := []int{'!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', ':',
';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~'}
specialHumanBytes := []int{'"', '#', '%', '*', '+', '-', '/', ':', ';', '=', '\\', '_', '|', '~'}
testTable := []struct {
testName string
allowedBytes []int
useLowerCase bool
useUpperCase bool
useNumber bool
useSpecial bool
humanReadable bool
}{
{"lowercase_only", lowerCaseBytes, true, false, false, false, false},
{"lowercase_only_human", lowerCaseHumanBytes, true, false, false, false, true},
{"uppercase_only", upperCaseBytes, false, true, false, false, false},
{"uppercase_only_human", upperCaseHumanBytes, false, true, false, false, true},
{"number_only", numberBytes, false, false, true, false, false},
{"number_only_human", numberHumanBytes, false, false, true, false, true},
{"special_only", specialBytes, false, false, false, true, false},
{"special_only_human", specialHumanBytes, false, false, false, true, true},
}
for _, testCase := range testTable {
t.Run(testCase.testName, func(t *testing.T) {
cfgObj.UseLowerCase = testCase.useLowerCase
cfgObj.UseUpperCase = testCase.useUpperCase
cfgObj.UseNumber = testCase.useNumber
cfgObj.UseSpecial = testCase.useSpecial
cfgObj.HumanReadable = testCase.humanReadable
charRange := chars.GetRange(&cfgObj)
for _, curChar := range charRange {
searchAllowedBytes := containsByte(testCase.allowedBytes, int(curChar), t)
if !searchAllowedBytes {
t.Errorf("Character range returned invalid value: %v", string(curChar))
}
}
})
}
}
// Test Conversions
func TestConvert(t *testing.T) {
testTable := []struct {
testName string
givenVal byte
expVal string
shouldFail bool
}{
{"convert_A_to_Alfa", 'A', "Alfa", false},
{"convert_a_to_alfa", 'a', "alfa", false},
{"convert_0_to_ZERO", '0', "ZERO", false},
{"convert_/_to_SLASH", '/', "SLASH", false},
}
for _, testCase := range testTable {
t.Run(testCase.testName, func(t *testing.T) {
charToString, err := spelling.ConvertCharToName(testCase.givenVal)
if testCase.shouldFail {
if err == nil {
t.Errorf("Character to string conversion succeeded but was expected to fail. Given: %v, returned: %v",
testCase.givenVal, charToString)
}
} else {
if err != nil {
t.Errorf("Character to string conversion failed: %v", err.Error())
}
if charToString != testCase.expVal {
t.Errorf("Character to String conversion fail. Given: %q, expected: %q, got: %q",
testCase.givenVal, testCase.expVal, charToString)
}
}
})
}
t.Run("all_chars_must_return_a_conversion_string", func(t *testing.T) {
cfgObj.UseUpperCase = true
cfgObj.UseLowerCase = true
cfgObj.UseNumber = true
cfgObj.UseSpecial = true
cfgObj.HumanReadable = false
charRange := chars.GetRange(&cfgObj)
for _, curChar := range charRange {
_, err := spelling.ConvertCharToName(byte(curChar))
if err != nil {
t.Fatalf("Character to string conversion failed: %v", err.Error())
}
}
})
t.Run("spell_Ab!_to_strings", func(t *testing.T) {
pwString := "Ab!"
spelledString, err := spelling.String(pwString)
if err != nil {
t.Fatalf("password spelling failed: %v", err.Error())
}
if spelledString != "Alfa/bravo/EXCLAMATION_POINT" {
t.Fatalf(
"Spelling pwString 'Ab!' is expected to provide 'Alfa/bravo/EXCLAMATION_POINT', but returned: %q",
spelledString)
}
})
}
// Benchmark: Random number generation
func BenchmarkGetRandNum(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = random.GetNum(100000)
}
}
// Benchmark: Random char generation
func BenchmarkGetRandChar(b *testing.B) {
charRange := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\"#/!\\$%&+-*.,?=()[]{}:;~^|"
for i := 0; i < b.N; i++ {
_, _ = random.GetChar(charRange, 20)
}
}
// Benchmark: Random char generation
func BenchmarkConvertChar(b *testing.B) {
cfgObj.UseUpperCase = true
cfgObj.UseLowerCase = true
cfgObj.UseNumber = true
cfgObj.UseSpecial = true
cfgObj.HumanReadable = false
charRange := chars.GetRange(&cfgObj)
for i := 0; i < b.N; i++ {
charToConv, _ := random.GetChar(charRange, 1)
charBytes := []byte(charToConv)
_, _ = spelling.ConvertCharToName(charBytes[0])
}
}
// Contains function to search a given slice for values
func containsByte(allowedBytes []int, currentChar int, t *testing.T) bool {
t.Helper()
for _, charInt := range allowedBytes {
if charInt == currentChar {
return true
}
}
return false
}

110
config.go Normal file
View file

@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
// List of default values for Config instances
const (
// DefaultMinLength reflects the default minimum length of a generated password
DefaultMinLength int64 = 12
// DefaultMaxLength reflects the default maximum length of a generated password
DefaultMaxLength int64 = 20
// DefaultMode sets the default character set mode bitmask to a combination of
// lower- and upper-case characters as well as numbers
DefaultMode ModeMask = ModeLowerCase | ModeNumeric | ModeUpperCase
// DefaultNumberPass reflects the default amount of passwords returned by the generator
DefaultNumberPass int64 = 6
)
// Config represents the apg.Generator config parameters
type Config struct {
// Algorithm sets the Algorithm used for the password generation
Algorithm Algorithm
// CheckHIBP sets a flag if the generated password has to be checked
// against the HIBP pwned password database
CheckHIBP bool
// ExcludeChars is a list of characters that should be excluded from
// generated passwords
ExcludeChars string
// FixedLength sets a fixed length for generated passwords and ignores
// the MinLength and MaxLength values
FixedLength int64
// MaxLength sets the maximum length for a generated password
MaxLength int64
// MinLength sets the minimum length for a generated password
MinLength int64
// MinLowerCase represents the minimum amount of lower-case characters that have
// to be part of the generated password
MinLowerCase int64
// MinNumeric represents the minimum amount of numeric characters that have
// to be part of the generated password
MinNumeric int64
// MinSpecial represents the minimum amount of special characters that have
// to be part of the generated password
MinSpecial int64
// MinUpperCase represents the minimum amount of upper-case characters that have
// to be part of the generated password
MinUpperCase int64
// Mode holds the different character modes for the Random algorithm
Mode ModeMask
// NumberPass sets the number of passwords that are generated
// and returned by the generator
NumberPass int64
// SpellPassword if set will spell the generated passwords in the phonetic alphabet
SpellPassword bool
// SpellPronounceable if set will spell the generated pronounceable passwords in
// as its corresponding syllables
SpellPronounceable bool
}
// Option is a function that can override default Config settings
type Option func(*Config)
// NewConfig creates a new Config instance and pre-fills it with sane
// default settings. The Config is returned as pointer value
func NewConfig(opts ...Option) *Config {
config := &Config{
MaxLength: DefaultMaxLength,
MinLength: DefaultMinLength,
Mode: DefaultMode,
NumberPass: DefaultNumberPass,
}
// Override defaults with optionally provided config.Option functions
for _, opt := range opts {
if opt == nil {
continue
}
opt(config)
}
return config
}
// WithAlgorithm overrides the algorithm mode for the password generation
func WithAlgorithm(algo Algorithm) Option {
return func(config *Config) {
config.Algorithm = algo
}
}
// WithMinLength overrides the minimum password length
func WithMinLength(length int64) Option {
return func(config *Config) {
config.MinLength = length
}
}
// WithMaxLength overrides the maximum password length
func WithMaxLength(length int64) Option {
return func(config *Config) {
config.MaxLength = length
}
}
// WithNumberPass overrides the amount of generated passwords setting
func WithNumberPass(amount int64) Option {
return func(config *Config) {
config.NumberPass = amount
}
}

View file

@ -1,194 +0,0 @@
package config
import (
"flag"
"github.com/wneessen/apg-go/random"
"log"
)
// Config is a struct that holds the different config parameters for the apg-go
// application
type Config struct {
MinPassLen int // Minimum password length
MaxPassLen int // Maximum password length
NumOfPass int // Number of passwords to be generated
useComplex bool // Force complex password generation (implies all other Use* Options to be true)
UseLowerCase bool // Allow lower-case chars in passwords
UseUpperCase bool // Allow upper-case chars in password
UseNumber bool // Allow numbers in passwords
UseSpecial bool // Allow special chars in passwords
HumanReadable bool // Generated passwords use the "human readable" character set
CheckHibp bool // Check generated are validated against the HIBP API for possible leaks
ExcludeChars string // List of characters to be excluded from the PW generation charset
NewStyleModes string // Use the "new style" parameters instead of the single params
spellPassword bool // Spell out passwords in the output
ShowHelp bool // Display the help message in the CLI
ShowVersion bool // Display the version string in the CLI
OutputMode int // Interal parameter to control the output mode of the CLI
PwAlgo int // PW generation algorithm to use (0: random PW based on flags, 1: pronouncable)
SpellPron bool // Spell out the pronouncable password
}
// DefaultMinLength reflects the default minimum length of a generated password
const DefaultMinLength int = 12
// DefaultMaxLength reflects the default maximum length of a generated password
const DefaultMaxLength int = 20
// DefaultPwAlgo reflects the default password generation algorithm
const DefaultPwAlgo int = 1
// New parses the CLI flags and returns a new config object
func New() Config {
var switchConf Config
defaultSwitches := Config{
UseLowerCase: true,
UseUpperCase: true,
UseNumber: true,
UseSpecial: false,
useComplex: false,
HumanReadable: false,
}
config := Config{
UseLowerCase: defaultSwitches.UseLowerCase,
UseUpperCase: defaultSwitches.UseUpperCase,
UseNumber: defaultSwitches.UseNumber,
UseSpecial: defaultSwitches.UseSpecial,
useComplex: defaultSwitches.useComplex,
HumanReadable: defaultSwitches.HumanReadable,
}
// Read and set all flags
flag.BoolVar(&switchConf.UseLowerCase, "L", false, "Use lower case characters in passwords")
flag.BoolVar(&switchConf.UseUpperCase, "U", false, "Use upper case characters in passwords")
flag.BoolVar(&switchConf.UseNumber, "N", false, "Use numerich characters in passwords")
flag.BoolVar(&switchConf.UseSpecial, "S", false, "Use special characters in passwords")
flag.BoolVar(&switchConf.useComplex, "C", false, "Generate complex passwords (implies -L -U -N -S, disables -H)")
flag.BoolVar(&switchConf.HumanReadable, "H", false, "Generate human-readable passwords")
flag.BoolVar(&config.spellPassword, "l", false, "Spell generated password")
flag.BoolVar(&config.CheckHibp, "p", false, "Check the HIBP database if the generated password was leaked before")
flag.BoolVar(&config.ShowVersion, "v", false, "Show version")
flag.IntVar(&config.MinPassLen, "m", DefaultMinLength, "Minimum password length")
flag.IntVar(&config.MaxPassLen, "x", DefaultMaxLength, "Maxiumum password length")
flag.IntVar(&config.NumOfPass, "n", 6, "Number of passwords to generate")
flag.StringVar(&config.ExcludeChars, "E", "", "Exclude list of characters from generated password")
flag.StringVar(&config.NewStyleModes, "M", "",
"New style password parameters (higher priority than single parameters)")
flag.IntVar(&config.PwAlgo, "a", DefaultPwAlgo, "Password generation algorithm")
flag.BoolVar(&config.SpellPron, "t", false, "In pronouncable password mode, spell out the password")
flag.Parse()
// Invert-switch the defaults
if switchConf.UseLowerCase {
config.UseLowerCase = !defaultSwitches.UseLowerCase
}
if switchConf.UseUpperCase {
config.UseUpperCase = !defaultSwitches.UseUpperCase
}
if switchConf.UseNumber {
config.UseNumber = !defaultSwitches.UseNumber
}
if switchConf.UseSpecial {
config.UseSpecial = !defaultSwitches.UseSpecial
}
if switchConf.useComplex {
config.useComplex = !defaultSwitches.useComplex
}
if switchConf.HumanReadable {
config.HumanReadable = !defaultSwitches.HumanReadable
}
// Parse additional parameters and new-style switches
parseParams(&config)
return config
}
// Parse the parameters and set the according config flags
func parseParams(config *Config) {
parseNewStyleParams(config)
// Complex overrides everything
if config.useComplex {
config.UseUpperCase = true
config.UseLowerCase = true
config.UseSpecial = true
config.UseNumber = true
config.HumanReadable = false
}
if !config.UseUpperCase &&
!config.UseLowerCase &&
!config.UseNumber &&
!config.UseSpecial {
log.Fatalf("No password mode set. Cannot generate password from empty character set.")
}
// Set output mode
switch config.PwAlgo {
case 0:
config.OutputMode = 2
default:
config.OutputMode = 0
if config.spellPassword {
config.OutputMode = 1
}
}
}
// Parse the new style parameters
func parseNewStyleParams(config *Config) {
if config.NewStyleModes == "" {
return
}
for _, curParam := range config.NewStyleModes {
switch curParam {
case 'S':
config.UseSpecial = true
case 's':
config.UseSpecial = false
case 'N':
config.UseNumber = true
case 'n':
config.UseNumber = false
case 'L':
config.UseLowerCase = true
case 'l':
config.UseLowerCase = false
case 'U':
config.UseUpperCase = true
case 'u':
config.UseUpperCase = false
case 'H':
config.HumanReadable = true
case 'h':
config.HumanReadable = false
case 'C':
config.useComplex = true
case 'c':
config.useComplex = false
default:
log.Fatalf("Unknown password style parameter: %q\n", string(curParam))
}
}
}
// GetPwLengthFromParams extracts the password length from the given cli flags and stores
// in the provided config object
func GetPwLengthFromParams(config *Config) int {
if config.MinPassLen > config.MaxPassLen {
config.MaxPassLen = config.MinPassLen
}
lenDiff := config.MaxPassLen - config.MinPassLen + 1
randAdd, err := random.GetNum(lenDiff)
if err != nil {
log.Fatalf("Failed to generated password length: %v", err)
}
retVal := config.MinPassLen + randAdd
if retVal <= 0 {
return 1
}
return retVal
}

103
config_test.go Normal file
View file

@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"testing"
)
func TestNewConfig(t *testing.T) {
c := NewConfig()
if c == nil {
t.Errorf("NewConfig() failed, expected config pointer but got nil")
return
}
c = NewConfig(nil)
if c == nil {
t.Errorf("NewConfig() failed, expected config pointer but got nil")
return
}
if c.MinLength != DefaultMinLength {
t.Errorf("NewConfig() failed, expected min length: %d, got: %d", DefaultMinLength,
c.MinLength)
}
if c.MaxLength != DefaultMaxLength {
t.Errorf("NewConfig() failed, expected max length: %d, got: %d", DefaultMaxLength,
c.MaxLength)
}
if c.NumberPass != DefaultNumberPass {
t.Errorf("NewConfig() failed, expected number of passwords: %d, got: %d",
DefaultNumberPass, c.NumberPass)
}
}
func TestWithAlgorithm(t *testing.T) {
tests := []struct {
name string
algo Algorithm
want int
}{
{"Pronouncble passwords", AlgoPronounceable, 0},
{"Random passwords", AlgoRandom, 1},
{"Coinflip", AlgoCoinFlip, 2},
{"Unsupported", AlgoUnsupported, 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewConfig(WithAlgorithm(tt.algo))
if c == nil {
t.Errorf("NewConfig(WithAlgorithm()) failed, expected config pointer but got nil")
return
}
if c.Algorithm != tt.algo {
t.Errorf("NewConfig(WithAlgorithm()) failed, expected algo: %d, got: %d",
tt.algo, c.Algorithm)
}
if IntToAlgo(tt.want) != c.Algorithm {
t.Errorf("IntToAlgo() failed, expected algo: %d, got: %d",
tt.want, c.Algorithm)
}
})
}
}
func TestWithMaxLength(t *testing.T) {
var e int64 = 123
c := NewConfig(WithMaxLength(e))
if c == nil {
t.Errorf("NewConfig(WithMaxLength()) failed, expected config pointer but got nil")
return
}
if c.MaxLength != e {
t.Errorf("NewConfig(WithMaxLength()) failed, expected max length: %d, got: %d",
e, c.MaxLength)
}
}
func TestWithMinLength(t *testing.T) {
var e int64 = 1
c := NewConfig(WithMinLength(e))
if c == nil {
t.Errorf("NewConfig(WithMinLength()) failed, expected config pointer but got nil")
return
}
if c.MinLength != e {
t.Errorf("NewConfig(WithMinLength()) failed, expected min length: %d, got: %d",
e, c.MinLength)
}
}
func TestWithNumberPass(t *testing.T) {
var e int64 = 123
c := NewConfig(WithNumberPass(e))
if c == nil {
t.Errorf("NewConfig(WithNumberPass()) failed, expected config pointer but got nil")
return
}
if c.NumberPass != e {
t.Errorf("NewConfig(WithNumberPass()) failed, expected number of passwords: %d, got: %d",
e, c.NumberPass)
}
}

View file

@ -1 +0,0 @@
apg-go:*:1000:apg-go

View file

@ -1 +0,0 @@
apg-go:*:1000:1000:Automated Password Generator User:/apg-go:/usr/bin/false

View file

@ -1,27 +0,0 @@
package main
import (
"fmt"
"github.com/wneessen/apg-go/chars"
"github.com/wneessen/apg-go/config"
"github.com/wneessen/apg-go/random"
)
func main() {
c := config.Config{
UseNumber: true,
UseSpecial: true,
UseUpperCase: true,
UseLowerCase: true,
PwAlgo: 1,
MinPassLen: 15,
MaxPassLen: 15,
}
pl := config.GetPwLengthFromParams(&c)
cs := chars.GetRange(&c)
pw, err := random.GetChar(cs, pl)
if err != nil {
panic(err)
}
fmt.Println("Your Password:", pw)
}

8
go.mod
View file

@ -1,5 +1,9 @@
module github.com/wneessen/apg-go // SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
go 1.16 module src.neessen.cloud/wneessen/apg-go
go 1.22
require github.com/wneessen/go-hibp v1.0.6 require github.com/wneessen/go-hibp v1.0.6

3
go.sum.license Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
SPDX-License-Identifier: MIT

20
hibp.go Normal file
View file

@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"time"
"github.com/wneessen/go-hibp"
)
// HasBeenPwned checks the given password string against the HIBP pwned
// passwords database and returns true if the password has been leaked
func HasBeenPwned(password string) (bool, error) {
hc := hibp.New(hibp.WithHTTPTimeout(time.Second*2),
hibp.WithPwnedPadding())
matches, _, err := hc.PwnedPassAPI.CheckPassword(password)
return matches != nil && matches.Count != 0, err
}

31
hibp_test.go Normal file
View file

@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"testing"
)
func TestHasBeenPwned(t *testing.T) {
tests := []struct {
name string
password string
want bool
}{
{"Pwned PW", "Test123", true},
{"Secure PW", "Cta8mWYmW7O*j1V!YMTS", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HasBeenPwned(tt.password)
if err != nil {
t.Errorf("HasBeenPwned() failed: %s", err)
}
if tt.want != got {
t.Errorf("HasBeenPwned() failed, wanted: %t, got: %t", tt.want, got)
}
})
}
}

View file

@ -1,8 +1,13 @@
package chars // SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
// KoremutakeSyllables is a slightly modified Koremutake syllables list based on // KoremutakeSyllables is a slightly modified Koremutake syllables list based on
// the mechanism described on https://shorl.com/koremutake.php // the mechanism described on https://shorl.com/koremutake.php
var KoremutakeSyllables = []string{"ba", "be", "bi", "bo", "bu", "by", "da", "de", "di", var KoremutakeSyllables = []string{
"ba", "be", "bi", "bo", "bu", "by", "da", "de", "di",
"do", "du", "dy", "fe", "fi", "fo", "fu", "fy", "ga", "ge", "gi", "go", "gu", "do", "du", "dy", "fe", "fi", "fo", "fu", "fy", "ga", "ge", "gi", "go", "gu",
"gy", "ha", "he", "hi", "ho", "hu", "hy", "ja", "je", "ji", "jo", "ju", "jy", "gy", "ha", "he", "hi", "ho", "hu", "hy", "ja", "je", "ji", "jo", "ju", "jy",
"ka", "ke", "ko", "ku", "ky", "la", "le", "li", "lo", "lu", "ly", "ma", "ka", "ke", "ko", "ku", "ky", "la", "le", "li", "lo", "lu", "ly", "ma",
@ -19,4 +24,5 @@ var KoremutakeSyllables = []string{"ba", "be", "bi", "bo", "bu", "by", "da", "de
"col", "ful", "get", "low", "son", "tle", "day", "pen", "pre", "ten", "col", "ful", "get", "low", "son", "tle", "day", "pen", "pre", "ten",
"tor", "ver", "ber", "can", "ple", "fer", "gen", "den", "mag", "sub", "sur", "tor", "ver", "ber", "can", "ple", "fer", "gen", "den", "mag", "sub", "sur",
"men", "min", "out", "tal", "but", "cit", "cle", "cov", "dif", "ern", "men", "min", "out", "tal", "but", "cit", "cle", "cov", "dif", "ern",
"eve", "hap", "ket", "nal", "sup", "ted", "tem", "tin", "tro", "tro"} "eve", "hap", "ket", "nal", "sup", "ted", "tem", "tin", "tro", "tro",
}

113
mode.go Normal file
View file

@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"strings"
)
// Mode represents a mode of characters
type Mode uint8
// ModeMask represents a bitmask of character modes
type ModeMask uint8
const (
// ModeNumeric sets the bitmask to include numeric in the generated passwords
ModeNumeric = 1 << iota
// ModeLowerCase sets the bitmask to include lower case characters in the
// generated passwords
ModeLowerCase
// ModeUpperCase sets the bitmask to include upper case characters in the
// generated passwords
ModeUpperCase
// ModeSpecial sets the bitmask to include special characters in the
// generated passwords
ModeSpecial
// ModeHumanReadable sets the bitmask to generate human readable passwords
ModeHumanReadable
)
const (
// CharRangeAlphaLower represents all lower-case alphabetical characters
CharRangeAlphaLower = "abcdefghijklmnopqrstuvwxyz"
// CharRangeAlphaLowerHuman represents the human-readable lower-case alphabetical characters
CharRangeAlphaLowerHuman = "abcdefghjkmnpqrstuvwxyz"
// CharRangeAlphaUpper represents all upper-case alphabetical characters
CharRangeAlphaUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// CharRangeAlphaUpperHuman represents the human-readable upper-case alphabetical characters
CharRangeAlphaUpperHuman = "ABCDEFGHJKMNPQRSTUVWXYZ"
// CharRangeNumeric represents all numerical characters
CharRangeNumeric = "1234567890"
// CharRangeNumericHuman represents all human-readable numerical characters
CharRangeNumericHuman = "23456789"
// CharRangeSpecial represents all special characters
CharRangeSpecial = `!\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~`
// CharRangeSpecialHuman represents all human-readable special characters
CharRangeSpecialHuman = `#%*+-:;=`
)
// MaskSetMode sets a specific Mode to a given Mode bitmask
func MaskSetMode(mask ModeMask, mode Mode) ModeMask { return ModeMask(uint8(mask) | uint8(mode)) }
// MaskClearMode clears a specific Mode from a given Mode bitmask
func MaskClearMode(mask ModeMask, mode Mode) ModeMask { return ModeMask(uint8(mask) &^ uint8(mode)) }
// MaskToggleMode toggles a specific Mode in a given Mode bitmask
func MaskToggleMode(mask ModeMask, mode Mode) ModeMask { return ModeMask(uint8(mask) ^ uint8(mode)) }
// MaskHasMode returns true if a given Mode bitmask holds a specific Mode
func MaskHasMode(mask ModeMask, mode Mode) bool { return uint8(mask)&uint8(mode) != 0 }
func ModesFromFlags(maskString string) ModeMask {
cl := strings.Split(maskString, "")
var modeMask ModeMask
for _, m := range cl {
switch m {
case "C":
modeMask = MaskSetMode(modeMask, ModeLowerCase|ModeNumeric|ModeSpecial|ModeUpperCase)
case "h":
modeMask = MaskClearMode(modeMask, ModeHumanReadable)
case "H":
modeMask = MaskSetMode(modeMask, ModeHumanReadable)
case "l":
modeMask = MaskClearMode(modeMask, ModeLowerCase)
case "L":
modeMask = MaskSetMode(modeMask, ModeLowerCase)
case "n":
modeMask = MaskClearMode(modeMask, ModeNumeric)
case "N":
modeMask = MaskSetMode(modeMask, ModeNumeric)
case "s":
modeMask = MaskClearMode(modeMask, ModeSpecial)
case "S":
modeMask = MaskSetMode(modeMask, ModeSpecial)
case "u":
modeMask = MaskClearMode(modeMask, ModeUpperCase)
case "U":
modeMask = MaskSetMode(modeMask, ModeUpperCase)
}
}
return modeMask
}
// String satisfies the fmt.Stringer interface for the Mode type
func (m Mode) String() string {
switch m {
case ModeHumanReadable:
return "Human-readable"
case ModeLowerCase:
return "Lower-case"
case ModeNumeric:
return "Numeric"
case ModeSpecial:
return "Special"
case ModeUpperCase:
return "Upper-case"
default:
return "Unknown"
}
}

119
mode_test.go Normal file
View file

@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"testing"
)
func TestSetClearHasToggleMode(t *testing.T) {
tt := []struct {
name string
mode Mode
}{
{"ModeHumanReadable", ModeHumanReadable},
{"ModeLowerCase", ModeLowerCase},
{"ModeNumeric", ModeNumeric},
{"ModeSpecial", ModeSpecial},
{"ModeUpperCase", ModeUpperCase},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
var m ModeMask
m = MaskSetMode(m, tc.mode)
if !MaskHasMode(m, tc.mode) {
t.Errorf("MaskSetMode() failed, mode not found in bitmask")
}
m = MaskToggleMode(m, tc.mode)
if MaskHasMode(m, tc.mode) {
t.Errorf("MaskToggleMode() failed, mode found in bitmask")
}
m = MaskToggleMode(m, tc.mode)
if !MaskHasMode(m, tc.mode) {
t.Errorf("MaskToggleMode() failed, mode not found in bitmask")
}
m = MaskClearMode(m, tc.mode)
if MaskHasMode(m, tc.mode) {
t.Errorf("MaskClearMode() failed, mode found in bitmask")
}
})
}
}
func TestModesFromFlags(t *testing.T) {
tt := []struct {
name string
ms string
mode []Mode
}{
{"ModeComplex", "C", []Mode{
ModeLowerCase, ModeNumeric, ModeSpecial,
ModeUpperCase,
}},
{"ModeHumanReadable", "H", []Mode{ModeHumanReadable}},
{"ModeLowerCase", "L", []Mode{ModeLowerCase}},
{"ModeNumeric", "N", []Mode{ModeNumeric}},
{"ModeUpperCase", "U", []Mode{ModeUpperCase}},
{"ModeSpecial", "S", []Mode{ModeSpecial}},
{"ModeLowerSpecialUpper", "LSUH", []Mode{
ModeHumanReadable,
ModeLowerCase, ModeSpecial, ModeUpperCase,
}},
{"ModeComplexNoHumanReadable", "Ch", []Mode{
ModeLowerCase,
ModeNumeric, ModeSpecial, ModeUpperCase,
}},
{"ModeComplexNoLower", "Cl", []Mode{
ModeNumeric, ModeSpecial,
ModeUpperCase,
}},
{"ModeComplexNoNumber", "Cn", []Mode{
ModeLowerCase, ModeSpecial,
ModeUpperCase,
}},
{"ModeComplexNoSpecial", "Cs", []Mode{
ModeLowerCase, ModeNumeric,
ModeUpperCase,
}},
{"ModeComplexNoUpper", "Cu", []Mode{
ModeLowerCase, ModeNumeric,
ModeSpecial,
}},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
mm := ModesFromFlags(tc.ms)
for _, tm := range tc.mode {
if !MaskHasMode(mm, tm) {
t.Errorf("ModesFromFlags() failed, expected mode %q not found", tm)
}
}
})
}
}
func TestMode_String(t *testing.T) {
tt := []struct {
name string
m Mode
e string
}{
{"ModeHumanReadable", ModeHumanReadable, "Human-readable"},
{"ModeLowerCase", ModeLowerCase, "Lower-case"},
{"ModeNumeric", ModeNumeric, "Numeric"},
{"ModeSpecial", ModeSpecial, "Special"},
{"ModeUpperCase", ModeUpperCase, "Upper-case"},
{"ModeUnknown", 255, "Unknown"},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
if tc.m.String() != tc.e {
t.Errorf("Mode.String() failed, expected: %s, got: %s", tc.e,
tc.m.String())
}
})
}
}

361
random.go Normal file
View file

@ -0,0 +1,361 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"math/big"
"os"
"regexp"
"strings"
)
const (
// 7 bits to represent a letter index
letterIdxBits = 7
// All 1-bits, as many as letterIdxBits
letterIdxMask = 1<<letterIdxBits - 1
// # of letter indices fitting in 63 bits)
letterIdxMax = 63 / letterIdxBits
)
// maxInt32 is the maximum positive value for a int32 number type
const maxInt32 = 2147483647
var (
// ErrInvalidLength is returned if the provided maximum number is equal or less than zero
ErrInvalidLength = errors.New("provided length value cannot be zero or less")
// ErrLengthMismatch is returned if the number of generated bytes does not match the expected length
ErrLengthMismatch = errors.New("number of generated random bytes does not match the expected length")
// ErrInvalidCharRange is returned if the given range of characters is not valid
ErrInvalidCharRange = errors.New("provided character range is not valid or empty")
)
// CoinFlip performs a simple coinflip based on the rand library and returns 1 or 0
func (g *Generator) CoinFlip() int64 {
coinFlip, _ := g.RandNum(2)
return coinFlip
}
// CoinFlipBool performs a simple coinflip based on the rand library and returns true or false
func (g *Generator) CoinFlipBool() bool {
return g.CoinFlip() == 1
}
// Generate generates a password based on all the different config flags and returns
// it as string type. If the generation fails, an error will be thrown
func (g *Generator) Generate() (string, error) {
switch g.config.Algorithm {
case AlgoPronounceable:
return g.generatePronounceable()
case AlgoCoinFlip:
return g.generateCoinFlip()
case AlgoRandom:
return g.generateRandom()
case AlgoUnsupported:
return "", fmt.Errorf("unsupported algorithm")
}
return "", nil
}
// GetCharRangeFromConfig checks the Mode from the Config and returns a
// list of all possible characters that are supported by these Mode
func (g *Generator) GetCharRangeFromConfig() string {
charRange := strings.Builder{}
if MaskHasMode(g.config.Mode, ModeLowerCase) {
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange.WriteString(CharRangeAlphaLowerHuman)
default:
charRange.WriteString(CharRangeAlphaLower)
}
}
if MaskHasMode(g.config.Mode, ModeNumeric) {
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange.WriteString(CharRangeNumericHuman)
default:
charRange.WriteString(CharRangeNumeric)
}
}
if MaskHasMode(g.config.Mode, ModeSpecial) {
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange.WriteString(CharRangeSpecialHuman)
default:
charRange.WriteString(CharRangeSpecial)
}
}
if MaskHasMode(g.config.Mode, ModeUpperCase) {
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange.WriteString(CharRangeAlphaUpperHuman)
default:
charRange.WriteString(CharRangeAlphaUpper)
}
}
if g.config.ExcludeChars != "" {
rex, err := regexp.Compile("[" + regexp.QuoteMeta(g.config.ExcludeChars) + "]")
if err == nil {
newRange := rex.ReplaceAllLiteralString(charRange.String(), "")
charRange.Reset()
charRange.WriteString(newRange)
} else {
_, _ = fmt.Fprintf(os.Stderr, "failed to exclude characters: %s\n", err)
}
}
return charRange.String()
}
// GetPasswordLength returns the password length based on the given config
// parameters
func (g *Generator) GetPasswordLength() (int64, error) {
if g.config.FixedLength > 0 {
return g.config.FixedLength, nil
}
minLength := g.config.MinLength
maxLength := g.config.MaxLength
if minLength > maxLength {
maxLength = minLength
}
diff := maxLength - minLength + 1
randNum, err := g.RandNum(diff)
if err != nil {
return 0, err
}
length := minLength + randNum
if length <= 0 {
return 1, nil
}
return length, nil
}
// RandomBytes returns a byte slice of random bytes with given length that got generated by
// the crypto/rand generator
func (g *Generator) RandomBytes(length int64) ([]byte, error) {
if length < 1 {
return nil, ErrInvalidLength
}
bytes := make([]byte, length)
numBytes, err := rand.Read(bytes)
if int64(numBytes) != length {
return nil, ErrLengthMismatch
}
if err != nil {
return nil, err
}
return bytes, nil
}
// RandNum generates a random, non-negative number with given maximum value
func (g *Generator) RandNum(max int64) (int64, error) {
if max < 1 {
return 0, ErrInvalidLength
}
max64 := big.NewInt(max)
randNum, err := rand.Int(rand.Reader, max64)
if err != nil {
return 0, fmt.Errorf("random number generation failed: %w", err)
}
return randNum.Int64(), nil
}
// RandomStringFromCharRange returns a random string of length l based of the range of characters given.
// The method makes use of the crypto/random package and therfore is
// cryptographically secure
func (g *Generator) RandomStringFromCharRange(length int64, charRange string) (string, error) {
if length < 1 {
return "", ErrInvalidLength
}
if len(charRange) < 1 {
return "", ErrInvalidCharRange
}
randString := strings.Builder{}
// As long as the length is smaller than the max. int32 value let's grow
// the string builder to the actual size, so we need less allocations
if length <= maxInt32 {
randString.Grow(int(length))
}
charRangeLength := len(charRange)
randPool := make([]byte, 8)
_, err := rand.Read(randPool)
if err != nil {
return randString.String(), err
}
for idx, char, rest := length-1, binary.BigEndian.Uint64(randPool), letterIdxMax; idx >= 0; {
if rest == 0 {
_, err = rand.Read(randPool)
if err != nil {
return randString.String(), err
}
char, rest = binary.BigEndian.Uint64(randPool), letterIdxMax
}
if i := int(char & letterIdxMask); i < charRangeLength {
randString.WriteByte(charRange[i])
idx--
}
char >>= letterIdxBits
rest--
}
return randString.String(), nil
}
// checkMinimumRequirements checks if a password meets the minimum requirements specified in the
// generator's configuration. It returns true if the password meets the requirements, otherwise it
// returns false.
//
// The minimum requirements for each character type (lowercase, numeric, special, uppercase) are
// checked independently. For each character type, the corresponding character range is determined
// based on the generator's configuration. The password is then checked for the presence of each
// character in the character range, and a count is maintained.
func (g *Generator) checkMinimumRequirements(password string) bool {
ok := true
if g.config.MinLowerCase > 0 {
var charRange string
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange = CharRangeAlphaLowerHuman
default:
charRange = CharRangeAlphaLower
}
count := 0
for _, char := range charRange {
count += strings.Count(password, string(char))
}
if int64(count) < g.config.MinLowerCase {
ok = false
}
}
if g.config.MinNumeric > 0 {
var charRange string
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange = CharRangeNumericHuman
default:
charRange = CharRangeNumeric
}
count := 0
for _, char := range charRange {
count += strings.Count(password, string(char))
}
if int64(count) < g.config.MinNumeric {
ok = false
}
}
if g.config.MinSpecial > 0 {
var charRange string
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange = CharRangeSpecialHuman
default:
charRange = CharRangeSpecial
}
count := 0
for _, char := range charRange {
count += strings.Count(password, string(char))
}
if int64(count) < g.config.MinSpecial {
ok = false
}
}
if g.config.MinUpperCase > 0 {
var charRange string
switch MaskHasMode(g.config.Mode, ModeHumanReadable) {
case true:
charRange = CharRangeAlphaUpperHuman
default:
charRange = CharRangeAlphaUpper
}
count := 0
for _, char := range charRange {
count += strings.Count(password, string(char))
}
if int64(count) < g.config.MinUpperCase {
ok = false
}
}
return ok
}
// generateCoinFlip is executed when Generate() is called with Algorithm set
// to AlgoCoinFlip
func (g *Generator) generateCoinFlip() (string, error) {
if g.CoinFlipBool() {
return "Heads", nil
}
return "Tails", nil
}
// generatePronounceable is executed when Generate() is called with Algorithm set
// to AlgoPronounceable
func (g *Generator) generatePronounceable() (string, error) {
var password string
g.syllables = make([]string, 0)
length, err := g.GetPasswordLength()
if err != nil {
return "", fmt.Errorf("failed to calculate password length: %w", err)
}
characterSet := KoremutakeSyllables
characterSet = append(characterSet, strings.Split(CharRangeNumericHuman, "")...)
characterSet = append(characterSet, strings.Split(CharRangeSpecialHuman, "")...)
characterSetLength := len(characterSet)
for int64(len(password)) < length {
randNum, err := g.RandNum(int64(characterSetLength))
if err != nil {
return "", fmt.Errorf("failed to generate a random number for Koremutake syllable generation: %w",
err)
}
nextSyllable := characterSet[randNum]
if g.CoinFlipBool() {
syllableLength := len(nextSyllable)
characterPosition, err := g.RandNum(int64(syllableLength))
if err != nil {
return "", fmt.Errorf("failed to generate a random number for Koremutake syllable generation: %w",
err)
}
randomChar := string(nextSyllable[characterPosition])
nextSyllable = strings.ReplaceAll(nextSyllable, randomChar, strings.ToUpper(randomChar))
}
password += nextSyllable
g.syllables = append(g.syllables, nextSyllable)
}
return password, nil
}
// generateRandom is executed when Generate() is called with Algorithm set
// to AlgoRandmom
func (g *Generator) generateRandom() (string, error) {
length, err := g.GetPasswordLength()
if err != nil {
return "", fmt.Errorf("failed to calculate password length: %w", err)
}
charRange := g.GetCharRangeFromConfig()
var password string
var ok bool
for !ok {
password, err = g.RandomStringFromCharRange(length, charRange)
if err != nil {
return "", err
}
ok = g.checkMinimumRequirements(password)
}
return password, nil
}

View file

@ -1,80 +0,0 @@
package random
import (
"crypto/rand"
"encoding/binary"
"fmt"
"math/big"
"strings"
)
// Bitmask sizes for the string generators (based on 93 chars total)
const (
letterIdxBits = 7 // 7 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
// GetChar generates random characters based on given character range
// and password length
func GetChar(cr string, l int) (string, error) {
if l < 1 {
return "", fmt.Errorf("length is negative")
}
rs := strings.Builder{}
rs.Grow(l)
crl := len(cr)
rp := make([]byte, 8)
_, err := rand.Read(rp)
if err != nil {
return rs.String(), err
}
for i, c, r := l-1, binary.BigEndian.Uint64(rp), letterIdxMax; i >= 0; {
if r == 0 {
_, err := rand.Read(rp)
if err != nil {
return rs.String(), err
}
c, r = binary.BigEndian.Uint64(rp), letterIdxMax
}
if idx := int(c & letterIdxMask); idx < crl {
rs.WriteByte(cr[idx])
i--
}
c >>= letterIdxBits
r--
}
return rs.String(), nil
}
// GetNum generates a random number with given maximum value
func GetNum(maxNum int) (int, error) {
if maxNum <= 0 {
err := fmt.Errorf("provided maxNum is <= 0: %v", maxNum)
return 0, err
}
maxNumBigInt := big.NewInt(int64(maxNum))
if !maxNumBigInt.IsUint64() {
err := fmt.Errorf("big.NewInt() generation returned negative value: %v", maxNumBigInt)
return 0, err
}
randNum64, err := rand.Int(rand.Reader, maxNumBigInt)
if err != nil {
return 0, err
}
randNum := int(randNum64.Int64())
if randNum < 0 {
err := fmt.Errorf("generated random number does not fit as int64: %v", randNum64)
return 0, err
}
return randNum, nil
}
// CoinFlip performs a simple coinflip based on the rand library and returns true or false
func CoinFlip() bool {
num := big.NewInt(2)
cf, _ := rand.Int(rand.Reader, num)
r := int(cf.Int64())
return r == 1
}

515
random_test.go Normal file
View file

@ -0,0 +1,515 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"bytes"
"errors"
"fmt"
"strconv"
"strings"
"testing"
)
func TestGenerator_CoinFlip(t *testing.T) {
g := New(NewConfig())
cf := g.CoinFlip()
if cf < 0 || cf > 1 {
t.Errorf("CoinFlip failed(), expected 0 or 1, got: %d", cf)
}
}
func TestGenerator_CoinFlipBool(t *testing.T) {
g := New(NewConfig())
gt := false
for i := 0; i < 500_000; i++ {
cf := g.CoinFlipBool()
if cf {
gt = true
break
}
}
if !gt {
t.Error("CoinFlipBool likely not working, expected at least one true value in 500k tries, got none")
}
}
func TestGenerator_RandNum(t *testing.T) {
tt := []struct {
name string
v int64
max int64
min int64
sf bool
}{
{"RandNum up to 1000", 1000, 1000, 0, false},
{"RandNum should be 1", 1, 1, 0, false},
{"RandNum should fail on 1", 0, 0, 0, true},
{"RandNum should fail on negative", -1, 0, 0, true},
}
g := New(NewConfig())
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
rn, err := g.RandNum(tc.v)
if err == nil && tc.sf {
t.Errorf("random number generation was supposed to fail, but didn't, got: %d", rn)
}
if err != nil && !tc.sf {
t.Errorf("random number generation failed: %s", err)
}
if rn > tc.max {
t.Errorf("random number generation returned too big number expected below: %d, got: %d",
tc.max, rn)
}
if rn < tc.min {
t.Errorf("random number generation returned too small number, expected min: %d, got: %d",
tc.min, rn)
}
})
}
}
func TestGenerator_RandomBytes(t *testing.T) {
tt := []struct {
name string
l int64
sf bool
}{
{"1 bytes of randomness", 1, false},
{"100 bytes of randomness", 100, false},
{"1024 bytes of randomness", 1024, false},
{"4096 bytes of randomness", 4096, false},
{"-1 bytes of randomness", -1, true},
}
g := New(NewConfig())
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
rb, err := g.RandomBytes(tc.l)
if err != nil && !tc.sf {
t.Errorf("random byte generation failed: %s", err)
return
}
if tc.sf {
return
}
bl := len(rb)
if int64(bl) != tc.l {
t.Errorf("lenght of provided bytes does not match requested length: got: %d, expected: %d",
bl, tc.l)
}
if bytes.Equal(rb, make([]byte, tc.l)) {
t.Errorf("random byte generation failed. returned slice is empty")
}
})
}
}
func TestGenerator_RandomString(t *testing.T) {
g := New(NewConfig())
var l int64 = 32 * 1024
tt := []struct {
name string
cr string
nr string
sf bool
}{
{
"CharRange:AlphaLower", CharRangeAlphaLower,
CharRangeAlphaUpper + CharRangeNumeric + CharRangeSpecial, false,
},
{
"CharRange:AlphaUpper", CharRangeAlphaUpper,
CharRangeAlphaLower + CharRangeNumeric + CharRangeSpecial, false,
},
{
"CharRange:Number", CharRangeNumeric,
CharRangeAlphaLower + CharRangeAlphaUpper + CharRangeSpecial, false,
},
{
"CharRange:Special", CharRangeSpecial,
CharRangeAlphaLower + CharRangeAlphaUpper + CharRangeNumeric, false,
},
{
"CharRange:Invalid", "",
CharRangeAlphaLower + CharRangeAlphaUpper + CharRangeNumeric + CharRangeSpecial, true,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
rs, err := g.RandomStringFromCharRange(l, tc.cr)
if err != nil && !tc.sf {
t.Errorf("RandomStringFromCharRange failed: %s", err)
}
if int64(len(rs)) != l && !tc.sf {
t.Errorf("RandomStringFromCharRange failed. Expected length: %d, got: %d", l, len(rs))
}
if strings.ContainsAny(rs, tc.nr) {
t.Errorf("RandomStringFromCharRange failed. Unexpected character found in returned string: %s", rs)
}
})
}
}
func TestGetCharRangeFromConfig(t *testing.T) {
config := NewConfig()
generator := New(config)
testCases := []struct {
Name string
ConfigMode ModeMask
ExpectedRange string
}{
{
Name: "LowerCaseHumanReadable",
ConfigMode: ModeLowerCase | ModeHumanReadable,
ExpectedRange: CharRangeAlphaLowerHuman,
},
{
Name: "LowerCaseNonHumanReadable",
ConfigMode: ModeLowerCase,
ExpectedRange: CharRangeAlphaLower,
},
{
Name: "NumericHumanReadable",
ConfigMode: ModeNumeric | ModeHumanReadable,
ExpectedRange: CharRangeNumericHuman,
},
{
Name: "NumericNonHumanReadable",
ConfigMode: ModeNumeric,
ExpectedRange: CharRangeNumeric,
},
{
Name: "SpecialHumanReadable",
ConfigMode: ModeSpecial | ModeHumanReadable,
ExpectedRange: CharRangeSpecialHuman,
},
{
Name: "SpecialNonHumanReadable",
ConfigMode: ModeSpecial,
ExpectedRange: CharRangeSpecial,
},
{
Name: "UpperCaseHumanReadable",
ConfigMode: ModeUpperCase | ModeHumanReadable,
ExpectedRange: CharRangeAlphaUpperHuman,
},
{
Name: "UpperCaseNonHumanReadable",
ConfigMode: ModeUpperCase,
ExpectedRange: CharRangeAlphaUpper,
},
{
Name: "MultipleModes",
ConfigMode: ModeLowerCase | ModeNumeric | ModeUpperCase | ModeHumanReadable,
ExpectedRange: CharRangeAlphaLowerHuman + CharRangeNumericHuman + CharRangeAlphaUpperHuman,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
generator.config.Mode = tc.ConfigMode
actualRange := generator.GetCharRangeFromConfig()
if actualRange != tc.ExpectedRange {
t.Errorf("Expected range %s, got %s", tc.ExpectedRange, actualRange)
}
})
}
}
func TestGetPasswordLength(t *testing.T) {
config := NewConfig()
generator := New(config)
testCases := []struct {
Name string
ConfigFixedLength int64
ConfigMinLength int64
ConfigMaxLength int64
ExpectedLength int64
ExpectedError error
}{
{
Name: "FixedLength",
ConfigFixedLength: 10,
ConfigMinLength: 5,
ConfigMaxLength: 15,
ExpectedLength: 10,
ExpectedError: nil,
},
{
Name: "MinLengthEqualToMaxLength",
ConfigFixedLength: 0,
ConfigMinLength: 8,
ConfigMaxLength: 8,
ExpectedLength: 8,
ExpectedError: nil,
},
{
Name: "MinLengthGreaterThanMaxLength",
ConfigFixedLength: 0,
ConfigMinLength: 12,
ConfigMaxLength: 5,
ExpectedLength: 12,
ExpectedError: nil,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
generator.config.FixedLength = tc.ConfigFixedLength
generator.config.MinLength = tc.ConfigMinLength
generator.config.MaxLength = tc.ConfigMaxLength
length, err := generator.GetPasswordLength()
if err != nil && !errors.Is(err, tc.ExpectedError) {
t.Errorf("Unexpected error: %v", err)
} else if err == nil && tc.ExpectedError != nil {
t.Errorf("Expected error %v, got nil", tc.ExpectedError)
} else if err == nil && length != tc.ExpectedLength {
t.Errorf("Expected length %d, got %d", tc.ExpectedLength, length)
}
})
}
}
// TestGenerateCoinFlip tries to test the coinflip. Randomness is hard to
// test, since it's supposed to be not prodictable. We think that in 100k
// tries at least one of each two results should be returned, even though
// it is possible that not. Therefore we only throw a warning
func TestGenerateCoinFlip(t *testing.T) {
config := NewConfig()
generator := New(config)
foundTails := false
foundHeads := false
for range 100_000 {
res, err := generator.generateCoinFlip()
if err != nil {
t.Errorf("generateCoinFlip() failed: %s", err)
return
}
switch res {
case "Tails":
foundTails = true
case "Heads":
foundHeads = true
}
}
if !foundTails && !foundHeads {
t.Logf("WARNING: generateCoinFlip() was supposed to find heads and tails "+
"in 100_000 tries but didn't. Heads: %t, Tails: %t", foundHeads, foundTails)
}
}
func TestGeneratePronounceable(t *testing.T) {
config := NewConfig()
generator := New(config)
foundSylables := 0
for range 100 {
res, err := generator.generatePronounceable()
if err != nil {
t.Errorf("generatePronounceable() failed: %s", err)
return
}
for _, syl := range KoremutakeSyllables {
if strings.Contains(res, syl) {
foundSylables++
}
}
}
if foundSylables < 100 {
t.Errorf("generatePronounceable() failed, expected at least 1 sylable, got none")
}
}
func TestCheckMinimumRequirements(t *testing.T) {
config := NewConfig()
generator := New(config)
testCases := []struct {
Name string
Password string
ConfigMinLowerCase int64
ConfigMinNumeric int64
ConfigMinSpecial int64
ConfigMinUpperCase int64
ExpectedResult bool
}{
{
Name: "Meets all requirements",
Password: "Th1sIsA$trongP@ssword",
ConfigMinLowerCase: 2,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 1,
ExpectedResult: true,
},
{
Name: "Missing lowercase",
Password: "THISIS@STRONGPASSWORD",
ConfigMinLowerCase: 2,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 1,
ExpectedResult: false,
},
{
Name: "Missing numeric",
Password: "ThisIsA$trongPassword",
ConfigMinLowerCase: 2,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 1,
ExpectedResult: false,
},
{
Name: "Missing special",
Password: "ThisIsALowercaseNumericPassword",
ConfigMinLowerCase: 2,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 1,
ExpectedResult: false,
},
{
Name: "Missing uppercase",
Password: "thisisanumericspecialpassword",
ConfigMinLowerCase: 2,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 1,
ExpectedResult: false,
},
{
Name: "Bare minimum",
Password: "a1!",
ConfigMinLowerCase: 1,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 0,
ExpectedResult: true,
},
{
Name: "Empty password",
Password: "",
ConfigMinLowerCase: 1,
ConfigMinNumeric: 1,
ConfigMinSpecial: 1,
ConfigMinUpperCase: 1,
ExpectedResult: false,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
generator.config.MinLowerCase = tc.ConfigMinLowerCase
generator.config.MinNumeric = tc.ConfigMinNumeric
generator.config.MinSpecial = tc.ConfigMinSpecial
generator.config.MinUpperCase = tc.ConfigMinUpperCase
result := generator.checkMinimumRequirements(tc.Password)
if result != tc.ExpectedResult {
t.Errorf("Expected result %v, got %v", tc.ExpectedResult, result)
}
})
}
}
func TestGenerateRandom(t *testing.T) {
config := NewConfig(WithAlgorithm(AlgoRandom), WithNumberPass(1),
WithMinLength(1), WithMaxLength(1))
config.MinNumeric = 1
generator := New(config)
pw, err := generator.generateRandom()
if err != nil {
t.Errorf("generateRandom() failed: %s", err)
}
if len(pw) > 1 {
t.Errorf("expected password with length 1 but got: %d", len(pw))
}
n, err := strconv.Atoi(pw)
if err != nil {
t.Errorf("expected password to be a number but got an error: %s", err)
}
if n < 0 || n > 9 {
t.Errorf("expected password to be a number between 0 and 9, got: %d", n)
}
}
func TestGenerate(t *testing.T) {
tests := []struct {
name string
algorithm Algorithm
expectedErr error
}{
{
name: "Pronounceable",
algorithm: AlgoPronounceable,
},
{
name: "CoinFlip",
algorithm: AlgoCoinFlip,
},
{
name: "Random",
algorithm: AlgoRandom,
},
{
name: "Unsupported",
algorithm: AlgoUnsupported,
expectedErr: fmt.Errorf("unsupported algorithm"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := NewConfig(WithAlgorithm(tt.algorithm))
g := New(config)
_, err := g.Generate()
if tt.expectedErr != nil {
if err == nil || err.Error() != tt.expectedErr.Error() {
t.Errorf("Expected error: %s, got: %s", tt.expectedErr, err)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %s", err)
return
}
})
}
}
func BenchmarkGenerator_CoinFlip(b *testing.B) {
b.ReportAllocs()
g := New(NewConfig())
for i := 0; i < b.N; i++ {
_ = g.CoinFlip()
}
}
func BenchmarkGenerator_RandomBytes(b *testing.B) {
b.ReportAllocs()
g := New(NewConfig())
var l int64 = 1024
for i := 0; i < b.N; i++ {
_, err := g.RandomBytes(l)
if err != nil {
b.Errorf("failed to generate random bytes: %s", err)
return
}
}
}
func BenchmarkGenerator_RandomString(b *testing.B) {
b.ReportAllocs()
g := New(NewConfig())
cr := CharRangeAlphaUpper + CharRangeAlphaLower + CharRangeNumeric + CharRangeSpecial
for i := 0; i < b.N; i++ {
_, err := g.RandomStringFromCharRange(32, cr)
if err != nil {
b.Errorf("RandomStringFromCharRange() failed: %s", err)
}
}
}

View file

@ -1 +1,6 @@
# SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
sonar.projectKey=apg-go sonar.projectKey=apg-go
sonar.go.coverage.reportPaths=cov.out

View file

@ -1,8 +1,11 @@
package spelling // SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import ( import (
"fmt" "fmt"
"github.com/wneessen/apg-go/chars"
"strings" "strings"
) )
@ -81,11 +84,11 @@ var (
} }
) )
// String returns an english spelled version of the given string // Spell returns a given string as spelled english phonetic alphabet string
func String(pwString string) (string, error) { func Spell(input string) (string, error) {
var returnString []string var returnString []string
for _, curChar := range pwString { for _, curChar := range input {
curSpellString, err := ConvertCharToName(byte(curChar)) curSpellString, err := ConvertByteToWord(byte(curChar))
if err != nil { if err != nil {
return "", err return "", err
} }
@ -94,24 +97,23 @@ func String(pwString string) (string, error) {
return strings.Join(returnString, "/"), nil return strings.Join(returnString, "/"), nil
} }
// Koremutake returns the spelling of the Koremutake password with numbers and special // Pronounce returns last generated pronounceable password as spelled syllables string
// chars spelled out in english language func (g *Generator) Pronounce() (string, error) {
func Koremutake(sylList []string) (string, error) {
var returnString []string var returnString []string
for _, curSyl := range sylList { for _, syllable := range g.syllables {
isKore := false isKoremutake := false
for _, x := range chars.KoremutakeSyllables { for _, x := range KoremutakeSyllables {
if x == strings.ToLower(curSyl) { if x == strings.ToLower(syllable) {
isKore = true isKoremutake = true
} }
} }
if isKore { if isKoremutake {
returnString = append(returnString, curSyl) returnString = append(returnString, syllable)
continue continue
} }
curSpellString, err := ConvertCharToName(curSyl[0]) curSpellString, err := ConvertByteToWord(syllable[0])
if err != nil { if err != nil {
return "", err return "", err
} }
@ -120,21 +122,21 @@ func Koremutake(sylList []string) (string, error) {
return strings.Join(returnString, "-"), nil return strings.Join(returnString, "-"), nil
} }
// ConvertCharToName converts a given ascii byte into the corresponding english spelled // ConvertByteToWord converts a given ASCII byte into the corresponding spelled version
// name // of the english phonetic alphabet
func ConvertCharToName(charByte byte) (string, error) { func ConvertByteToWord(charByte byte) (string, error) {
var returnString string var returnString string
if charByte > 64 && charByte < 91 { switch {
case charByte > 64 && charByte < 91:
returnString = alphabetNames[charByte] returnString = alphabetNames[charByte]
} else if charByte > 96 && charByte < 123 { case charByte > 96 && charByte < 123:
returnString = strings.ToLower(alphabetNames[charByte-32]) returnString = strings.ToLower(alphabetNames[charByte-32])
} else { default:
returnString = symbNumNames[charByte] returnString = symbNumNames[charByte]
} }
if returnString == "" { if returnString == "" {
err := fmt.Errorf("cannot convert to character to name: %q is an unknown character", charByte) return "", fmt.Errorf("failed to convert given byte to word: %s is unsupported", string(charByte))
return "", err
} }
return returnString, nil return returnString, nil
} }

165
spelling_test.go Normal file
View file

@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2021-2024 Winni Neessen <wn@neessen.dev>
//
// SPDX-License-Identifier: MIT
package apg
import (
"strings"
"testing"
)
func TestConvertByteToWord(t *testing.T) {
tests := []struct {
name string
char byte
want string
wantErr bool
}{
{
name: "UpperCaseChar",
char: 'A',
want: alphabetNames['A'],
wantErr: false,
},
{
name: "LowerCaseChar",
char: 'a',
want: strings.ToLower(alphabetNames['A']),
wantErr: false,
},
{
name: "NonAlphaChar",
char: '(',
want: symbNumNames['('],
wantErr: false,
},
{
name: "UnsupportedChar",
char: 'ü',
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ConvertByteToWord(tt.char)
if (err != nil) != tt.wantErr {
t.Errorf("ConvertByteToWord() error = %s, wantErr %t", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ConvertByteToWord() got = %s, want %s", got, tt.want)
}
})
}
}
func TestSpell(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "empty string",
input: "",
want: "",
wantErr: false,
},
{
name: "single character",
input: "a",
want: "alfa",
wantErr: false,
},
{
name: "multiple characters",
input: "abc",
want: "alfa/bravo/charlie",
wantErr: false,
},
{
name: "non-alphabetic character",
input: "1",
want: "ONE",
wantErr: false,
},
{
name: "mixed alphabetic and non-alphabetic characters",
input: "a1",
want: "alfa/ONE",
wantErr: false,
},
{
name: "not supported characters",
input: "üäö߀",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Spell(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("Spell() error = %s, wantErr %t", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Spell() = %s, want %s", got, tt.want)
}
})
}
}
func TestPronounce(t *testing.T) {
tests := []struct {
name string
syllables []string
want string
wantErr bool
}{
{
name: "Pronounce_NoSyllables",
syllables: []string{},
want: "",
wantErr: false,
},
{
name: "Pronounce_SingleSyllable",
syllables: []string{"me"},
want: "me",
wantErr: false,
},
{
name: "Pronounce_MultipleSyllables",
syllables: []string{"mu", "sa"},
want: "mu-sa",
wantErr: false,
},
{
name: "Pronounce_NonKoremutakeSyllable",
syllables: []string{"ä"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := NewConfig()
g := New(config)
g.syllables = tt.syllables
got, err := g.Pronounce()
if (err != nil) != tt.wantErr {
t.Errorf("Generator.Pronounce() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Generator.Pronounce() = %v, want %v", got, tt.want)
}
})
}
}