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 /internal | |
| download | qpass-main.tar.gz qpass-main.tar.xz qpass-main.zip | |
Diffstat (limited to 'internal')
| -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 |
4 files changed, 429 insertions, 0 deletions
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) + } +} |
