MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
)
|
||||
|
||||
// Encrypt wraps entity's canonical bytes as CMS EnvelopedData addressed to
|
||||
// recipients, per RFC 8551 application/pkcs7-mime; smime-type=enveloped-data. Pass
|
||||
// every recipient's certificate, including the sender's own, so a copy kept in Sent
|
||||
// stays readable.
|
||||
func Encrypt(entity Entity, recipients []*x509.Certificate) (Entity, error) {
|
||||
if len(recipients) == 0 {
|
||||
return Entity{}, errors.New("smime: no recipient certificates provided")
|
||||
}
|
||||
envelopedDER, err := pkcs7.Encrypt(entity.bytes(), recipients)
|
||||
if err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: encrypt: %w", err)
|
||||
}
|
||||
return Entity{
|
||||
Headers: []string{
|
||||
`Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"`,
|
||||
"Content-Transfer-Encoding: base64",
|
||||
`Content-Disposition: attachment; filename="smime.p7m"`,
|
||||
},
|
||||
Body: []byte(wrapBase64(envelopedDER)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Decrypt reverses Encrypt, returning the inner MIME entity that was originally
|
||||
// wrapped.
|
||||
func Decrypt(entity Entity, cert *x509.Certificate, key crypto.PrivateKey) (Entity, error) {
|
||||
ct := HeaderValue(entity.Headers, "Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mediaType != "application/pkcs7-mime" {
|
||||
return Entity{}, errors.New("smime: not an application/pkcs7-mime message")
|
||||
}
|
||||
if st := params["smime-type"]; st != "" && !strings.EqualFold(st, "enveloped-data") {
|
||||
return Entity{}, fmt.Errorf("smime: unsupported smime-type %q", st)
|
||||
}
|
||||
|
||||
raw := entity.Body
|
||||
if isBase64CTE(HeaderValue(entity.Headers, "Content-Transfer-Encoding")) {
|
||||
if raw, err = decodeBase64(entity.Body); err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: decode envelope: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
p7, err := pkcs7.Parse(raw)
|
||||
if err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: parse envelope: %w", err)
|
||||
}
|
||||
plaintext, err := p7.Decrypt(cert, key)
|
||||
if err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: decrypt: %w", err)
|
||||
}
|
||||
return parseEntity(plaintext)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Entity is a MIME entity: its own part-level headers (Content-Type,
|
||||
// Content-Transfer-Encoding, Content-Disposition — never envelope headers like
|
||||
// From/To/Subject/Date) plus its body. Sign/Encrypt/Decrypt/VerifySigned all operate
|
||||
// on an Entity, not a flat raw RFC822 message — the caller (webui's compose/read
|
||||
// handlers) is responsible for keeping envelope headers separate, since S/MIME only
|
||||
// ever transforms the message body's own MIME entity, never the envelope.
|
||||
type Entity struct {
|
||||
Headers []string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// bytes renders the entity as it would appear on the wire: headers, a blank line,
|
||||
// then the body normalized to CRLF line endings — MIME's canonical form, which is
|
||||
// what gets hashed/signed/encrypted. Both Sign and Encrypt must operate on exactly
|
||||
// this rendering so a receiving client's own canonicalization matches ours.
|
||||
func (e Entity) bytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
for _, h := range e.Headers {
|
||||
buf.WriteString(h)
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
buf.WriteString("\r\n")
|
||||
buf.Write(toCRLF(e.Body))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// parseEntity splits raw bytes (headers, a blank line, then body) back into an
|
||||
// Entity — used to recover the inner MIME entity after Decrypt or the signed part
|
||||
// after VerifySigned, both of which hand back a full "headers+body" byte blob.
|
||||
func parseEntity(raw []byte) (Entity, error) {
|
||||
idx := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
sep := 4
|
||||
if idx < 0 {
|
||||
idx = bytes.Index(raw, []byte("\n\n"))
|
||||
sep = 2
|
||||
}
|
||||
if idx < 0 {
|
||||
return Entity{Body: raw}, nil
|
||||
}
|
||||
var headers []string
|
||||
for _, line := range strings.Split(string(raw[:idx]), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, line)
|
||||
}
|
||||
return Entity{Headers: headers, Body: raw[idx+sep:]}, nil
|
||||
}
|
||||
|
||||
// toCRLF normalizes line endings to CRLF — first collapsing any existing CRLF to a
|
||||
// bare LF so a mixed or already-CRLF input doesn't end up double-terminated.
|
||||
func toCRLF(b []byte) []byte {
|
||||
b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
|
||||
return bytes.ReplaceAll(b, []byte("\n"), []byte("\r\n"))
|
||||
}
|
||||
|
||||
// HeaderValue is a case-insensitive lookup over a MIME entity's header lines —
|
||||
// exported so callers outside this package (e.g. webui's read-integration, which
|
||||
// must inspect a message's Content-Type before deciding whether to unwrap it) don't
|
||||
// need to reimplement it.
|
||||
func HeaderValue(headers []string, name string) string {
|
||||
for _, h := range headers {
|
||||
idx := strings.Index(h, ":")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(h[:idx]), name) {
|
||||
return strings.TrimSpace(h[idx+1:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isBase64CTE(cte string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(cte), "base64")
|
||||
}
|
||||
|
||||
func decodeBase64(data []byte) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(stripWhitespace(string(data)))
|
||||
}
|
||||
|
||||
func stripWhitespace(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func newBoundary() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return "----=_SMIME_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// wrapBase64 base64-encodes data at the RFC 2045-recommended 76 characters per line —
|
||||
// cosmetic (a decoder doesn't care), but matches what every real MTA/MUA produces.
|
||||
func wrapBase64(data []byte) string {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := min(i+76, len(encoded))
|
||||
b.WriteString(encoded[i:end])
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\r\n")
|
||||
}
|
||||
|
||||
// splitMultipartRaw extracts each part's *exact* original bytes between boundary
|
||||
// delimiters — deliberately not using mime/multipart.Reader, whose Part API parses
|
||||
// headers away from the raw body and would require re-serializing them to recover
|
||||
// signable bytes. A detached S/MIME signature covers the literal octets of the
|
||||
// signed part (RFC 8551 §3.4.3), so reconstruction-from-parsed-headers risks a
|
||||
// byte-for-byte mismatch (header order, casing, whitespace) that breaks verification
|
||||
// even for semantically-identical content. Real S/MIME implementations extract raw
|
||||
// byte ranges for exactly this reason.
|
||||
func splitMultipartRaw(body []byte, boundary string) ([][]byte, error) {
|
||||
delim := []byte("--" + boundary)
|
||||
segments := bytes.Split(body, delim)
|
||||
if len(segments) < 3 {
|
||||
return nil, errors.New("smime: malformed multipart body")
|
||||
}
|
||||
// segments[0] is the preamble (ignored); the last segment starts with "--" (the
|
||||
// closing delimiter) and anything after is the epilogue (ignored). Everything in
|
||||
// between is one part, each still wrapped in the CRLF that separated it from its
|
||||
// boundary line.
|
||||
parts := make([][]byte, 0, len(segments)-2)
|
||||
for _, seg := range segments[1 : len(segments)-1] {
|
||||
seg = bytes.TrimPrefix(seg, []byte("\r\n"))
|
||||
seg = bytes.TrimSuffix(seg, []byte("\r\n"))
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package smime implements S/MIME certificate-based email signing and encryption:
|
||||
// generating or importing a mailbox's own identity (certificate + private key),
|
||||
// signing outbound mail (RFC 8551 multipart/signed, detached CMS SignedData),
|
||||
// verifying a signature, encrypting outbound mail (application/pkcs7-mime, CMS
|
||||
// EnvelopedData), and decrypting it again.
|
||||
//
|
||||
// This package is deliberately certificate-chain-agnostic: it does not validate a
|
||||
// certificate against any CA trust store. A verified signature here means "this
|
||||
// message was cryptographically signed by the private key matching this exact
|
||||
// certificate," not "this certificate is trusted by a PKI" — the same posture this
|
||||
// codebase's own self-signed TLS certificate already has. Callers that want to
|
||||
// display a warning for unrecognized signers should compare the signer's certificate
|
||||
// against their own address book (see the mailbox_smime_contacts table in
|
||||
// internal/db), not chain validation.
|
||||
package smime
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// go.mozilla.org/pkcs7 defaults ContentEncryptionAlgorithm to legacy DES-CBC for
|
||||
// backward compatibility with old clients — AES-256-GCM is the only acceptable
|
||||
// choice for anything generated here.
|
||||
pkcs7.ContentEncryptionAlgorithm = pkcs7.EncryptionAlgorithmAES256GCM
|
||||
}
|
||||
|
||||
// DefaultValidity mirrors a typical S/MIME certificate lifetime (1 year), matching
|
||||
// what most CAs issue for individual email certificates.
|
||||
const DefaultValidity = 365 * 24 * time.Hour
|
||||
|
||||
// GenerateSelfSigned creates a fresh RSA-2048 keypair and a self-signed certificate
|
||||
// scoped to email — same key size internal/tlsutil already uses for the server's own
|
||||
// TLS certificate. KeyUsage/ExtKeyUsage/EmailAddresses are set per RFC 8551 so
|
||||
// mainstream mail clients recognize it as a valid S/MIME certificate.
|
||||
func GenerateSelfSigned(email string, validity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("smime: generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("smime: generate serial: %w", err)
|
||||
}
|
||||
subject := pkix.Name{CommonName: email}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: subject,
|
||||
Issuer: subject,
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(validity),
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
|
||||
EmailAddresses: []string{email},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("smime: create certificate: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("smime: marshal private key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ImportPKCS12 parses a .p12/.pfx bundle — the usual export format from a CA or
|
||||
// another mail client — into a certificate + private key. Only RSA keys are
|
||||
// supported (the only key type go.mozilla.org/pkcs7's encrypt/decrypt operations
|
||||
// actually support for key transport).
|
||||
func ImportPKCS12(data []byte, password string) (certPEM, keyPEM []byte, err error) {
|
||||
priv, cert, err := pkcs12.Decode(data, password)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("smime: decode PKCS#12: %w", err)
|
||||
}
|
||||
rsaKey, ok := priv.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("smime: only RSA keys are supported")
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(rsaKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("smime: marshal private key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// ParseCertPEM decodes a stored certificate back into a usable *x509.Certificate.
|
||||
func ParseCertPEM(certPEM []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("smime: invalid certificate PEM")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// ParseKeyPEM decodes a stored private key back into a usable crypto.PrivateKey.
|
||||
// Tries PKCS#8 first (what GenerateSelfSigned/ImportPKCS12 both produce), falling
|
||||
// back to PKCS#1 for a hand-imported PEM that used the older format.
|
||||
func ParseKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode(keyPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("smime: invalid private key PEM")
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("smime: unrecognized private key format")
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
|
||||
"go.mozilla.org/pkcs7"
|
||||
)
|
||||
|
||||
// Sign wraps entity in RFC 8551 multipart/signed: part 1 is the entity's own
|
||||
// canonical bytes (unmodified — this is what the signature covers), part 2 is a
|
||||
// detached CMS SignedData over those same bytes.
|
||||
func Sign(entity Entity, cert *x509.Certificate, key crypto.PrivateKey) (Entity, error) {
|
||||
content := entity.bytes()
|
||||
|
||||
sd, err := pkcs7.NewSignedData(content)
|
||||
if err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: sign: %w", err)
|
||||
}
|
||||
sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
|
||||
if err := sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{}); err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: sign: %w", err)
|
||||
}
|
||||
sd.Detach()
|
||||
sigDER, err := sd.Finish()
|
||||
if err != nil {
|
||||
return Entity{}, fmt.Errorf("smime: sign: %w", err)
|
||||
}
|
||||
|
||||
boundary := newBoundary()
|
||||
body := make([]byte, 0, len(content)+len(sigDER)*2)
|
||||
body = append(body, []byte("--"+boundary+"\r\n")...)
|
||||
body = append(body, content...)
|
||||
body = append(body, []byte("\r\n--"+boundary+"\r\n")...)
|
||||
body = append(body, []byte("Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")...)
|
||||
body = append(body, []byte("Content-Transfer-Encoding: base64\r\n")...)
|
||||
body = append(body, []byte("Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")...)
|
||||
body = append(body, []byte(wrapBase64(sigDER))...)
|
||||
body = append(body, []byte("\r\n--"+boundary+"--\r\n")...)
|
||||
|
||||
return Entity{
|
||||
Headers: []string{
|
||||
fmt.Sprintf(`Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256; boundary="%s"`, boundary),
|
||||
},
|
||||
Body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifySigned parses a multipart/signed entity produced by Sign (or any RFC
|
||||
// 8551-compliant sender), checks the detached signature against the exact original
|
||||
// bytes of part 1, and returns that inner entity plus the signer's certificate. On a
|
||||
// signature mismatch it still returns the inner entity — so a tampered or
|
||||
// unverifiable message can be shown with a warning rather than hidden — alongside a
|
||||
// non-nil error and a nil signer.
|
||||
func VerifySigned(entity Entity) (inner Entity, signer *x509.Certificate, err error) {
|
||||
ct := HeaderValue(entity.Headers, "Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mediaType != "multipart/signed" {
|
||||
return Entity{}, nil, errors.New("smime: not a multipart/signed message")
|
||||
}
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return Entity{}, nil, errors.New("smime: missing multipart boundary")
|
||||
}
|
||||
parts, err := splitMultipartRaw(entity.Body, boundary)
|
||||
if err != nil || len(parts) < 2 {
|
||||
return Entity{}, nil, errors.New("smime: malformed signed message")
|
||||
}
|
||||
signedContent := parts[0]
|
||||
|
||||
sigPart, err := parseEntity(parts[1])
|
||||
if err != nil {
|
||||
return Entity{}, nil, err
|
||||
}
|
||||
sigDER := sigPart.Body
|
||||
if isBase64CTE(HeaderValue(sigPart.Headers, "Content-Transfer-Encoding")) {
|
||||
if sigDER, err = decodeBase64(sigPart.Body); err != nil {
|
||||
return Entity{}, nil, fmt.Errorf("smime: decode signature: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
p7, err := pkcs7.Parse(sigDER)
|
||||
if err != nil {
|
||||
return Entity{}, nil, fmt.Errorf("smime: parse signature: %w", err)
|
||||
}
|
||||
p7.Content = signedContent
|
||||
|
||||
inner, perr := parseEntity(signedContent)
|
||||
if perr != nil {
|
||||
return Entity{}, nil, perr
|
||||
}
|
||||
if err := p7.Verify(); err != nil {
|
||||
return inner, nil, fmt.Errorf("smime: signature verification failed: %w", err)
|
||||
}
|
||||
return inner, p7.GetOnlySigner(), nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package smime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testIdentity(t *testing.T, email string) (*x509.Certificate, crypto.PrivateKey) {
|
||||
t.Helper()
|
||||
certPEM, keyPEM, err := GenerateSelfSigned(email, DefaultValidity)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSelfSigned: %v", err)
|
||||
}
|
||||
cert, err := ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCertPEM: %v", err)
|
||||
}
|
||||
key, err := ParseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseKeyPEM: %v", err)
|
||||
}
|
||||
return cert, key
|
||||
}
|
||||
|
||||
// testEntity uses CRLF line endings already, since Sign/Encrypt canonicalize the
|
||||
// body to CRLF (MIME's wire form) before signing/encrypting — a round trip through
|
||||
// either normalizes bare LF to CRLF, so tests compare against the canonical form.
|
||||
func testEntity() Entity {
|
||||
return Entity{
|
||||
Headers: []string{"Content-Type: text/plain; charset=utf-8"},
|
||||
Body: []byte("hello world\r\nsecond line\r\n"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
cert, key := testIdentity(t, "alice@example.com")
|
||||
orig := testEntity()
|
||||
|
||||
signed, err := Sign(orig, cert, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if ct := HeaderValue(signed.Headers, "Content-Type"); !strings.HasPrefix(ct, "multipart/signed") {
|
||||
t.Fatalf("unexpected Content-Type: %q", ct)
|
||||
}
|
||||
|
||||
inner, signer, err := VerifySigned(signed)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer == nil || signer.Subject.CommonName != "alice@example.com" {
|
||||
t.Fatalf("unexpected signer: %+v", signer)
|
||||
}
|
||||
if !bytes.Equal(inner.Body, orig.Body) {
|
||||
t.Fatalf("body mismatch: got %q want %q", inner.Body, orig.Body)
|
||||
}
|
||||
if HeaderValue(inner.Headers, "Content-Type") != HeaderValue(orig.Headers, "Content-Type") {
|
||||
t.Fatalf("header mismatch: got %v want %v", inner.Headers, orig.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySignedDetectsTampering(t *testing.T) {
|
||||
cert, key := testIdentity(t, "alice@example.com")
|
||||
signed, err := Sign(testEntity(), cert, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
tampered := string(signed.Body)
|
||||
tampered = strings.Replace(tampered, "hello world", "hello WORLD", 1)
|
||||
signed.Body = []byte(tampered)
|
||||
|
||||
inner, signer, err := VerifySigned(signed)
|
||||
if err == nil {
|
||||
t.Fatal("expected verification error for tampered content, got nil")
|
||||
}
|
||||
if signer != nil {
|
||||
t.Fatalf("expected nil signer on failed verification, got %+v", signer)
|
||||
}
|
||||
// The tampered body should still come back for display purposes even though
|
||||
// verification failed.
|
||||
if !bytes.Contains(inner.Body, []byte("hello WORLD")) {
|
||||
t.Fatalf("expected tampered body returned alongside the error, got %q", inner.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySignedWrongSignerCert(t *testing.T) {
|
||||
cert, key := testIdentity(t, "alice@example.com")
|
||||
other, _ := testIdentity(t, "mallory@example.com")
|
||||
|
||||
signed, err := Sign(testEntity(), cert, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
_, signer, err := VerifySigned(signed)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer.Subject.CommonName == other.Subject.CommonName {
|
||||
t.Fatal("signer should not match an unrelated certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
cert, key := testIdentity(t, "bob@example.com")
|
||||
orig := testEntity()
|
||||
|
||||
encrypted, err := Encrypt(orig, []*x509.Certificate{cert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
if ct := HeaderValue(encrypted.Headers, "Content-Type"); !strings.HasPrefix(ct, "application/pkcs7-mime") {
|
||||
t.Fatalf("unexpected Content-Type: %q", ct)
|
||||
}
|
||||
if bytes.Contains(encrypted.Body, orig.Body) {
|
||||
t.Fatal("encrypted body should not contain the plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, cert, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decrypted.Body, orig.Body) {
|
||||
t.Fatalf("body mismatch: got %q want %q", decrypted.Body, orig.Body)
|
||||
}
|
||||
if HeaderValue(decrypted.Headers, "Content-Type") != HeaderValue(orig.Headers, "Content-Type") {
|
||||
t.Fatalf("header mismatch: got %v want %v", decrypted.Headers, orig.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongKeyFails(t *testing.T) {
|
||||
cert, _ := testIdentity(t, "bob@example.com")
|
||||
otherCert, otherKey := testIdentity(t, "mallory@example.com")
|
||||
|
||||
encrypted, err := Encrypt(testEntity(), []*x509.Certificate{cert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
if _, err := Decrypt(encrypted, otherCert, otherKey); err == nil {
|
||||
t.Fatal("expected decryption with the wrong key to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptMultipleRecipientsBothCanDecrypt(t *testing.T) {
|
||||
senderCert, senderKey := testIdentity(t, "alice@example.com")
|
||||
recipCert, recipKey := testIdentity(t, "bob@example.com")
|
||||
orig := testEntity()
|
||||
|
||||
encrypted, err := Encrypt(orig, []*x509.Certificate{senderCert, recipCert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
senderCopy, err := Decrypt(encrypted, senderCert, senderKey)
|
||||
if err != nil {
|
||||
t.Fatalf("sender Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(senderCopy.Body, orig.Body) {
|
||||
t.Fatal("sender's own copy did not decrypt to the original body")
|
||||
}
|
||||
|
||||
recipCopy, err := Decrypt(encrypted, recipCert, recipKey)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(recipCopy.Body, orig.Body) {
|
||||
t.Fatal("recipient's copy did not decrypt to the original body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignThenEncryptNestedRoundTrip covers the "sign and encrypt" compose option:
|
||||
// the plaintext is signed, then the whole signed entity is encrypted (opaque
|
||||
// nesting), matching how webui's compose handler applies both transforms together.
|
||||
func TestSignThenEncryptNestedRoundTrip(t *testing.T) {
|
||||
senderCert, senderKey := testIdentity(t, "alice@example.com")
|
||||
recipCert, recipKey := testIdentity(t, "bob@example.com")
|
||||
orig := testEntity()
|
||||
|
||||
signed, err := Sign(orig, senderCert, senderKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
encrypted, err := Encrypt(signed, []*x509.Certificate{recipCert})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, recipCert, recipKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(HeaderValue(decrypted.Headers, "Content-Type"), "multipart/signed") {
|
||||
t.Fatalf("expected the decrypted layer to still be multipart/signed, got %q", HeaderValue(decrypted.Headers, "Content-Type"))
|
||||
}
|
||||
|
||||
inner, signer, err := VerifySigned(decrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySigned: %v", err)
|
||||
}
|
||||
if signer.Subject.CommonName != "alice@example.com" {
|
||||
t.Fatalf("unexpected signer: %+v", signer)
|
||||
}
|
||||
if !bytes.Equal(inner.Body, orig.Body) {
|
||||
t.Fatalf("body mismatch after unwrapping both layers: got %q want %q", inner.Body, orig.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPKCS12RejectsBadPassword(t *testing.T) {
|
||||
if _, _, err := ImportPKCS12([]byte("not a real pkcs12 file"), "whatever"); err == nil {
|
||||
t.Fatal("expected an error decoding garbage PKCS#12 data")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user