============================================================================== lua/bytelocker/core.lua ============================================================================== -- Bytelocker Core Module -- Pure encryption logic with no Neovim dependencies -- This module is independently testable 8 local M = {} -- Constants 8 M.CIPHER_BLOCK_SIZE = 16 8 M.MAGIC_HEADER = "BYTELOCKR" -- 9-byte magic header for encrypted text -- Available cipher methods 8 M.CIPHERS = { 8 shift = { 8 name = "Shift Cipher", 8 description = "Bitwise rotation cipher (original method)" 8 }, 8 xor = { 8 name = "XOR Cipher", 8 description = "XOR-based encryption" 8 }, 8 caesar = { 8 name = "Caesar Cipher", 8 description = "Character shifting cipher" 8 } 8 } -- Bit operations module (injected or auto-detected) 8 local bit_ops = nil -- Initialize bit operations from various sources local function init_bit_ops() 7 if bit_ops then return bit_ops end -- Try LuaJIT's bit module first (used by Neovim) 7 local ok, bit = pcall(require, "bit") 7 if ok then ******0 bit_ops = { band = bit.band, bor = bit.bor, bxor = bit.bxor, lshift = bit.lshift, rshift = bit.rshift, } ******0 return bit_ops end -- Try bit_compat for testing 7 ok, bit = pcall(require, "spec.mocks.bit_compat") 7 if ok then 7 bit_ops = { 7 band = bit.band, 7 bor = bit.bor, 7 bxor = bit.bxor, 7 lshift = bit.lshift, 7 rshift = bit.rshift, 7 } 7 return bit_ops end -- Fallback to Lua 5.3+ native operators ******0 bit_ops = { band = function(a, b) return a & b end, bor = function(a, b) return a | b end, bxor = function(a, b) return a ~ b end, lshift = function(a, n) return (a << n) & 0xFFFFFFFF end, rshift = function(a, n) return (a & 0xFFFFFFFF) >> n end, } ******0 return bit_ops end -- Allow injection of bit operations for testing 8 function M.set_bit_ops(ops) ******0 bit_ops = ops end -- Get bit operations (initializes if needed) local function get_bit_ops() 312088 return bit_ops or init_bit_ops() end -- Helper functions for 8-bit rotations 8 function M.rol8(value, bits) 156707 local ops = get_bit_ops() 156707 value = ops.band(value, 0xFF) 156707 bits = bits % 8 156707 return ops.band(ops.bor(ops.lshift(value, bits), ops.rshift(value, 8 - bits)), 0xFF) end 8 function M.ror8(value, bits) 151541 local ops = get_bit_ops() 151541 value = ops.band(value, 0xFF) 151541 bits = bits % 8 151541 return ops.band(ops.bor(ops.rshift(value, bits), ops.lshift(value, 8 - bits)), 0xFF) end -- Prepare password to 16-byte key 8 function M.prepare_password(password) 970 local prepared = {} 16490 for i = 1, M.CIPHER_BLOCK_SIZE do 15520 local char_code = string.byte(password, ((i - 1) % #password) + 1) 15520 table.insert(prepared, char_code % 256) end 970 return prepared end -- SHIFT CIPHER IMPLEMENTATION 8 function M.shift_encrypt_block(plaintext_block, password) 9160 local encrypted = {} 155720 for i = 1, M.CIPHER_BLOCK_SIZE do 146560 local byte_val = string.byte(plaintext_block, i) or 0 146560 local shift_amount = password[i] % 8 146560 byte_val = M.rol8(byte_val, shift_amount) 146560 table.insert(encrypted, string.char(byte_val)) end 9160 return table.concat(encrypted) end 8 function M.shift_decrypt_block(ciphertext_block, password) 9107 local decrypted = {} 154819 for i = 1, M.CIPHER_BLOCK_SIZE do 145712 local byte_val = string.byte(ciphertext_block, i) or 0 145712 local shift_amount = password[i] % 8 145712 byte_val = M.ror8(byte_val, shift_amount) 145712 table.insert(decrypted, string.char(byte_val)) end 9107 return table.concat(decrypted) end -- XOR CIPHER IMPLEMENTATION -- Uses +1 to prevent password leakage on null input, rotation to mix bits, XOR with key. 8 function M.xor_encrypt_block(plaintext_block, password) 608 local ops = get_bit_ops() 608 local encrypted = {} 10336 for i = 1, M.CIPHER_BLOCK_SIZE do 9728 local byte_val = string.byte(plaintext_block, i) or 0 9728 local key_byte = password[i] -- Add 1 to prevent null input from leaking password 9728 local safe_byte = (byte_val + 1) % 256 -- Rotate by key-dependent amount (1-7 bits) 9728 local rotation = (key_byte % 7) + 1 9728 local rotated = M.rol8(safe_byte, rotation) -- XOR with key 9728 local encrypted_byte = ops.bxor(rotated, key_byte) 9728 table.insert(encrypted, string.char(encrypted_byte)) end 608 return table.concat(encrypted) end 8 function M.xor_decrypt_block(ciphertext_block, password) 343 local ops = get_bit_ops() 343 local decrypted = {} 5831 for i = 1, M.CIPHER_BLOCK_SIZE do 5488 local byte_val = string.byte(ciphertext_block, i) or 0 5488 local key_byte = password[i] 5488 local rotation = (key_byte % 7) + 1 -- Reverse XOR 5488 local rotated = ops.bxor(byte_val, key_byte) -- Reverse rotation 5488 local safe_byte = M.ror8(rotated, rotation) -- Reverse +1 5488 local decrypted_byte = (safe_byte - 1 + 256) % 256 5488 table.insert(decrypted, string.char(decrypted_byte)) end 343 return table.concat(decrypted) end -- CAESAR CIPHER IMPLEMENTATION 8 function M.caesar_encrypt_block(plaintext_block, password) 337 local ops = get_bit_ops() 337 local encrypted = {} 5729 for i = 1, M.CIPHER_BLOCK_SIZE do 5392 local byte_val = string.byte(plaintext_block, i) or 0 5392 local key_byte = password[i] -- XOR first, then shift, to prevent password leakage 5392 local intermediate = ops.bxor(byte_val, key_byte) 5392 local shift = key_byte % 128 5392 local encrypted_byte = (intermediate + shift + 1) % 256 5392 table.insert(encrypted, string.char(encrypted_byte)) end 337 return table.concat(encrypted) end 8 function M.caesar_decrypt_block(ciphertext_block, password) 327 local ops = get_bit_ops() 327 local decrypted = {} 5559 for i = 1, M.CIPHER_BLOCK_SIZE do 5232 local byte_val = string.byte(ciphertext_block, i) or 0 5232 local key_byte = password[i] -- Reverse the safer Caesar encryption 5232 local shift = key_byte % 128 5232 local intermediate = (byte_val - shift - 1 + 256) % 256 5232 local decrypted_byte = ops.bxor(intermediate, key_byte) 5232 table.insert(decrypted, string.char(decrypted_byte)) end 327 return table.concat(decrypted) end -- Cipher method dispatcher 8 function M.encrypt_block(plaintext_block, password, cipher_type) 8980 if cipher_type == "xor" then 53 return M.xor_encrypt_block(plaintext_block, password) 8927 elseif cipher_type == "caesar" then 54 return M.caesar_encrypt_block(plaintext_block, password) else -- default to shift 8873 return M.shift_encrypt_block(plaintext_block, password) end end 8 function M.decrypt_block(ciphertext_block, password, cipher_type) 8927 if cipher_type == "xor" then 49 return M.xor_decrypt_block(ciphertext_block, password) 8878 elseif cipher_type == "caesar" then 49 return M.caesar_decrypt_block(ciphertext_block, password) else -- default to shift 8829 return M.shift_decrypt_block(ciphertext_block, password) end end -- BASE64 ENCODING/DECODING 8 local base64_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 8 function M.base64_encode(data) 677 local ops = get_bit_ops() 677 local result = {} 55007 for i = 1, #data, 3 do 54330 local b1 = string.byte(data, i) or 0 54330 local b2 = string.byte(data, i + 1) or 0 54330 local b3 = string.byte(data, i + 2) or 0 54330 local combined = ops.lshift(b1, 16) + ops.lshift(b2, 8) + b3 54330 local c1 = ops.band(ops.rshift(combined, 18), 0x3F) + 1 54330 local c2 = ops.band(ops.rshift(combined, 12), 0x3F) + 1 54330 local c3 = ops.band(ops.rshift(combined, 6), 0x3F) + 1 54330 local c4 = ops.band(combined, 0x3F) + 1 54330 table.insert(result, base64_chars:sub(c1, c1)) 54330 table.insert(result, base64_chars:sub(c2, c2)) 54330 if i + 1 <= #data then 54105 table.insert(result, base64_chars:sub(c3, c3)) else 225 table.insert(result, '=') end 54330 if i + 2 <= #data then 53787 table.insert(result, base64_chars:sub(c4, c4)) else 543 table.insert(result, '=') end end 677 return table.concat(result) end 8 function M.base64_decode(data) 618 local ops = get_bit_ops() -- Remove whitespace and padding 618 data = data:gsub('%s+', ''):gsub('=+$', '') 618 local result = {} 618 local decode_table = {} -- Build decode table 40170 for i = 1, #base64_chars do 39552 decode_table[base64_chars:sub(i, i)] = i - 1 end 54142 for i = 1, #data, 4 do 53524 local c1 = decode_table[data:sub(i, i)] or 0 53524 local c2 = decode_table[data:sub(i + 1, i + 1)] or 0 53524 local c3 = decode_table[data:sub(i + 2, i + 2)] or 0 53524 local c4 = decode_table[data:sub(i + 3, i + 3)] or 0 53524 local combined = ops.lshift(c1, 18) + ops.lshift(c2, 12) + ops.lshift(c3, 6) + c4 53524 table.insert(result, string.char(ops.band(ops.rshift(combined, 16), 0xFF))) 53524 if i + 2 <= #data then 53317 table.insert(result, string.char(ops.band(ops.rshift(combined, 8), 0xFF))) end 53524 if i + 3 <= #data then 53021 table.insert(result, string.char(ops.band(combined, 0xFF))) end end 618 return table.concat(result) end -- Detection functions 8 function M.is_encrypted(content) 6 if #content == 0 then return false end 5 return string.byte(content, 1) == 0 end 8 function M.is_text_encrypted(text) 18 if #text < #M.MAGIC_HEADER then return false end 15 local header = text:sub(1, #M.MAGIC_HEADER) 15 return header == M.MAGIC_HEADER end 8 function M.is_file_encrypted(content) 23 if #content == 0 then return false end 22 local header = "---BYTELOCKER-ENCRYPTED-FILE---" 22 return content:sub(1, #header) == header end -- Text encryption with magic header 8 function M.encrypt_text_only(content, password, cipher_type) 475 if not content or content == "" then return "" end 472 local ops = get_bit_ops() 472 local prepared_password = M.prepare_password(password) 472 local original_length = #content 944 local length_bytes = string.char( 472 ops.band(ops.rshift(original_length, 24), 0xFF), 472 ops.band(ops.rshift(original_length, 16), 0xFF), 472 ops.band(ops.rshift(original_length, 8), 0xFF), 472 ops.band(original_length, 0xFF) ) 472 local result = {M.MAGIC_HEADER, length_bytes} 9440 for i = 1, #content, M.CIPHER_BLOCK_SIZE do 8968 local block = content:sub(i, i + M.CIPHER_BLOCK_SIZE - 1) 11715 while #block < M.CIPHER_BLOCK_SIZE do 2747 block = block .. string.char(0) end 8968 local encrypted_block = M.encrypt_block(block, prepared_password, cipher_type) 8968 table.insert(result, encrypted_block) end -- Clear prepared password from memory 8024 for i = 1, #prepared_password do 7552 prepared_password[i] = 0 end 472 return table.concat(result) end 8 function M.decrypt_text_only(content, password, cipher_type) 460 if not content or content == "" then return "" end 458 local ops = get_bit_ops() 458 if #content < #M.MAGIC_HEADER + 4 then 3 error("Invalid encrypted text format: too short") end 455 local header = content:sub(1, #M.MAGIC_HEADER) 455 if header ~= M.MAGIC_HEADER then 3 error("Invalid encrypted text format: missing magic header") end 452 local prepared_password = M.prepare_password(password) 452 local length_bytes = content:sub(#M.MAGIC_HEADER + 1, #M.MAGIC_HEADER + 4) local original_length = 904 ops.lshift(string.byte(length_bytes, 1), 24) + 904 ops.lshift(string.byte(length_bytes, 2), 16) + 904 ops.lshift(string.byte(length_bytes, 3), 8) + 452 string.byte(length_bytes, 4) 452 content = content:sub(#M.MAGIC_HEADER + 5) 452 local result = {} 9374 for i = 1, #content, M.CIPHER_BLOCK_SIZE do 8922 local block = content:sub(i, i + M.CIPHER_BLOCK_SIZE - 1) 8922 while #block < M.CIPHER_BLOCK_SIZE do ******0 block = block .. string.char(0) end 8922 local decrypted_block = M.decrypt_block(block, prepared_password, cipher_type) 8922 table.insert(result, decrypted_block) end -- Clear prepared password from memory 7684 for i = 1, #prepared_password do 7232 prepared_password[i] = 0 end 452 local decrypted = table.concat(result) 452 return decrypted:sub(1, original_length) end -- File-safe encryption with base64 and markers 8 function M.encrypt_for_file(content, password, cipher_type) 349 if not content or content == "" then return "" end 344 local binary_encrypted = M.encrypt_text_only(content, password, cipher_type) 344 local base64_encrypted = M.base64_encode(binary_encrypted) 344 local file_header = "---BYTELOCKER-ENCRYPTED-FILE---\n" 344 local file_footer = "\n---END-BYTELOCKER-ENCRYPTED-FILE---" 344 return file_header .. base64_encrypted .. file_footer end 8 function M.decrypt_from_file(content, password, cipher_type) 347 if not content or content == "" then return "" end 341 local header = "---BYTELOCKER-ENCRYPTED-FILE---\n" 341 local footer = "\n---END-BYTELOCKER-ENCRYPTED-FILE---" 341 if not content:match("^" .. header:gsub("%-", "%%-")) then 4 error("Invalid encrypted file format: missing header") end 337 if not content:match(footer:gsub("%-", "%%-") .. "$") then 2 error("Invalid encrypted file format: missing footer") end 335 local base64_content = content:sub(#header + 1, -(#footer + 1)) 335 local success, binary_encrypted = pcall(M.base64_decode, base64_content) 335 if not success then ******0 error("Invalid encrypted file format: corrupted base64 data") end 335 return M.decrypt_text_only(binary_encrypted, password, cipher_type) end -- Binary content encryption (original format with null byte marker) 8 function M.encrypt_content(content, password, cipher_type) ******0 local ops = get_bit_ops() ******0 local prepared_password = M.prepare_password(password) -- Store original length in first 4 bytes after null marker ******0 local original_length = #content ******0 local length_bytes = string.char( ******0 ops.band(ops.rshift(original_length, 24), 0xFF), ******0 ops.band(ops.rshift(original_length, 16), 0xFF), ******0 ops.band(ops.rshift(original_length, 8), 0xFF), ******0 ops.band(original_length, 0xFF) ) ******0 local result = {string.char(0), length_bytes} ******0 for i = 1, #content, M.CIPHER_BLOCK_SIZE do ******0 local block = content:sub(i, i + M.CIPHER_BLOCK_SIZE - 1) ******0 while #block < M.CIPHER_BLOCK_SIZE do ******0 block = block .. string.char(0) end ******0 local encrypted_block = M.encrypt_block(block, prepared_password, cipher_type) ******0 table.insert(result, encrypted_block) end ******0 return table.concat(result) end 8 function M.decrypt_content(content, password, cipher_type) ******0 local ops = get_bit_ops() ******0 local prepared_password = M.prepare_password(password) ******0 if #content < 5 then ******0 error("Invalid encrypted file format") end ******0 local length_bytes = content:sub(2, 5) local original_length = ******0 ops.lshift(string.byte(length_bytes, 1), 24) + ******0 ops.lshift(string.byte(length_bytes, 2), 16) + ******0 ops.lshift(string.byte(length_bytes, 3), 8) + ******0 string.byte(length_bytes, 4) ******0 content = content:sub(6) ******0 local result = {} ******0 for i = 1, #content, M.CIPHER_BLOCK_SIZE do ******0 local block = content:sub(i, i + M.CIPHER_BLOCK_SIZE - 1) ******0 while #block < M.CIPHER_BLOCK_SIZE do ******0 block = block .. string.char(0) end ******0 local decrypted_block = M.decrypt_block(block, prepared_password, cipher_type) ******0 table.insert(result, decrypted_block) end ******0 local decrypted = table.concat(result) ******0 return decrypted:sub(1, original_length) end -- Password obfuscation helpers (for file storage) 8 function M.obfuscate_password(password) 366 if not password then return nil end 366 local obfuscated = {} 11987 for i = 1, #password do 11621 local byte = string.byte(password, i) 11621 table.insert(obfuscated, string.char((byte + 42) % 256)) end 366 return table.concat(obfuscated) end 8 function M.deobfuscate_password(obfuscated) 363 if not obfuscated or #obfuscated == 0 then return nil end 362 local password = {} 11957 for i = 1, #obfuscated do 11595 local byte = string.byte(obfuscated, i) 11595 table.insert(password, string.char((byte - 42) % 256)) end 362 return table.concat(password) end 8 return M ============================================================================== spec/helpers/test_utils.lua ============================================================================== -- Test utilities for bytelocker tests -- Provides state management and file I/O for integration testing 5 local core = require("bytelocker.core") 5 local M = {} -- Re-export everything from core 145 for k, v in pairs(core) do 140 M[k] = v end -- Test state 5 M.config = { 5 cipher = "shift", 5 _cipher_selected = false 5 } 5 M.stored_password = nil -- File paths (using test temp directory) 5 M.password_file = "/tmp/bytelocker_test_data/bytelocker_session.dat" 5 M.cipher_file = "/tmp/bytelocker_test_data/bytelocker_cipher.dat" -- Ensure temp directory exists 5 os.execute("mkdir -p /tmp/bytelocker_test_data") -- Set cipher for testing 5 function M.set_cipher(cipher) 159 M.config.cipher = cipher 159 M.config._cipher_selected = true end -- Reset test state 5 function M.reset() 185 M.config = { 185 cipher = "shift", 185 _cipher_selected = false 185 } 185 M.stored_password = nil 185 os.remove(M.password_file) 185 os.remove(M.cipher_file) end -- Password file I/O 5 function M.save_password(password) 367 if not password then return end 366 local obfuscated = core.obfuscate_password(password) 366 local file = io.open(M.password_file, 'wb') 366 if file then 366 file:write(obfuscated) 366 file:close() end end 5 function M.load_password() 364 local file = io.open(M.password_file, 'rb') 364 if not file then return nil end 363 local obfuscated = file:read('*all') 363 file:close() 363 return core.deobfuscate_password(obfuscated) end -- Cipher file I/O 5 function M.save_cipher(cipher) 11 if not cipher then return end 10 local file = io.open(M.cipher_file, 'w') 10 if file then 10 file:write(cipher) 10 file:close() end end 5 function M.load_cipher() 10 local file = io.open(M.cipher_file, 'r') 10 if not file then return nil end 9 local cipher = file:read('*all') 9 file:close() 9 if cipher and cipher ~= "" and core.CIPHERS[cipher] then 7 return cipher end 2 return nil end -- Wrappers that use the test config cipher 5 function M.encrypt_text_only(content, password) 131 return core.encrypt_text_only(content, password, M.config.cipher) end 5 function M.decrypt_text_only(content, password) 125 return core.decrypt_text_only(content, password, M.config.cipher) end 5 function M.encrypt_for_file(content, password) 349 return core.encrypt_for_file(content, password, M.config.cipher) end 5 function M.decrypt_from_file(content, password) 347 return core.decrypt_from_file(content, password, M.config.cipher) end 5 return M ============================================================================== Summary ============================================================================== File Hits Missed Coverage ------------------------------------------------ lua/bytelocker/core.lua 256 42 85.91% spec/helpers/test_utils.lua 59 0 100.00% ------------------------------------------------ Total 315 42 88.24%