diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:30 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-07 16:24:30 +0200 |
| commit | cc21fb4978b54d643d8fb3f5a454e62da4b396c2 (patch) | |
| tree | 3050c8d4b68e28989cbe611001f8c6e961a08e51 | |
| download | qpass-main.tar.gz qpass-main.tar.xz qpass-main.zip | |
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Makefile | 27 | ||||
| -rw-r--r-- | README.md | 72 | ||||
| -rw-r--r-- | cmd/qpass/main.go | 264 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | internal/entropy/entropy.go | 136 | ||||
| -rw-r--r-- | internal/entropy/entropy_test.go | 93 | ||||
| -rw-r--r-- | internal/passgen/passgen.go | 124 | ||||
| -rw-r--r-- | internal/passgen/passgen_test.go | 76 |
9 files changed, 796 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..652ad92 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +BINARY := qpass +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +GO ?= go + +.PHONY: all build clean fmt install test uninstall + +all: build + +build: + mkdir -p bin + CGO_ENABLED=0 $(GO) build -buildvcs=false -trimpath -ldflags="-s -w" -o bin/$(BINARY) ./cmd/qpass + +fmt: + $(GO) fmt ./... + +test: + $(GO) test ./... + +install: build + install -Dm755 bin/$(BINARY) "$(DESTDIR)$(BINDIR)/$(BINARY)" + +uninstall: + rm -f "$(DESTDIR)$(BINDIR)/$(BINARY)" + +clean: + rm -rf bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b3cabf --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# qpass + +`qpass` is a small Go password generator with a conservative cryptographic +randomness model. + +By default it uses Go's `crypto/rand` OS CSPRNG. It can also run in hybrid mode, +where local OS randomness is mixed with bytes from an external quantum, +hardware, or certified randomness source. The external source is additive: it +never replaces the OS CSPRNG as the safety baseline. + +## Usage + +```bash +go run ./cmd/qpass +go run ./cmd/qpass -stats +go run ./cmd/qpass -length 32 -count 5 +go run ./cmd/qpass -length 32 -symbols=false +``` + +Hybrid entropy mode: + +```bash +go run ./cmd/qpass -rng hybrid -entropy-file random-bytes.bin +go run ./cmd/qpass -rng hybrid -entropy-device /dev/qrandom +qrng-tool --raw | go run ./cmd/qpass -rng hybrid -entropy-stdin +``` + +Legacy compatibility: + +```bash +go run ./cmd/qpass -length 32 -quantum-file random-bytes.bin +qrng-tool --raw | go run ./cmd/qpass -length 32 -quantum-file - +``` + +`-quantum-file` is kept as an alias for older usage. New scripts should prefer +`-rng hybrid` with `-entropy-file`, `-entropy-device`, or `-entropy-stdin`. + +Entropy checks: + +```bash +go run ./cmd/qpass entropy status +go run ./cmd/qpass entropy test -entropy-file random-bytes.bin +go run ./cmd/qpass entropy test -entropy-device /dev/qrandom -require-external +``` + +## Notes + +- `-rng os` uses only Go's `crypto/rand.Reader`. +- `-rng hybrid` mixes `crypto/rand.Reader` with the configured external source + using SHA-512 domain-separated blocks. +- Without `-require-external`, hybrid mode degrades to OS CSPRNG if the external + source runs out or fails, and prints a warning on stderr. +- With `-require-external`, generation fails when the external source cannot + provide enough bytes. +- `-replace-global-rand` installs the selected engine as `crypto/rand.Reader` + for this process. It is an advanced integration option; normal password + generation passes the reader explicitly. +- Entanglement-based randomness certification needs physical hardware and a + Bell-test/randomness-amplification protocol. +- A normal laptop cannot reproduce that guarantee in software. +- For everyday password generation, OS CSPRNG via `crypto/rand` is the + practical baseline. +- `qpass` prints only passwords by default. Use `-stats` when you want the + alphabet size and estimated entropy on stderr. + +## Development + +```bash +make fmt +make test +make build +``` diff --git a/cmd/qpass/main.go b/cmd/qpass/main.go new file mode 100644 index 0000000..8063ab8 --- /dev/null +++ b/cmd/qpass/main.go @@ -0,0 +1,264 @@ +package main + +import ( + crand "crypto/rand" + "flag" + "fmt" + "io" + "os" + "strings" + + "quantum-passgen/internal/entropy" + "quantum-passgen/internal/passgen" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "entropy" { + runEntropy(os.Args[2:]) + return + } + runGenerate(os.Args[1:]) +} + +func runGenerate(args []string) { + opts := passgen.DefaultOptions() + fs := flag.NewFlagSet("qpass", flag.ExitOnError) + count := fs.Int("count", 1, "number of passwords to generate") + stats := fs.Bool("stats", false, "print alphabet size and estimated entropy to stderr") + rngMode := fs.String("rng", "os", "random engine: os or hybrid") + entropyFile := fs.String("entropy-file", "", "external entropy file/device; use - for stdin") + entropyDevice := fs.String("entropy-device", "", "external entropy device path") + entropyStdin := fs.Bool("entropy-stdin", false, "read external entropy from stdin") + quantumFile := fs.String("quantum-file", "", "deprecated alias for -entropy-file") + requireExternal := fs.Bool("require-external", false, "fail if external entropy cannot provide enough bytes") + replaceGlobal := fs.Bool("replace-global-rand", false, "advanced: install selected engine as crypto/rand.Reader for this process") + + fs.IntVar(&opts.Length, "length", opts.Length, "password length") + fs.BoolVar(&opts.UseLower, "lower", opts.UseLower, "include lowercase letters") + fs.BoolVar(&opts.UseUpper, "upper", opts.UseUpper, "include uppercase letters") + fs.BoolVar(&opts.UseDigits, "digits", opts.UseDigits, "include digits") + fs.BoolVar(&opts.UseSymbols, "symbols", opts.UseSymbols, "include symbols") + fs.Parse(args) + + if *count <= 0 { + exitf("count must be positive") + } + + random, hybrid, cleanup := configureRandom(randomConfig{ + mode: *rngMode, + entropyFile: *entropyFile, + entropyDevice: *entropyDevice, + entropyStdin: *entropyStdin, + quantumFile: *quantumFile, + requireExternal: *requireExternal, + rngFlagSet: flagWasSet(fs, "rng"), + }) + defer cleanup() + opts.Random = random + + if *replaceGlobal { + restore := entropy.InstallGlobal(random) + defer restore() + } + + for i := 0; i < *count; i++ { + password, err := passgen.Generate(opts) + if err != nil { + exitf("%v", err) + } + fmt.Println(password) + } + + if *stats { + alphabetSize := passgen.AlphabetSize(opts) + entropyBits, err := passgen.EntropyBits(opts.Length, alphabetSize) + if err != nil { + exitf("%v", err) + } + fmt.Fprintf(os.Stderr, "alphabet=%d estimated_entropy=%.1f bits\n", alphabetSize, entropyBits) + } + + if hybrid != nil && hybrid.Degraded() { + fmt.Fprintf(os.Stderr, "qpass: warning: external entropy degraded; continued with OS CSPRNG: %v\n", hybrid.DegradeError()) + } +} + +type randomConfig struct { + mode string + entropyFile string + entropyDevice string + entropyStdin bool + quantumFile string + requireExternal bool + rngFlagSet bool +} + +func configureRandom(cfg randomConfig) (io.Reader, *entropy.HybridReader, func()) { + cleanup := func() {} + source, closeSource := openExternalSource(cfg) + if closeSource != nil { + cleanup = closeSource + } + + mode := strings.ToLower(strings.TrimSpace(cfg.mode)) + if mode == "" { + mode = "os" + } + if source != nil && mode == "os" && !cfg.rngFlagSet { + mode = "hybrid" + } + + switch mode { + case "os": + if source != nil { + cleanup() + exitf("-rng os cannot be combined with an external entropy source") + } + return crand.Reader, nil, cleanup + case "hybrid": + if source == nil { + cleanup() + exitf("-rng hybrid requires -entropy-file, -entropy-device, -entropy-stdin, or -quantum-file") + } + reader, err := entropy.NewHybridReader(crand.Reader, source, entropy.HybridOptions{ + RequireExternal: cfg.requireExternal, + }) + if err != nil { + cleanup() + exitf("configure hybrid entropy: %v", err) + } + return reader, reader, cleanup + default: + cleanup() + exitf("unknown rng mode %q", cfg.mode) + return nil, nil, cleanup + } +} + +func openExternalSource(cfg randomConfig) (io.Reader, func()) { + sources := 0 + sourceLabel := "" + sourcePath := "" + + if cfg.quantumFile != "" { + sources++ + sourceLabel = "-quantum-file" + sourcePath = cfg.quantumFile + } + if cfg.entropyFile != "" { + sources++ + sourceLabel = "-entropy-file" + sourcePath = cfg.entropyFile + } + if cfg.entropyDevice != "" { + sources++ + sourceLabel = "-entropy-device" + sourcePath = cfg.entropyDevice + } + if cfg.entropyStdin { + sources++ + sourceLabel = "-entropy-stdin" + sourcePath = "-" + } + + if sources == 0 { + return nil, nil + } + if sources > 1 { + exitf("choose only one external entropy source") + } + if sourcePath == "-" { + return os.Stdin, nil + } + + file, err := os.Open(sourcePath) + if err != nil { + exitf("open %s: %v", sourceLabel, err) + } + return file, func() { + if err := file.Close(); err != nil { + fmt.Fprintf(os.Stderr, "qpass: warning: close %s: %v\n", sourceLabel, err) + } + } +} + +func flagWasSet(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + +func runEntropy(args []string) { + if len(args) == 0 { + exitf("usage: qpass entropy status|test") + } + + switch args[0] { + case "status": + runEntropyStatus(args[1:]) + case "test": + runEntropyTest(args[1:]) + default: + exitf("unknown entropy command %q", args[0]) + } +} + +func runEntropyStatus(args []string) { + fs := flag.NewFlagSet("qpass entropy status", flag.ExitOnError) + fs.Parse(args) + + var b [1]byte + if _, err := io.ReadFull(crand.Reader, b[:]); err != nil { + exitf("OS CSPRNG unavailable: %v", err) + } + + fmt.Println("os_csprng=available") + fmt.Println("external_source=not_configured") + fmt.Println("global_override=disabled_by_default") +} + +func runEntropyTest(args []string) { + fs := flag.NewFlagSet("qpass entropy test", flag.ExitOnError) + entropyFile := fs.String("entropy-file", "", "external entropy file/device; use - for stdin") + entropyDevice := fs.String("entropy-device", "", "external entropy device path") + entropyStdin := fs.Bool("entropy-stdin", false, "read external entropy from stdin") + quantumFile := fs.String("quantum-file", "", "deprecated alias for -entropy-file") + requireExternal := fs.Bool("require-external", false, "fail if external entropy cannot provide enough bytes") + bytesToRead := fs.Int("bytes", 64, "number of bytes to read without printing them") + fs.Parse(args) + + if *bytesToRead <= 0 { + exitf("bytes must be positive") + } + + random, hybrid, cleanup := configureRandom(randomConfig{ + mode: "hybrid", + entropyFile: *entropyFile, + entropyDevice: *entropyDevice, + entropyStdin: *entropyStdin, + quantumFile: *quantumFile, + requireExternal: *requireExternal, + }) + defer cleanup() + + buf := make([]byte, *bytesToRead) + if _, err := io.ReadFull(random, buf); err != nil { + exitf("entropy test failed: %v", err) + } + + fmt.Printf("mode=hybrid\n") + fmt.Printf("read_bytes=%d\n", len(buf)) + fmt.Printf("external_degraded=%t\n", hybrid.Degraded()) + if hybrid.Degraded() { + fmt.Printf("degrade_error=%v\n", hybrid.DegradeError()) + } +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "qpass: "+format+"\n", args...) + os.Exit(1) +} @@ -0,0 +1,3 @@ +module quantum-passgen + +go 1.22 diff --git a/internal/entropy/entropy.go b/internal/entropy/entropy.go new file mode 100644 index 0000000..886ecba --- /dev/null +++ b/internal/entropy/entropy.go @@ -0,0 +1,136 @@ +package entropy + +import ( + crand "crypto/rand" + "crypto/sha512" + "encoding/binary" + "errors" + "fmt" + "io" + "sync" +) + +const blockSize = sha512.Size + +var ErrExternalEntropyRequired = errors.New("external entropy source required") + +type HybridOptions struct { + RequireExternal bool +} + +type HybridReader struct { + base io.Reader + external io.Reader + requireExternal bool + + mu sync.Mutex + counter uint64 + degraded bool + degradeErr error + buffer []byte +} + +func NewHybridReader(base io.Reader, external io.Reader, opts HybridOptions) (*HybridReader, error) { + if base == nil { + base = crand.Reader + } + if external == nil { + return nil, ErrExternalEntropyRequired + } + + return &HybridReader{ + base: base, + external: external, + requireExternal: opts.RequireExternal, + }, nil +} + +func (r *HybridReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + written := 0 + for written < len(p) { + if len(r.buffer) > 0 { + n := copy(p[written:], r.buffer) + written += n + r.buffer = r.buffer[n:] + continue + } + + block, err := r.nextBlockLocked() + if err != nil { + return written, err + } + + n := copy(p[written:], block[:]) + written += n + if n < len(block) { + r.buffer = append(r.buffer[:0], block[n:]...) + } + } + + return written, nil +} + +func (r *HybridReader) Degraded() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.degraded +} + +func (r *HybridReader) DegradeError() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.degradeErr +} + +func (r *HybridReader) nextBlockLocked() ([blockSize]byte, error) { + var base [blockSize]byte + if _, err := io.ReadFull(r.base, base[:]); err != nil { + return [blockSize]byte{}, fmt.Errorf("read OS CSPRNG: %w", err) + } + + if r.degraded { + return base, nil + } + + var external [blockSize]byte + if _, err := io.ReadFull(r.external, external[:]); err != nil { + if r.requireExternal { + return [blockSize]byte{}, fmt.Errorf("read external entropy: %w", err) + } + r.degraded = true + r.degradeErr = err + return base, nil + } + + var counter [8]byte + binary.BigEndian.PutUint64(counter[:], r.counter) + r.counter++ + + h := sha512.New() + h.Write([]byte("qpass hybrid entropy v1\x00")) + h.Write(counter[:]) + h.Write(base[:]) + h.Write(external[:]) + + var out [blockSize]byte + copy(out[:], h.Sum(nil)) + return out, nil +} + +func InstallGlobal(reader io.Reader) func() { + if reader == nil { + reader = crand.Reader + } + previous := crand.Reader + crand.Reader = reader + return func() { + crand.Reader = previous + } +} diff --git a/internal/entropy/entropy_test.go b/internal/entropy/entropy_test.go new file mode 100644 index 0000000..4ee1a6b --- /dev/null +++ b/internal/entropy/entropy_test.go @@ -0,0 +1,93 @@ +package entropy + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestHybridReaderMixesBaseAndExternal(t *testing.T) { + base := strings.NewReader(strings.Repeat("a", 4096)) + external := strings.NewReader(strings.Repeat("b", 4096)) + reader, err := NewHybridReader(base, external, HybridOptions{}) + if err != nil { + t.Fatalf("NewHybridReader returned error: %v", err) + } + + out := make([]byte, 128) + if _, err := io.ReadFull(reader, out); err != nil { + t.Fatalf("Read returned error: %v", err) + } + + if bytes.Equal(out, bytes.Repeat([]byte("a"), len(out))) { + t.Fatalf("hybrid output matched raw base reader") + } + if reader.Degraded() { + t.Fatalf("hybrid reader degraded with enough external bytes") + } +} + +func TestHybridReaderDegradesToBaseWhenExternalFails(t *testing.T) { + base := strings.NewReader(strings.Repeat("x", 4096)) + external := strings.NewReader("") + reader, err := NewHybridReader(base, external, HybridOptions{}) + if err != nil { + t.Fatalf("NewHybridReader returned error: %v", err) + } + + out := make([]byte, 32) + if _, err := io.ReadFull(reader, out); err != nil { + t.Fatalf("Read returned error: %v", err) + } + + if !bytes.Equal(out, bytes.Repeat([]byte("x"), len(out))) { + t.Fatalf("degraded output should come from base reader") + } + if !reader.Degraded() { + t.Fatalf("hybrid reader should report degraded state") + } + if reader.DegradeError() == nil { + t.Fatalf("hybrid reader should keep the external source error") + } +} + +func TestHybridReaderBuffersSmallReads(t *testing.T) { + base := strings.NewReader(strings.Repeat("a", blockSize)) + external := strings.NewReader(strings.Repeat("b", blockSize)) + reader, err := NewHybridReader(base, external, HybridOptions{}) + if err != nil { + t.Fatalf("NewHybridReader returned error: %v", err) + } + + var one [1]byte + for i := 0; i < blockSize; i++ { + if _, err := io.ReadFull(reader, one[:]); err != nil { + t.Fatalf("Read %d returned error: %v", i, err) + } + } + + if reader.Degraded() { + t.Fatalf("small reads should reuse one mixed block before reading external again") + } +} + +func TestHybridReaderRequiresExternalWhenConfigured(t *testing.T) { + base := strings.NewReader(strings.Repeat("x", 4096)) + external := strings.NewReader("") + reader, err := NewHybridReader(base, external, HybridOptions{RequireExternal: true}) + if err != nil { + t.Fatalf("NewHybridReader returned error: %v", err) + } + + out := make([]byte, 1) + if _, err := io.ReadFull(reader, out); err == nil { + t.Fatalf("Read returned nil error with required external entropy") + } +} + +func TestNewHybridReaderRejectsMissingExternalSource(t *testing.T) { + if _, err := NewHybridReader(strings.NewReader("base"), nil, HybridOptions{}); err != ErrExternalEntropyRequired { + t.Fatalf("NewHybridReader error = %v, want %v", err, ErrExternalEntropyRequired) + } +} diff --git a/internal/passgen/passgen.go b/internal/passgen/passgen.go new file mode 100644 index 0000000..1c3241d --- /dev/null +++ b/internal/passgen/passgen.go @@ -0,0 +1,124 @@ +package passgen + +import ( + "crypto/rand" + "errors" + "fmt" + "io" + "math" +) + +const ( + lowerChars = "abcdefghijklmnopqrstuvwxyz" + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digitChars = "0123456789" + symbolChars = "!@#$%^&*()-_=+[]{};:,.?/" +) + +var ErrEmptyAlphabet = errors.New("alphabet is empty") + +type Options struct { + Length int + UseLower bool + UseUpper bool + UseDigits bool + UseSymbols bool + Random io.Reader +} + +func DefaultOptions() Options { + return Options{ + Length: 24, + UseLower: true, + UseUpper: true, + UseDigits: true, + UseSymbols: true, + } +} + +func Generate(opts Options) (string, error) { + if opts.Length <= 0 { + return "", fmt.Errorf("length must be positive") + } + + alphabet := buildAlphabet(opts) + if alphabet == "" { + return "", ErrEmptyAlphabet + } + + random := opts.Random + if random == nil { + random = rand.Reader + } + + password := make([]byte, opts.Length) + for i := range password { + n, err := randomIndex(len(alphabet), random) + if err != nil { + return "", fmt.Errorf("choose password character: %w", err) + } + password[i] = alphabet[n] + } + + return string(password), nil +} + +func EntropyBits(length int, alphabetSize int) (float64, error) { + if length <= 0 { + return 0, fmt.Errorf("length must be positive") + } + if alphabetSize <= 1 { + return 0, fmt.Errorf("alphabet size must be greater than 1") + } + + return float64(length) * math.Log2(float64(alphabetSize)), nil +} + +func AlphabetSize(opts Options) int { + return len(buildAlphabet(opts)) +} + +func buildAlphabet(opts Options) string { + alphabet := "" + if opts.UseLower { + alphabet += lowerChars + } + if opts.UseUpper { + alphabet += upperChars + } + if opts.UseDigits { + alphabet += digitChars + } + if opts.UseSymbols { + alphabet += symbolChars + } + return alphabet +} + +func randomIndex(size int, random io.Reader) (int, error) { + if size <= 0 { + return 0, ErrEmptyAlphabet + } + if size > 256 { + return 0, fmt.Errorf("alphabet size %d exceeds supported maximum 256", size) + } + + cutoff := 256 - (256 % size) + for { + value, err := randomByte(random) + if err != nil { + return 0, err + } + if int(value) < cutoff { + return int(value) % size, nil + } + } +} + +func randomByte(random io.Reader) (byte, error) { + var b [1]byte + if _, err := io.ReadFull(random, b[:]); err != nil { + return 0, fmt.Errorf("read random byte: %w", err) + } + return b[0], nil +} diff --git a/internal/passgen/passgen_test.go b/internal/passgen/passgen_test.go new file mode 100644 index 0000000..8c70420 --- /dev/null +++ b/internal/passgen/passgen_test.go @@ -0,0 +1,76 @@ +package passgen + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestGenerateUsesRequestedLengthAndAlphabet(t *testing.T) { + opts := Options{ + Length: 64, + UseLower: true, + UseUpper: false, + UseDigits: false, + } + + password, err := Generate(opts) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if len(password) != opts.Length { + t.Fatalf("password length = %d, want %d", len(password), opts.Length) + } + + for _, ch := range password { + if !strings.ContainsRune(lowerChars, ch) { + t.Fatalf("password contains character outside requested alphabet: %q", ch) + } + } +} + +func TestGenerateRejectsEmptyAlphabet(t *testing.T) { + _, err := Generate(Options{Length: 24}) + if err != ErrEmptyAlphabet { + t.Fatalf("Generate error = %v, want %v", err, ErrEmptyAlphabet) + } +} + +func TestGenerateUsesProvidedRandomReader(t *testing.T) { + opts := Options{ + Length: 3, + UseLower: true, + UseUpper: false, + UseDigits: false, + UseSymbols: false, + Random: bytes.NewReader([]byte{0, 1, 2}), + } + + password, err := Generate(opts) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if password != "abc" { + t.Fatalf("password = %q, want %q", password, "abc") + } +} + +func TestGenerateReturnsRandomReaderError(t *testing.T) { + opts := Options{ + Length: 1, + UseLower: true, + UseUpper: false, + UseDigits: false, + UseSymbols: false, + Random: strings.NewReader(""), + } + + if _, err := Generate(opts); err == nil { + t.Fatalf("Generate returned nil error") + } else if !strings.Contains(err.Error(), io.EOF.Error()) { + t.Fatalf("Generate error = %v, want EOF context", err) + } +} |
