Skip to content

Bug: sc secrets add/hide fails on UTF-8 secret files — "crypto/rsa: message too long for RSA key size" #318

Description

@universe-ops

Summary

sc secrets add / sc secrets hide fail to encrypt a secret file when the file
contains enough multi-byte UTF-8 characters (emoji, box-drawing , arrows
→ ↔, em-dash , Cyrillic, CJK, accented letters) clustered together. The
command aborts with:

Error executing command: failed to re-encrypt all secrets:
  failed to encrypt secret file: "<path>" with publicKey "ssh-rsa AAAAB3N":
  failed to encrypt secret: crypto/rsa: message too long for RSA key size

The failure is data-dependent and intermittent: an ASCII-only file of the
same size encrypts fine; a file with a dense run of multi-byte characters fails.
This makes it confusing to diagnose (it looks like a key-size problem, but all
keys are 2048-bit and the file is far smaller than any RSA limit).

Impact

  • Any secret file with non-ASCII content (comments with box-drawing/arrows,
    emoji in values, Cyrillic/CJK text) can become un-encryptable.
  • Severity: medium. Blocks adding/rotating affected secrets; no data loss.
  • Real case: a host_vars file with section-header comments built from and
    → ↔ — characters (worst 128-rune window = 260 bytes) failed; the same file
    with those replaced by ASCII (-, ->, <->, --) encrypted fine.

Root cause

pkg/api/secrets/ciphers/encryption.go, EncryptLargeString (≈ line 147):

func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) {
    if rsaKey, ok := key.(*rsa.PublicKey); ok {
        chunks := lo.ChunkString(s, rsaKey.Size()/2)          // <-- (1) chunk size in RUNES
        for idx, chunk := range chunks {
            encryptedData, err := rsa.EncryptOAEP(
                sha256.New(), rand.Reader, rsaKey, []byte(chunk), nil)   // <-- (2) OAEP byte limit
            ...
        }
    }
}

Two compounding issues:

  1. Chunking is by runes, the limit is in bytes. lo.ChunkString splits the
    string by rune count (it operates on []rune). The chunk is then converted
    back with []byte(chunk). For multi-byte UTF-8, the byte length of a chunk can
    be up to 4× its rune count.

  2. The chunk size rsaKey.Size()/2 is not a safe OAEP message size.
    RSA-OAEP can encrypt at most k − 2·hLen − 2 bytes, where k = modulus size
    in bytes and hLen = hash output size. With SHA-256 (hLen = 32) and a
    2048-bit key (k = 256): max = 256 − 64 − 2 = 190 bytes.
    The code uses rsaKey.Size()/2 = 128 as the chunk size.

    • For ASCII: 128 runes = 128 bytes ≤ 190 → OK (which is why it usually works).
    • For multi-byte UTF-8: a 128-rune chunk can be 129…512 bytes. As soon as
      one chunk exceeds 190 bytes, rsa.EncryptOAEP returns
      crypto/rsa: message too long for RSA key size.

So Size()/2 only happens to be safe for ASCII; it is unsafe in general
because (a) it counts runes not bytes and (b) Size()/2 > the true OAEP limit
once content is multi-byte.

Verification (math + measured)

  • 2048-bit key: OAEP-SHA256 limit = 190 bytes; code chunk size = 128.
  • Measured worst-case 128-rune window (bytes):
    • ASCII-heavy host_vars: 160 B, 168 B → encrypt OK.
    • Box-drawing-heavy host_vars: 260 B → encrypt FAILS.

Reproduction

# A file whose 128-rune window exceeds 190 bytes when UTF-8 encoded.
printf '# %s\n' "$(python3 -c 'print("─"*120)')" > /tmp/secret.txt   # 120x U+2500 = 360 bytes
sc secrets add /tmp/secret.txt
# -> crypto/rsa: message too long for RSA key size

Proposed fix

Chunk by bytes with a size that respects the OAEP limit, and operate on the
byte slice instead of runes. Splitting a multi-byte character across chunk
boundaries is safe here because DecryptLargeString concatenates the decrypted
byte chunks back exactly (strings.Join of the per-chunk strings reproduces the
original byte sequence; Go strings are byte sequences, so an invalid-UTF-8
fragment in one chunk is harmless once joined).

func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) {
    if rsaKey, ok := key.(*rsa.PublicKey); ok {
        // RSA-OAEP max message = k - 2*hLen - 2 (SHA-256 => hLen=32). Leave a
        // small safety margin.
        maxPlain := rsaKey.Size() - 2*sha256.Size() - 2   // 190 for 2048-bit
        if maxPlain <= 0 {
            return nil, errors.Errorf("RSA key too small (%d bits) for OAEP-SHA256", rsaKey.Size()*8)
        }
        data := []byte(s)
        res := make([]string, 0, (len(data)+maxPlain-1)/maxPlain)
        for i := 0; i < len(data); i += maxPlain {
            end := i + maxPlain
            if end > len(data) {
                end = len(data)
            }
            enc, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaKey, data[i:end], nil)
            if err != nil {
                return nil, errors.Wrap(err, "failed to encrypt secret")
            }
            res = append(res, base64.StdEncoding.EncodeToString(enc))
        }
        return res, nil
    }
    // ... ed25519 branch unchanged ...
}

DecryptLargeString needs no change (it already decodes + RSA-decrypts each
chunk and joins the resulting bytes).

Backward compatibility

Existing secrets stored with the old rune-based chunking still decrypt correctly
(decryption is chunk-list driven and chunk-size agnostic). Only re-encryption
(add/hide) produces the new byte-based chunks. A --force re-encrypt of the whole
store after the fix is safe.

Alternative (recommended longer term)

Switch the RSA path to hybrid encryption, mirroring the existing ed25519
path (encryptWithEd25519): generate a random symmetric key, encrypt the file
once with AES-GCM / ChaCha20-Poly1305, and RSA-OAEP-encrypt only the small
symmetric key. This eliminates chunking entirely, is faster, and removes this
whole class of size bug.

Secondary note (latent inconsistency)

EncryptWithPublicRSAKey (same file, ≈ line 86) does a single-shot
rsa.EncryptOAEP with SHA-512 (hLen=64 → max 126 bytes for a 2048-bit key)
and no chunking, whereas EncryptLargeString uses SHA-256 + chunking. If any
caller routes large data through EncryptWithPublicRSAKey, it will fail for
anything > 126 bytes. Consider standardizing the hash and always going through
the chunked/hybrid path.

Affected file

  • pkg/api/secrets/ciphers/encryption.goEncryptLargeString (chunking),
    and EncryptWithPublicRSAKey (hash inconsistency).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions