package securechat import ( "crypto/ed25519" "crypto/rand" "crypto/sha512" "crypto/subtle" "encoding/binary" "encoding/hex" "regexp" "sort" "strconv" "strings" "golang.org/x/crypto/blake2b" "golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/nacl/box" ) const ( EnvelopeVersion = 1 Scheme = "sc1" PadBlock = 256 MaxPlaintextBytes = 64 * 1024 domain = "SecureChat|v1" wrapMagic = "SCK1" ) // Envelope es el objeto `encryption` de un mensaje (ADR-022). type Envelope struct { V int `json:"v"` Scheme string `json:"scheme"` KeyVersion int `json:"keyVersion"` ClientMessageID string `json:"clientMessageId"` Nonce string `json:"nonce"` Ciphertext string `json:"ciphertext"` } // Wrap es una envoltura de la clave de conversación: { keyVersion, sealed }. type Wrap struct { KeyVersion int `json:"keyVersion"` Sealed string `json:"sealed"` } // KeyPair X25519 (bytes crudos de 32). type KeyPair struct { PublicKey []byte PrivateKey []byte } // ─── Relleno ISO/IEC 7816-4 ──────────────────────────────────────────────── func PaddedLength(n int) int { return (n + 1 + PadBlock - 1) / PadBlock * PadBlock } func Pad(b []byte) []byte { out := make([]byte, PaddedLength(len(b))) copy(out, b) out[len(b)] = 0x80 return out } func Unpad(b []byte) ([]byte, error) { if len(b) == 0 || len(b)%PadBlock != 0 { return nil, fail(ErrBadPadding, "longitud no múltiplo del bloque") } i := len(b) - 1 for i >= 0 && b[i] == 0 { i-- } if i < 0 || b[i] != 0x80 { return nil, fail(ErrBadPadding, "marcador 0x80 ausente") } if len(b)-i > PadBlock { return nil, fail(ErrBadPadding, "relleno más largo que un bloque") } return b[:i], nil } // MessageAD = SecureChat|v1|msg|||| func MessageAD(conversationID, senderType, clientMessageID string, keyVersion int) (string, error) { if err := assertID(conversationID, "conversationId"); err != nil { return "", err } if senderType != "user" && senderType != "company" { return "", fail(ErrInvalidInput, "senderType inválido: %s", senderType) } if err := assertID(clientMessageID, "clientMessageId"); err != nil { return "", err } if err := assertKeyVersion(keyVersion); err != nil { return "", err } return domain + "|msg|" + conversationID + "|" + senderType + "|" + clientMessageID + "|" + strconv.Itoa(keyVersion), nil } // IdentityKeyPairFromSeed = crypto_box_seed_keypair: sk = SHA-512(seed)[0:32], pk = X25519(sk, 9). func IdentityKeyPairFromSeed(seed []byte) (KeyPair, error) { if err := assertLen(seed, 32, "seed"); err != nil { return KeyPair{}, err } h := sha512.Sum512(seed) sk := append([]byte(nil), h[:32]...) pk, err := curve25519.X25519(sk, curve25519.Basepoint) if err != nil { return KeyPair{}, fail(ErrInvalidInput, "X25519: %v", err) } return KeyPair{PublicKey: pk, PrivateKey: sk}, nil } // GenerateKeyPair genera un par X25519 compatible con crypto_box_keypair. func GenerateKeyPair() (KeyPair, error) { pk, sk, err := box.GenerateKey(rand.Reader) if err != nil { return KeyPair{}, err } return KeyPair{PublicKey: pk[:], PrivateKey: sk[:]}, nil } func to32(b []byte) *[32]byte { var a [32]byte copy(a[:], b) return &a } // ─── Clave de conversación ──────────────────────────────────────────────── // WrapConversationKey = crypto_box_seal("SCK1" ‖ u32be(keyVersion) ‖ K ‖ conversationId, recipientPk). func WrapConversationKey(key []byte, conversationID string, keyVersion int, recipientPublicKey []byte) (Wrap, error) { if err := assertLen(key, 32, "key"); err != nil { return Wrap{}, err } if err := assertID(conversationID, "conversationId"); err != nil { return Wrap{}, err } if err := assertKeyVersion(keyVersion); err != nil { return Wrap{}, err } if err := assertLen(recipientPublicKey, 32, "recipientPublicKey"); err != nil { return Wrap{}, err } payload := make([]byte, 0, 8+32+len(conversationID)) payload = append(payload, wrapMagic...) payload = binary.BigEndian.AppendUint32(payload, uint32(keyVersion)) payload = append(payload, key...) payload = append(payload, conversationID...) sealed, err := box.SealAnonymous(nil, payload, to32(recipientPublicKey), rand.Reader) if err != nil { return Wrap{}, err } return Wrap{KeyVersion: keyVersion, Sealed: B64uEncode(sealed)}, nil } // OpenConversationKey abre una envoltura (crypto_box_seal_open) y comprueba su contexto. // DECRYPT_FAILED: no es para esta llave. KEY_MISMATCH: es para esta llave, pero de otro contexto. func OpenConversationKey(wrapped Wrap, conversationID string, kp KeyPair) ([]byte, error) { if err := assertID(conversationID, "conversationId"); err != nil { return nil, err } if err := assertLen(kp.PublicKey, 32, "publicKey"); err != nil { return nil, err } if err := assertLen(kp.PrivateKey, 32, "privateKey"); err != nil { return nil, err } if err := assertKeyVersion(wrapped.KeyVersion); err != nil { return nil, err } sealed, err := B64uDecode(wrapped.Sealed) if err != nil { return nil, err } payload, ok := box.OpenAnonymous(nil, sealed, to32(kp.PublicKey), to32(kp.PrivateKey)) if !ok { return nil, fail(ErrDecryptFailed, "no se pudo abrir la clave de conversación") } if len(payload) < 4+4+32+1 || string(payload[:4]) != wrapMagic { return nil, fail(ErrKeyMismatch, "envoltura con formato inválido") } if kv := binary.BigEndian.Uint32(payload[4:8]); int64(kv) != int64(wrapped.KeyVersion) { return nil, fail(ErrKeyMismatch, "keyVersion de la envoltura no coincide") } cid := payload[40:] if !validUTF8(cid) { return nil, fail(ErrInvalidInput, "UTF-8 inválido") } if string(cid) != conversationID { return nil, fail(ErrKeyMismatch, "la clave pertenece a otra conversación") } return append([]byte(nil), payload[8:40]...), nil } // OpenWithAnyKey prueba [actual, anterior, …]. Solo pasa a la siguiente llave con DECRYPT_FAILED. func OpenWithAnyKey(wrapped Wrap, conversationID string, companyKeys []KeyPair) ([]byte, error) { if len(companyKeys) == 0 { return nil, fail(ErrInvalidInput, "companyKeys vacío") } var last error for _, kp := range companyKeys { k, err := OpenConversationKey(wrapped, conversationID, kp) if err == nil { return k, nil } if ErrorCode(err) != ErrDecryptFailed { return nil, err } last = err } return nil, last } // ─── Mensajes ────────────────────────────────────────────────────────────── // EncryptMessage cifra texto UTF-8. nonce == nil → aleatorio (lo normal; solo los vectores lo fijan). func EncryptMessage(key []byte, conversationID, senderType, clientMessageID string, keyVersion int, text string, nonce []byte) (Envelope, error) { if err := assertLen(key, 32, "key"); err != nil { return Envelope{}, err } if !validUTF8([]byte(text)) { return Envelope{}, fail(ErrInvalidInput, "el texto no es UTF-8 válido") } if len(text) > MaxPlaintextBytes { return Envelope{}, fail(ErrInvalidInput, "mensaje demasiado largo") } ad, err := MessageAD(conversationID, senderType, clientMessageID, keyVersion) if err != nil { return Envelope{}, err } if nonce == nil { nonce = make([]byte, chacha20poly1305.NonceSizeX) if _, err := rand.Read(nonce); err != nil { return Envelope{}, err } } if err := assertLen(nonce, 24, "nonce"); err != nil { return Envelope{}, err } aead, _ := chacha20poly1305.NewX(key) ct := aead.Seal(nil, nonce, Pad([]byte(text)), []byte(ad)) return Envelope{ V: EnvelopeVersion, Scheme: Scheme, KeyVersion: keyVersion, ClientMessageID: clientMessageID, Nonce: B64uEncode(nonce), Ciphertext: B64uEncode(ct), }, nil } // DecryptMessage: conversationID y senderType los pone el RECEPTOR, nunca se toman del evento. func DecryptMessage(key []byte, conversationID, senderType string, env Envelope) (string, error) { if err := assertLen(key, 32, "key"); err != nil { return "", err } if env.V != EnvelopeVersion || env.Scheme != Scheme { return "", fail(ErrUnsupportedVersion, "sobre v=%d scheme=%s", env.V, env.Scheme) } ad, err := MessageAD(conversationID, senderType, env.ClientMessageID, env.KeyVersion) if err != nil { return "", err } nonce, err := decodeLen(env.Nonce, 24, "nonce") if err != nil { return "", err } ct, err := B64uDecode(env.Ciphertext) if err != nil { return "", err } if len(ct) < 16+PadBlock || (len(ct)-16)%PadBlock != 0 { return "", fail(ErrDecryptFailed, "longitud de ciphertext inválida") } aead, _ := chacha20poly1305.NewX(key) padded, err := aead.Open(nil, nonce, ct, []byte(ad)) if err != nil { return "", fail(ErrDecryptFailed, "autenticación fallida") } plain, err := Unpad(padded) if err != nil { return "", err } if !validUTF8(plain) { return "", fail(ErrInvalidInput, "UTF-8 inválido") } return string(plain), nil } // NewClientMessageID: único por conversación, [A-Za-z0-9_-]. func NewClientMessageID() string { b := make([]byte, 16) _, _ = rand.Read(b) return "srv_" + B64uEncode(b) } // EncryptReply cifra una respuesta de la empresa (senderType "company") con un clientMessageId nuevo. func EncryptReply(conversationKey []byte, conversationID string, keyVersion int, text string) (Envelope, error) { return EncryptMessage(conversationKey, conversationID, "company", NewClientMessageID(), keyVersion, text, nil) } // ─── Huella e invitación ────────────────────────────────────────────────── // KeyFingerprint = BLAKE2b-256("SecureChat|v1|fp|" ‖ publicKey). func KeyFingerprint(publicKey []byte) ([]byte, error) { if err := assertLen(publicKey, 32, "publicKey"); err != nil { return nil, err } h := blake2b.Sum256(append([]byte(domain+"|fp|"), publicKey...)) return h[:], nil } // FormatFingerprint: 16 grupos de 4 hex separados por espacio. func FormatFingerprint(fp []byte) string { h := hex.EncodeToString(fp) parts := make([]string, 0, len(h)/4) for i := 0; i+4 <= len(h); i += 4 { parts = append(parts, h[i:i+4]) } return strings.Join(parts, " ") } // BuildInviteURL añade #s=&fp= al inviteUrl de la API. func BuildInviteURL(inviteURL, inviteSecretB64u string, companyPublicKey []byte) (string, error) { fp, err := KeyFingerprint(companyPublicKey) if err != nil { return "", err } return inviteURL + "#s=" + inviteSecretB64u + "&fp=" + B64uEncode(fp), nil } // NewInviteSecret: 32 bytes aleatorios en base64url. Guárdalo con el conversationId. func NewInviteSecret() string { b := make([]byte, 32) _, _ = rand.Read(b) return B64uEncode(b) } // InviteBindingTag = BLAKE2b-256(key=inviteSecret, "SecureChat|v1|invite-binding||||"). func InviteBindingTag(inviteSecret []byte, conversationID string, userPublicKey []byte, wrapForCompany Wrap) (string, error) { if err := assertLen(inviteSecret, 32, "inviteSecret"); err != nil { return "", err } if err := assertID(conversationID, "conversationId"); err != nil { return "", err } if err := assertLen(userPublicKey, 32, "userPublicKey"); err != nil { return "", err } msg := domain + "|invite-binding|" + conversationID + "|" + B64uEncode(userPublicKey) + "|" + strconv.Itoa(wrapForCompany.KeyVersion) + "|" + wrapForCompany.Sealed h, _ := blake2b.New(32, inviteSecret) h.Write([]byte(msg)) return B64uEncode(h.Sum(nil)), nil } // VerifyInviteBinding devuelve BINDING_INVALID si la llave del usuario no vino de quien tenía el link. func VerifyInviteBinding(tag string, inviteSecret []byte, conversationID string, userPublicKey []byte, wrapForCompany Wrap) error { exp, err := InviteBindingTag(inviteSecret, conversationID, userPublicKey, wrapForCompany) if err != nil { return err } expected, _ := B64uDecode(exp) got, err := B64uDecode(tag) if err != nil { return err } if subtle.ConstantTimeCompare(expected, got) != 1 { return fail(ErrBindingInvalid, "la llave del usuario no está ligada al secreto de la invitación") } return nil } // ─── Firmas Ed25519 ──────────────────────────────────────────────────────── func CompanyKeyStatement(companyID string, keyVersion int, publicKey []byte) ([]byte, error) { if err := assertID(companyID, "companyId"); err != nil { return nil, err } if err := assertKeyVersion(keyVersion); err != nil { return nil, err } if err := assertLen(publicKey, 32, "publicKey"); err != nil { return nil, err } return []byte(domain + "|company-key|" + companyID + "|" + strconv.Itoa(keyVersion) + "|" + B64uEncode(publicKey)), nil } // VerifyCompanyKey: firma de la PLATAFORMA sobre la llave de la empresa (la verifica la app). func VerifyCompanyKey(companyID string, keyVersion int, publicKey []byte, signatureB64u string, platformPublicKey []byte) error { if err := assertLen(platformPublicKey, 32, "platformPublicKey"); err != nil { return err } sig, err := decodeLen(signatureB64u, 64, "signature") if err != nil { return err } stmt, err := CompanyKeyStatement(companyID, keyVersion, publicKey) if err != nil { return err } if !ed25519.Verify(platformPublicKey, stmt, sig) { return fail(ErrSignatureInvalid, "la llave de la empresa no está firmada por la plataforma") } return nil } // Rotation describe el paso de la llave X25519 `from` a `to` (ADR-028). type Rotation struct { CompanyID string FromKeyVersion int FromPublicKey []byte ToKeyVersion int ToPublicKey []byte } // Statement = SecureChat|v1|company-key-rotation||||| func (r Rotation) Statement() ([]byte, error) { if err := assertID(r.CompanyID, "companyId"); err != nil { return nil, err } if err := assertKeyVersion(r.FromKeyVersion); err != nil { return nil, err } if err := assertKeyVersion(r.ToKeyVersion); err != nil { return nil, err } if r.ToKeyVersion <= r.FromKeyVersion { return nil, fail(ErrInvalidInput, "toKeyVersion debe ser mayor que fromKeyVersion") } if err := assertLen(r.FromPublicKey, 32, "fromPublicKey"); err != nil { return nil, err } if err := assertLen(r.ToPublicKey, 32, "toPublicKey"); err != nil { return nil, err } return []byte(domain + "|company-key-rotation|" + r.CompanyID + "|" + strconv.Itoa(r.FromKeyVersion) + "|" + B64uEncode(r.FromPublicKey) + "|" + strconv.Itoa(r.ToKeyVersion) + "|" + B64uEncode(r.ToPublicKey)), nil } // Sign devuelve la rotationSignature (base64url) con la secreta Ed25519 de 64 bytes (signing.key). func (r Rotation) Sign(signingPrivateKey []byte) (string, error) { if err := assertLen(signingPrivateKey, 64, "signingPrivateKey"); err != nil { return "", err } stmt, err := r.Statement() if err != nil { return "", err } return B64uEncode(ed25519.Sign(ed25519.PrivateKey(signingPrivateKey), stmt)), nil } // Verify devuelve SIGNATURE_INVALID si no la firmó esa llave de firma de la empresa. func (r Rotation) Verify(signatureB64u string, signingPublicKey []byte) error { if err := assertLen(signingPublicKey, 32, "signingPublicKey"); err != nil { return err } sig, err := decodeLen(signatureB64u, 64, "signature") if err != nil { return err } stmt, err := r.Statement() if err != nil { return err } if !ed25519.Verify(signingPublicKey, stmt, sig) { return fail(ErrSignatureInvalid, "la rotación no está firmada por la llave de firma de la empresa") } return nil } // ─── Lista de agentes de la bandeja web, firmada con tu signing.key ──────── // AgentRosterMaxAgents es el máximo de llaves de agente en una lista. const AgentRosterMaxAgents = 1000 var ( agentKeyIDRe = regexp.MustCompile(`^agk_[A-Za-z0-9_-]{8,64}$`) issuedAtRe = regexp.MustCompile(`^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})Z$`) ) // AgentKey es una llave X25519 de agente con su id (agk_…). type AgentKey struct { AgentKeyID string PublicKey []byte } // AgentRoster es la lista firmada de llaves que pueden recibir la clave de conversación. type AgentRoster struct { CompanyID string RosterVersion int IssuedAt string // YYYY-MM-DDTHH:MM:SSZ exacto Agents []AgentKey } func assertIssuedAt(v string) error { m := issuedAtRe.FindStringSubmatch(v) if m == nil { return fail(ErrInvalidInput, "issuedAt debe ser YYYY-MM-DDTHH:MM:SSZ") } n := make([]int, 6) for i := range n { n[i], _ = strconv.Atoi(m[i+1]) } y, mo, d, h, mi, s := n[0], n[1], n[2], n[3], n[4], n[5] leap := (y%4 == 0 && y%100 != 0) || y%400 == 0 feb := 28 if leap { feb = 29 } days := []int{31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} if mo < 1 || mo > 12 || d < 1 || d > days[mo-1] || h > 23 || mi > 59 || s > 59 { return fail(ErrInvalidInput, "issuedAt no es una fecha válida") } return nil } // Statement = SecureChat|v1|agent-roster||||:,… // con los agentes ordenados por agentKeyId (orden de bytes). func (r AgentRoster) Statement() ([]byte, error) { if err := assertID(r.CompanyID, "companyId"); err != nil { return nil, err } if r.RosterVersion < 1 || r.RosterVersion > 0x7fffffff { return nil, fail(ErrInvalidInput, "rosterVersion debe ser un entero entre 1 y 2^31-1") } if err := assertIssuedAt(r.IssuedAt); err != nil { return nil, err } if len(r.Agents) > AgentRosterMaxAgents { return nil, fail(ErrInvalidInput, "como mucho %d agentes", AgentRosterMaxAgents) } type entry struct{ id, pk string } entries := make([]entry, 0, len(r.Agents)) for _, a := range r.Agents { if !agentKeyIDRe.MatchString(a.AgentKeyID) { return nil, fail(ErrInvalidInput, "agentKeyId debe cumplir agk_[A-Za-z0-9_-]{8,64}") } if err := assertLen(a.PublicKey, 32, "agent.publicKey"); err != nil { return nil, err } entries = append(entries, entry{a.AgentKeyID, B64uEncode(a.PublicKey)}) } sort.Slice(entries, func(i, j int) bool { return entries[i].id < entries[j].id }) seen := map[string]bool{} parts := make([]string, len(entries)) for i, e := range entries { if i > 0 && e.id == entries[i-1].id { return nil, fail(ErrInvalidInput, "agentKeyId repetido") } if seen[e.pk] { return nil, fail(ErrInvalidInput, "llave de agente repetida") } seen[e.pk] = true parts[i] = e.id + ":" + e.pk } return []byte(domain + "|agent-roster|" + r.CompanyID + "|" + strconv.Itoa(r.RosterVersion) + "|" + r.IssuedAt + "|" + strings.Join(parts, ",")), nil } // Sign firma la lista con la secreta Ed25519 de 64 bytes (signing.key). func (r AgentRoster) Sign(signingPrivateKey []byte) (string, error) { if err := assertLen(signingPrivateKey, 64, "signingPrivateKey"); err != nil { return "", err } stmt, err := r.Statement() if err != nil { return "", err } return B64uEncode(ed25519.Sign(ed25519.PrivateKey(signingPrivateKey), stmt)), nil } // Verify devuelve SIGNATURE_INVALID si no la firmó esa llave de firma de la empresa. func (r AgentRoster) Verify(signatureB64u string, signingPublicKey []byte) error { if err := assertLen(signingPublicKey, 32, "signingPublicKey"); err != nil { return err } stmt, err := r.Statement() if err != nil { return err } sig, err := decodeLen(signatureB64u, 64, "signature") if err != nil { return err } if !ed25519.Verify(signingPublicKey, stmt, sig) { return fail(ErrSignatureInvalid, "la lista de agentes no está firmada por la llave de firma de la empresa") } return nil }