NextMyDay/internal/core/auth/sealer.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

73 lines
2 KiB
Go

package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
// Sealer is the encryption seam for TOTP secrets at rest (A9). The key comes
// from config later (D-035); this package only defines the mechanism.
//
// A Sealer must return ciphertext that never equals — and never contains — the
// plaintext.
type Sealer interface {
Seal(plaintext []byte) ([]byte, error)
Open(ciphertext []byte) ([]byte, error)
}
// KeySize is the AES-256 key length required by AEADSealer.
const KeySize = 32
// AEADSealer encrypts with AES-256-GCM. The stored blob is
//
// nonce || ciphertext || tag
//
// A fresh random nonce is generated on every Seal, so sealing the same secret
// twice yields different ciphertext.
type AEADSealer struct {
aead cipher.AEAD
}
// NewAEADSealer builds a Sealer from a 32-byte key.
func NewAEADSealer(key []byte) (*AEADSealer, error) {
if len(key) != KeySize {
return nil, fmt.Errorf("auth: TOTP encryption key must be %d bytes, got %d", KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("auth: new cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("auth: new GCM: %w", err)
}
return &AEADSealer{aead: gcm}, nil
}
// Seal encrypts plaintext.
func (s *AEADSealer) Seal(plaintext []byte) ([]byte, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("auth: seal nonce: %w", err)
}
// Seal appends to nonce, producing nonce || ciphertext||tag.
return s.aead.Seal(nonce, nonce, plaintext, nil), nil
}
// Open decrypts a blob produced by Seal.
func (s *AEADSealer) Open(ciphertext []byte) ([]byte, error) {
ns := s.aead.NonceSize()
if len(ciphertext) < ns {
return nil, ErrInvalidCiphertext
}
nonce, ct := ciphertext[:ns], ciphertext[ns:]
plaintext, err := s.aead.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidCiphertext, err)
}
return plaintext, nil
}
var _ Sealer = (*AEADSealer)(nil)