Module: Freshjots
- Defined in:
- lib/freshjots.rb,
lib/freshjots/crypto.rb,
lib/freshjots/version.rb
Overview
Client-side encryption for Fresh Jots notes — format "fj1".
Encrypt locally with your own passphrase; the server stores only the ciphertext and can never read it. Wire format:
"fj1:" + base64( salt[16] | iv[16] | ciphertext | mac[32] )
A single PBKDF2-HMAC-SHA256 pass (210_000 iterations) derives 64 bytes from the passphrase and salt: the first 32 are the AES-256-CBC key, the last 32 the HMAC-SHA256 key. The note is AES-256-CBC encrypted, then authenticated encrypt-then-MAC over iv|ciphertext; decryption verifies the MAC before decrypting. The output is a single line (base64 carries no newlines), so it survives the server's newline append separator. The format is identical across the Fresh Jots JS, Python, Ruby, and shell clients: a note encrypted by one decrypts with the others. (CBC+HMAC, not GCM, because it is the one authenticated construction every client — including the bash CLI, whose openssl refuses AEAD — can implement identically.) Uses only Ruby's stdlib openssl (no gem deps).
Defined Under Namespace
Classes: ApiError, Client, EncryptionError
Constant Summary collapse
- FJ_PREFIX =
"fj1:"- FJ_ITERATIONS =
210_000- FJ_SALT_LEN =
16- FJ_IV_LEN =
16- FJ_MAC_LEN =
32- VERSION =
"1.1.0"
Class Method Summary collapse
-
.decrypt(token, passphrase) ⇒ Object
Decrypt an "fj1:" token back to its plaintext.
-
.encrypt(plaintext, passphrase) ⇒ Object
Encrypt a string with a passphrase; returns an "fj1:" token.
-
.encrypted?(text) ⇒ Boolean
True if the text carries the Fresh Jots ciphertext prefix ("fj1:").
Class Method Details
.decrypt(token, passphrase) ⇒ Object
Decrypt an "fj1:" token back to its plaintext. Raises EncryptionError on a malformed token, a wrong passphrase, or tampering.
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/freshjots/crypto.rb', line 58 def self.decrypt(token, passphrase) raise ArgumentError, "decrypt requires a passphrase" if passphrase.nil? || passphrase.empty? raise EncryptionError, "not a Fresh Jots ciphertext (missing 'fj1:' prefix)" unless encrypted?(token) blob = begin Base64.strict_decode64(token[FJ_PREFIX.length..]) rescue ArgumentError raise EncryptionError, "ciphertext is not valid base64" end if blob.bytesize < FJ_SALT_LEN + FJ_IV_LEN + FJ_MAC_LEN + 16 raise EncryptionError, "ciphertext is truncated or corrupted" end salt = blob.byteslice(0, FJ_SALT_LEN) iv = blob.byteslice(FJ_SALT_LEN, FJ_IV_LEN) mac = blob.byteslice(blob.bytesize - FJ_MAC_LEN, FJ_MAC_LEN) ct_len = blob.bytesize - FJ_SALT_LEN - FJ_IV_LEN - FJ_MAC_LEN ct = blob.byteslice(FJ_SALT_LEN + FJ_IV_LEN, ct_len) enc_key, mac_key = fj_derive_keys(passphrase, salt) expected = OpenSSL::HMAC.digest("SHA256", mac_key, iv + ct) unless mac.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(mac, expected) raise EncryptionError, "decryption failed — wrong passphrase or corrupted ciphertext" end cipher = OpenSSL::Cipher.new("aes-256-cbc") cipher.decrypt cipher.key = enc_key cipher.iv = iv begin (cipher.update(ct) + cipher.final).force_encoding("UTF-8") rescue OpenSSL::Cipher::CipherError raise EncryptionError, "decryption failed — wrong passphrase or corrupted ciphertext" end end |
.encrypt(plaintext, passphrase) ⇒ Object
Encrypt a string with a passphrase; returns an "fj1:" token.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/freshjots/crypto.rb', line 41 def self.encrypt(plaintext, passphrase) raise ArgumentError, "encrypt requires a passphrase" if passphrase.nil? || passphrase.empty? salt = SecureRandom.random_bytes(FJ_SALT_LEN) iv = SecureRandom.random_bytes(FJ_IV_LEN) enc_key, mac_key = fj_derive_keys(passphrase, salt) cipher = OpenSSL::Cipher.new("aes-256-cbc") cipher.encrypt cipher.key = enc_key cipher.iv = iv ciphertext = cipher.update(plaintext.to_s) + cipher.final mac = OpenSSL::HMAC.digest("SHA256", mac_key, iv + ciphertext) FJ_PREFIX + Base64.strict_encode64(salt + iv + ciphertext + mac) end |
.encrypted?(text) ⇒ Boolean
True if the text carries the Fresh Jots ciphertext prefix ("fj1:"). A declaration of shape, not a guarantee it decrypts.
36 37 38 |
# File 'lib/freshjots/crypto.rb', line 36 def self.encrypted?(text) text.is_a?(String) && text.start_with?(FJ_PREFIX) end |