Implement owner setup and browser authentication sessions
Some checks failed
CI / Backend (push) Failing after 1s
CI / Frontend (push) Successful in 13s
CI / Contracts and repository policy (push) Failing after 3s
CI / Container (push) Has been skipped

This commit is contained in:
Elijah 2026-07-16 19:45:40 -07:00
parent 077cf7601a
commit 715423ab8e
21 changed files with 2185 additions and 14 deletions

View file

@ -0,0 +1,46 @@
package authn
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"fmt"
"strings"
"time"
"drive.local/drivev2/internal/application"
)
type CredentialGenerator struct{}
func (CredentialGenerator) NewCredential() (application.GeneratedCredential, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return application.GeneratedCredential{}, fmt.Errorf("generate credential: %w", err)
}
plaintext := base64.RawURLEncoding.EncodeToString(secret)
digest := sha256.Sum256([]byte(plaintext))
return application.GeneratedCredential{Plaintext: plaintext, Digest: digest[:]}, nil
}
func (CredentialGenerator) NewRecoveryCodes(count int) ([]application.GeneratedCredential, error) {
codes := make([]application.GeneratedCredential, 0, count)
for range count {
raw := make([]byte, 12)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate recovery code: %w", err)
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
plaintext := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20]}, "-")
digest := sha256.Sum256([]byte(plaintext))
codes = append(codes, application.GeneratedCredential{Plaintext: plaintext, Digest: digest[:]})
}
return codes, nil
}
type SystemClock struct{}
func (SystemClock) Now() time.Time {
return time.Now()
}

View file

@ -0,0 +1,127 @@
// Package authn implements password hashing and high-entropy credential generation.
package authn
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
var ErrInvalidPasswordHash = errors.New("invalid password hash")
type Argon2Parameters struct {
MemoryKiB uint32
Time uint32
Threads uint8
SaltBytes uint32
KeyBytes uint32
}
type PasswordHasher struct {
parameters Argon2Parameters
}
func NewPasswordHasher() PasswordHasher {
return PasswordHasher{parameters: Argon2Parameters{
MemoryKiB: 64 * 1024,
Time: 3,
Threads: 4,
SaltBytes: 16,
KeyBytes: 32,
}}
}
func newPasswordHasher(parameters Argon2Parameters) PasswordHasher {
return PasswordHasher{parameters: parameters}
}
func (h PasswordHasher) Hash(password string) (string, error) {
salt := make([]byte, h.parameters.SaltBytes)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
derived := argon2.IDKey([]byte(password), salt, h.parameters.Time, h.parameters.MemoryKiB, h.parameters.Threads, h.parameters.KeyBytes)
return fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
h.parameters.MemoryKiB,
h.parameters.Time,
h.parameters.Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(derived),
), nil
}
func (h PasswordHasher) Verify(password, encoded string) (bool, error) {
parameters, salt, expected, err := parsePasswordHash(encoded)
if err != nil {
return false, err
}
actual := argon2.IDKey([]byte(password), salt, parameters.Time, parameters.MemoryKiB, parameters.Threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1, nil
}
func parsePasswordHash(encoded string) (Argon2Parameters, []byte, []byte, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" {
return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
version, err := parseNamedUint(parts[2], "v", 8)
if err != nil || version != argon2.Version {
return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
parameters, err := parseArgon2Parameters(parts[3])
if err != nil {
return Argon2Parameters{}, nil, nil, err
}
salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4])
if err != nil || len(salt) < 16 || len(salt) > 64 {
return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
derived, err := base64.RawStdEncoding.Strict().DecodeString(parts[5])
if err != nil || len(derived) < 16 || len(derived) > 64 {
return Argon2Parameters{}, nil, nil, ErrInvalidPasswordHash
}
parameters.SaltBytes = uint32(len(salt))
parameters.KeyBytes = uint32(len(derived))
return parameters, salt, derived, nil
}
func parseArgon2Parameters(value string) (Argon2Parameters, error) {
parts := strings.Split(value, ",")
if len(parts) != 3 {
return Argon2Parameters{}, ErrInvalidPasswordHash
}
memory, err := parseNamedUint(parts[0], "m", 32)
if err != nil || memory < 8*1024 || memory > 1024*1024 {
return Argon2Parameters{}, ErrInvalidPasswordHash
}
timeCost, err := parseNamedUint(parts[1], "t", 32)
if err != nil || timeCost == 0 || timeCost > 10 {
return Argon2Parameters{}, ErrInvalidPasswordHash
}
threads, err := parseNamedUint(parts[2], "p", 8)
if err != nil || threads == 0 || threads > 16 {
return Argon2Parameters{}, ErrInvalidPasswordHash
}
return Argon2Parameters{MemoryKiB: uint32(memory), Time: uint32(timeCost), Threads: uint8(threads)}, nil
}
func parseNamedUint(value, name string, bitSize int) (uint64, error) {
prefix := name + "="
if !strings.HasPrefix(value, prefix) {
return 0, ErrInvalidPasswordHash
}
parsed, err := strconv.ParseUint(strings.TrimPrefix(value, prefix), 10, bitSize)
if err != nil {
return 0, ErrInvalidPasswordHash
}
return parsed, nil
}

View file

@ -0,0 +1,40 @@
package authn
import (
"errors"
"testing"
)
func TestPasswordHasherRoundTrip(t *testing.T) {
t.Parallel()
hasher := newPasswordHasher(Argon2Parameters{MemoryKiB: 8 * 1024, Time: 1, Threads: 1, SaltBytes: 16, KeyBytes: 32})
encoded, err := hasher.Hash("a correct horse battery staple")
if err != nil {
t.Fatalf("Hash returned an error: %v", err)
}
valid, err := hasher.Verify("a correct horse battery staple", encoded)
if err != nil {
t.Fatalf("Verify returned an error: %v", err)
}
if !valid {
t.Fatal("Verify = false, want true")
}
valid, err = hasher.Verify("wrong password", encoded)
if err != nil {
t.Fatalf("Verify wrong password returned an error: %v", err)
}
if valid {
t.Fatal("Verify wrong password = true, want false")
}
}
func TestPasswordHasherRejectsUnboundedParameters(t *testing.T) {
t.Parallel()
hasher := NewPasswordHasher()
_, err := hasher.Verify("password", "$argon2id$v=19$m=4294967295,t=3,p=4$c2FsdHNhbHRzYWx0c2FsdA$ZGVyaXZlZGRlcml2ZWRkZXJpdmVkZGVyaXZlZA")
if !errors.Is(err, ErrInvalidPasswordHash) {
t.Fatalf("Verify error = %v, want %v", err, ErrInvalidPasswordHash)
}
}