355 lines
11 KiB
Go
355 lines
11 KiB
Go
package crypto
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/crypto"
|
|
)
|
|
|
|
// tryDecodeBase64 attempts to decode a base64 string using multiple encodings.
|
|
// It tries URL-safe without padding first (libsodium's URLSAFE_NO_PADDING),
|
|
// then standard base64 with padding, then standard without padding.
|
|
func tryDecodeBase64(s string) ([]byte, error) {
|
|
var lastErr error
|
|
|
|
// Try URL-safe base64 without padding (libsodium's URLSAFE_NO_PADDING)
|
|
if data, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
|
return data, nil
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
// Try standard base64 with padding (Go's default for []byte)
|
|
if data, err := base64.StdEncoding.DecodeString(s); err == nil {
|
|
return data, nil
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
// Try standard base64 without padding
|
|
if data, err := base64.RawStdEncoding.DecodeString(s); err == nil {
|
|
return data, nil
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
// Try URL-safe base64 with padding
|
|
if data, err := base64.URLEncoding.DecodeString(s); err == nil {
|
|
return data, nil
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to decode base64 with any encoding: %w", lastErr)
|
|
}
|
|
|
|
// MasterKey represents the root encryption key for a user
|
|
type MasterKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
}
|
|
|
|
// EncryptedMasterKey is the master key encrypted with the key encryption key
|
|
type EncryptedMasterKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
KeyVersion int `json:"key_version" bson:"key_version"`
|
|
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
|
|
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
|
|
}
|
|
|
|
func (emk *EncryptedMasterKey) GetCurrentVersion() int {
|
|
return emk.KeyVersion
|
|
}
|
|
|
|
func (emk *EncryptedMasterKey) GetKeyByVersion(version int) *EncryptedHistoricalKey {
|
|
if version == emk.KeyVersion {
|
|
// Return current key as historical format
|
|
return &EncryptedHistoricalKey{
|
|
KeyVersion: emk.KeyVersion,
|
|
Ciphertext: emk.Ciphertext,
|
|
Nonce: emk.Nonce,
|
|
Algorithm: crypto.ChaCha20Poly1305Algorithm, // ✅ Updated to ChaCha20-Poly1305
|
|
}
|
|
}
|
|
|
|
for _, key := range emk.PreviousKeys {
|
|
if key.KeyVersion == version {
|
|
return &key
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// KeyEncryptionKey derived from user password
|
|
type KeyEncryptionKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
Salt []byte `json:"salt" bson:"salt"`
|
|
}
|
|
|
|
// PublicKey for asymmetric encryption
|
|
type PublicKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
VerificationID string `json:"verification_id" bson:"verification_id"`
|
|
}
|
|
|
|
// PrivateKey for asymmetric decryption
|
|
type PrivateKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
}
|
|
|
|
// EncryptedPrivateKey is the private key encrypted with the master key
|
|
type EncryptedPrivateKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
}
|
|
|
|
// RecoveryKey for account recovery
|
|
type RecoveryKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
}
|
|
|
|
// EncryptedRecoveryKey is the recovery key encrypted with the master key
|
|
type EncryptedRecoveryKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
}
|
|
|
|
// CollectionKey encrypts files in a collection
|
|
type CollectionKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
CollectionID string `json:"collection_id" bson:"collection_id"`
|
|
}
|
|
|
|
// EncryptedCollectionKey is the collection key encrypted with master key
|
|
type EncryptedCollectionKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
KeyVersion int `json:"key_version" bson:"key_version"`
|
|
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
|
|
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
|
|
}
|
|
|
|
func (eck *EncryptedCollectionKey) NeedsRotation(policy KeyRotationPolicy) bool {
|
|
if eck.RotatedAt == nil {
|
|
return true // Never rotated
|
|
}
|
|
|
|
keyAge := time.Since(*eck.RotatedAt)
|
|
return keyAge > policy.MaxKeyAge
|
|
}
|
|
|
|
// MarshalJSON custom marshaller for EncryptedCollectionKey to serialize bytes as base64 strings.
|
|
func (eck *EncryptedCollectionKey) MarshalJSON() ([]byte, error) {
|
|
type Alias struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
KeyVersion int `json:"key_version"`
|
|
}
|
|
alias := Alias{
|
|
Ciphertext: base64.StdEncoding.EncodeToString(eck.Ciphertext),
|
|
Nonce: base64.StdEncoding.EncodeToString(eck.Nonce),
|
|
KeyVersion: eck.KeyVersion,
|
|
}
|
|
return json.Marshal(alias)
|
|
}
|
|
|
|
// UnmarshalJSON custom unmarshaller for EncryptedCollectionKey to handle URL-safe base64 strings.
|
|
func (eck *EncryptedCollectionKey) UnmarshalJSON(data []byte) error {
|
|
// Temporary struct to unmarshal into string fields
|
|
type Alias struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
KeyVersion int `json:"key_version"`
|
|
}
|
|
var alias Alias
|
|
|
|
if err := json.Unmarshal(data, &alias); err != nil {
|
|
return fmt.Errorf("failed to unmarshal EncryptedCollectionKey into alias: %w", err)
|
|
}
|
|
|
|
// Set KeyVersion
|
|
eck.KeyVersion = alias.KeyVersion
|
|
|
|
// Decode Ciphertext - try multiple base64 encodings
|
|
if alias.Ciphertext != "" {
|
|
ciphertextBytes, err := tryDecodeBase64(alias.Ciphertext)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode EncryptedCollectionKey.Ciphertext: %w", err)
|
|
}
|
|
eck.Ciphertext = ciphertextBytes
|
|
}
|
|
|
|
// Decode Nonce - try multiple base64 encodings
|
|
if alias.Nonce != "" {
|
|
nonceBytes, err := tryDecodeBase64(alias.Nonce)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode EncryptedCollectionKey.Nonce: %w", err)
|
|
}
|
|
eck.Nonce = nonceBytes
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FileKey encrypts a specific file
|
|
type FileKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
FileID string `json:"file_id" bson:"file_id"`
|
|
}
|
|
|
|
// EncryptedFileKey is the file key encrypted with collection key
|
|
type EncryptedFileKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
KeyVersion int `json:"key_version" bson:"key_version"`
|
|
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
|
|
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
|
|
}
|
|
|
|
func (eck *EncryptedFileKey) NeedsRotation(policy KeyRotationPolicy) bool {
|
|
if eck.RotatedAt == nil {
|
|
return true // Never rotated
|
|
}
|
|
|
|
keyAge := time.Since(*eck.RotatedAt)
|
|
return keyAge > policy.MaxKeyAge
|
|
}
|
|
|
|
// MarshalJSON custom marshaller for EncryptedFileKey to serialize bytes as base64 strings.
|
|
func (efk *EncryptedFileKey) MarshalJSON() ([]byte, error) {
|
|
type Alias struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
KeyVersion int `json:"key_version"`
|
|
}
|
|
alias := Alias{
|
|
Ciphertext: base64.StdEncoding.EncodeToString(efk.Ciphertext),
|
|
Nonce: base64.StdEncoding.EncodeToString(efk.Nonce),
|
|
KeyVersion: efk.KeyVersion,
|
|
}
|
|
return json.Marshal(alias)
|
|
}
|
|
|
|
// UnmarshalJSON custom unmarshaller for EncryptedFileKey to handle URL-safe base64 strings.
|
|
func (efk *EncryptedFileKey) UnmarshalJSON(data []byte) error {
|
|
// Temporary struct to unmarshal into string fields
|
|
type Alias struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
KeyVersion int `json:"key_version"`
|
|
}
|
|
var alias Alias
|
|
|
|
if err := json.Unmarshal(data, &alias); err != nil {
|
|
return fmt.Errorf("failed to unmarshal EncryptedFileKey into alias: %w", err)
|
|
}
|
|
|
|
// Set KeyVersion
|
|
efk.KeyVersion = alias.KeyVersion
|
|
|
|
// Decode Ciphertext - try multiple base64 encodings
|
|
if alias.Ciphertext != "" {
|
|
ciphertextBytes, err := tryDecodeBase64(alias.Ciphertext)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode EncryptedFileKey.Ciphertext: %w", err)
|
|
}
|
|
efk.Ciphertext = ciphertextBytes
|
|
}
|
|
|
|
// Decode Nonce - try multiple base64 encodings
|
|
if alias.Nonce != "" {
|
|
nonceBytes, err := tryDecodeBase64(alias.Nonce)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode EncryptedFileKey.Nonce: %w", err)
|
|
}
|
|
efk.Nonce = nonceBytes
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TagKey encrypts tag data (name and color)
|
|
type TagKey struct {
|
|
Key []byte `json:"key" bson:"key"`
|
|
TagID string `json:"tag_id" bson:"tag_id"`
|
|
}
|
|
|
|
// EncryptedTagKey is the tag key encrypted with user's master key
|
|
type EncryptedTagKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
KeyVersion int `json:"key_version" bson:"key_version"`
|
|
RotatedAt *time.Time `json:"rotated_at,omitempty" bson:"rotated_at,omitempty"`
|
|
PreviousKeys []EncryptedHistoricalKey `json:"previous_keys,omitempty" bson:"previous_keys,omitempty"`
|
|
}
|
|
|
|
func (etk *EncryptedTagKey) NeedsRotation(policy KeyRotationPolicy) bool {
|
|
if etk.RotatedAt == nil {
|
|
return true // Never rotated
|
|
}
|
|
|
|
keyAge := time.Since(*etk.RotatedAt)
|
|
return keyAge > policy.MaxKeyAge
|
|
}
|
|
|
|
// MarshalJSON custom marshaller for EncryptedTagKey to serialize bytes as base64 strings.
|
|
func (etk *EncryptedTagKey) MarshalJSON() ([]byte, error) {
|
|
type Alias struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
KeyVersion int `json:"key_version"`
|
|
}
|
|
alias := Alias{
|
|
Ciphertext: base64.StdEncoding.EncodeToString(etk.Ciphertext),
|
|
Nonce: base64.StdEncoding.EncodeToString(etk.Nonce),
|
|
KeyVersion: etk.KeyVersion,
|
|
}
|
|
return json.Marshal(alias)
|
|
}
|
|
|
|
// UnmarshalJSON custom unmarshaller for EncryptedTagKey to handle URL-safe base64 strings.
|
|
func (etk *EncryptedTagKey) UnmarshalJSON(data []byte) error {
|
|
// Temporary struct to unmarshal into string fields
|
|
type Alias struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
KeyVersion int `json:"key_version"`
|
|
}
|
|
var alias Alias
|
|
|
|
if err := json.Unmarshal(data, &alias); err != nil {
|
|
return fmt.Errorf("failed to unmarshal EncryptedTagKey into alias: %w", err)
|
|
}
|
|
|
|
// Set KeyVersion
|
|
etk.KeyVersion = alias.KeyVersion
|
|
|
|
// Decode Ciphertext - try multiple base64 encodings
|
|
if alias.Ciphertext != "" {
|
|
ciphertextBytes, err := tryDecodeBase64(alias.Ciphertext)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode EncryptedTagKey.Ciphertext: %w", err)
|
|
}
|
|
etk.Ciphertext = ciphertextBytes
|
|
}
|
|
|
|
// Decode Nonce - try multiple base64 encodings
|
|
if alias.Nonce != "" {
|
|
nonceBytes, err := tryDecodeBase64(alias.Nonce)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decode EncryptedTagKey.Nonce: %w", err)
|
|
}
|
|
etk.Nonce = nonceBytes
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MasterKeyEncryptedWithRecoveryKey allows account recovery
|
|
type MasterKeyEncryptedWithRecoveryKey struct {
|
|
Ciphertext []byte `json:"ciphertext" bson:"ciphertext"`
|
|
Nonce []byte `json:"nonce" bson:"nonce"`
|
|
}
|