164 lines
4.2 KiB
Go
164 lines
4.2 KiB
Go
package sharing
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/mail"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const shareIDLength = 27
|
|
|
|
var (
|
|
base62Alphabet = []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
shareIDPattern = regexp.MustCompile(`^[0-9A-Za-z]{27}$`)
|
|
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
|
)
|
|
|
|
type secureCodec struct {
|
|
aead cipher.AEAD
|
|
rand io.Reader
|
|
}
|
|
|
|
func newSecureCodec(secret string) (*secureCodec, error) {
|
|
secret = strings.TrimSpace(secret)
|
|
if secret == "" {
|
|
return nil, fmt.Errorf("%w: sharing encryption secret is required", ErrInvalidInput)
|
|
}
|
|
key := sha256.Sum256([]byte(secret))
|
|
block, err := aes.NewCipher(key[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
aead, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &secureCodec{aead: aead, rand: rand.Reader}, nil
|
|
}
|
|
|
|
func (c *secureCodec) newShareID() (string, error) {
|
|
result := make([]byte, 0, shareIDLength)
|
|
buffer := make([]byte, 64)
|
|
for len(result) < shareIDLength {
|
|
if _, err := io.ReadFull(c.rand, buffer); err != nil {
|
|
return "", err
|
|
}
|
|
for _, value := range buffer {
|
|
if value >= 248 {
|
|
continue
|
|
}
|
|
result = append(result, base62Alphabet[int(value)%len(base62Alphabet)])
|
|
if len(result) == shareIDLength {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return string(result), nil
|
|
}
|
|
|
|
func (c *secureCodec) encrypt(value string) ([]byte, error) {
|
|
nonce := make([]byte, c.aead.NonceSize())
|
|
if _, err := io.ReadFull(c.rand, nonce); err != nil {
|
|
return nil, err
|
|
}
|
|
return c.aead.Seal(nonce, nonce, []byte(value), nil), nil
|
|
}
|
|
|
|
func (c *secureCodec) decrypt(value []byte) (string, error) {
|
|
if len(value) < c.aead.NonceSize() {
|
|
return "", fmt.Errorf("%w: encrypted share value is invalid", ErrInvalidInput)
|
|
}
|
|
nonce := value[:c.aead.NonceSize()]
|
|
plaintext, err := c.aead.Open(nil, nonce, value[c.aead.NonceSize():], nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
func hashValue(value string) []byte {
|
|
digest := sha256.Sum256([]byte(value))
|
|
return digest[:]
|
|
}
|
|
|
|
func hashID(value []byte) string {
|
|
if len(value) > 16 {
|
|
value = value[:16]
|
|
}
|
|
return hex.EncodeToString(value)
|
|
}
|
|
|
|
func hashesEqual(left []byte, right []byte) bool {
|
|
return len(left) == len(right) && subtle.ConstantTimeCompare(left, right) == 1
|
|
}
|
|
|
|
func validShareID(value string) bool {
|
|
return shareIDPattern.MatchString(value)
|
|
}
|
|
|
|
func normalizeIdentifier(value string) (string, string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "", "", fmt.Errorf("%w: email or phone is required", ErrInvalidInput)
|
|
}
|
|
if strings.Contains(value, "@") {
|
|
address, err := mail.ParseAddress(value)
|
|
if err != nil || !strings.EqualFold(strings.TrimSpace(address.Address), value) {
|
|
return "", "", fmt.Errorf("%w: email is invalid", ErrInvalidInput)
|
|
}
|
|
return "email", strings.ToLower(address.Address), nil
|
|
}
|
|
phone := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(value)
|
|
if !phonePattern.MatchString(phone) {
|
|
return "", "", fmt.Errorf("%w: phone is invalid", ErrInvalidInput)
|
|
}
|
|
return "phone", phone, nil
|
|
}
|
|
|
|
func actorIdentifiers(actor Actor) []string {
|
|
seen := make(map[string]struct{})
|
|
appendIdentifier := func(values *[]string, value string) {
|
|
kind, normalized, err := normalizeIdentifier(value)
|
|
if err != nil || kind == "" {
|
|
return
|
|
}
|
|
key := kind + ":" + normalized
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
*values = append(*values, key)
|
|
}
|
|
values := make([]string, 0, 3)
|
|
appendIdentifier(&values, actor.Email)
|
|
appendIdentifier(&values, actor.Phone)
|
|
if strings.TrimSpace(actor.CountryCode) != "" && strings.TrimSpace(actor.Phone) != "" {
|
|
countryCode := strings.TrimPrefix(strings.TrimSpace(actor.CountryCode), "+")
|
|
phone := strings.TrimPrefix(strings.TrimSpace(actor.Phone), "+")
|
|
appendIdentifier(&values, "+"+countryCode+phone)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func ActorOwnsIdentifier(actor Actor, identifier string) bool {
|
|
identifierType, normalized, err := normalizeIdentifier(identifier)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
candidateHash := hashValue(identifierType + ":" + normalized)
|
|
for _, actorIdentifier := range actorIdentifiers(actor) {
|
|
if hashesEqual(candidateHash, hashValue(actorIdentifier)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|