Files
patrickandClaude Sonnet 5 d0d9c9ba27
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
FDN-22: gofmt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:45:12 +02:00

105 lines
3.0 KiB
Go

// Package textcrypt verschluesselt Textkopien von Dokumentinhalten (OCR-Text,
// Klartext-Extrakt, E-Rechnungs-Suchtext, FDN-22). Je Revision gibt es einen
// DEK, verpackt mit dem Mandanten-KEK; jeder Text ist per Additional Data an
// Revision und Seite gebunden. Es gibt keinen Klartext-Rueckfall.
package textcrypt
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
)
const versionV1 byte = 0x01
var (
ErrNoCrypto = errors.New("textcrypt: kein schluesseldienst konfiguriert, text wird nicht im klartext gespeichert oder gelesen")
ErrDecryptText = errors.New("textcrypt: text nicht entschluesselbar (falscher schluessel, fremde revision oder manipulierte daten)")
)
// Vault verbindet den Schluesseldienst mit dem Mandanten.
type Vault struct {
Crypto *dmscrypto.Service
TenantSlug string
}
func (v Vault) ready() error {
if v.Crypto == nil || v.TenantSlug == "" {
return ErrNoCrypto
}
return nil
}
// NewKey erzeugt einen DEK samt verpackter Form.
func (v Vault) NewKey(ctx context.Context) (dek, wrapped []byte, err error) {
if err := v.ready(); err != nil {
return nil, nil, err
}
return v.Crypto.NewDataKey(ctx, v.TenantSlug)
}
// Key entpackt einen gespeicherten DEK.
func (v Vault) Key(ctx context.Context, wrapped []byte) ([]byte, error) {
if err := v.ready(); err != nil {
return nil, err
}
if len(wrapped) == 0 {
return nil, ErrDecryptText
}
dek, err := v.Crypto.UnwrapDataKey(ctx, v.TenantSlug, wrapped)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrDecryptText, err)
}
return dek, nil
}
func AADPage(revisionID string, page int) []byte {
return []byte(fmt.Sprintf("ocr-page:%s:%d", revisionID, page))
}
func AADIndex(revisionID string) []byte { return []byte("text-index:" + revisionID) }
func AADInvoice(revisionID string) []byte { return []byte("einvoice-text:" + revisionID) }
func gcm(dek []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(dek)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// Seal liefert Version || Nonce || Chiffretext.
func Seal(dek []byte, aad []byte, text string) ([]byte, error) {
g, err := gcm(dek)
if err != nil {
return nil, fmt.Errorf("textcrypt: cipher: %w", err)
}
nonce := make([]byte, g.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("textcrypt: nonce: %w", err)
}
out := append([]byte{versionV1}, nonce...)
return g.Seal(out, nonce, []byte(text), aad), nil
}
// Open entschluesselt einen mit Seal erzeugten Wert.
func Open(dek []byte, aad []byte, sealed []byte) (string, error) {
g, err := gcm(dek)
if err != nil {
return "", fmt.Errorf("textcrypt: cipher: %w", err)
}
if len(sealed) < 1+g.NonceSize() || sealed[0] != versionV1 {
return "", ErrDecryptText
}
nonce, ct := sealed[1:1+g.NonceSize()], sealed[1+g.NonceSize():]
plain, err := g.Open(nil, nonce, ct, aad)
if err != nil {
return "", ErrDecryptText
}
return string(plain), nil
}