freshjots 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54b9c5dc0f9e2eef0978dc53579737fe6cf52fefee6b4105c9a8903761c7817b
4
- data.tar.gz: 2c40157c50f2ad882ede4486762b90f0cb35c715922e66c4533715100aafdddc
3
+ metadata.gz: 5a9a2097c87005bc6e9b43c35881949e54bce2871962789f463ed347b4e2e415
4
+ data.tar.gz: 18f981d7b062f4e683b0c4bb4331f6be9442e94cad3f4067dea8847ed9d3893e
5
5
  SHA512:
6
- metadata.gz: bf52b3c3da728a65071d7bcd6cd5c4cac197c17f293747ff870d7299365be80b1007c0787417e0fc2430decd389956ca518a2c4950f53c8b78364dc2f772fad7
7
- data.tar.gz: 442c1eccc0d157c7c0cb48b304ddc75ac6c23099f1723ad1623c3dbcf658887ff9f7e9a82412e47e4a90f2f47914d9104a25ce4677e4dfdf2168267943909bb1
6
+ metadata.gz: 00b9d55134d2826c0aa1515277907e31bcd1cb9b0df62918e194ca4b7f7e2542008fb3236b5503cb815ebde47702e4bac1e094a6ba3d2a5ac025ebb5ebf89171
7
+ data.tar.gz: f5f7dbcc724409cdb3b969972e0c5ff455abc715f3fc0da6650eaf1b6c654693ebd3a69c93c8f2f67aff021b529c41b37f90b506f3ead3d1e059737bfc4c4b85
data/README.md CHANGED
@@ -50,7 +50,35 @@ client.delete("old-note")
50
50
  client.folders.each { |f| puts "#{f[:id]}\t#{f[:name]}" }
51
51
  ```
52
52
 
53
- Client methods: `notes(sort:, folder_id:, limit:, offset:)`, `note(filename)`, `note_by_id(id)`, `create(title:, body:)`, `append(filename, text)`, `delete(id_or_filename)`, `move(id_or_filename, folder:)`, and `folders`. `note`/`note_by_id`/`create` return the note hash directly (no `{ note: … }` wrapper); `notes` and `folders` return arrays. For `notes`, `sort` is `created|updated|appended` and `folder_id` may be a folder id or `"none"` (un-foldered only).
53
+ Client methods: `notes(sort:, folder_id:, limit:, offset:)`, `note(filename)`, `note_by_id(id)`, `create(title:, body:, client_encrypted:)`, `append(filename, text, client_encrypted:)`, `delete(id_or_filename)`, `move(id_or_filename, folder:)`, and `folders`. Client-side crypto: `Freshjots.encrypt(text, passphrase)` / `Freshjots.decrypt(token, passphrase)` (see [Encryption](#encryption)). `note`/`note_by_id`/`create` return the note hash directly (no `{ note: … }` wrapper); `notes` and `folders` return arrays. For `notes`, `sort` is `created|updated|appended` and `folder_id` may be a folder id or `"none"` (un-foldered only).
54
+
55
+ ## Encryption
56
+
57
+ Keep notes the server can't read: encrypt locally with your own passphrase,
58
+ store the ciphertext, decrypt locally on read. Built in on Ruby's stdlib
59
+ `openssl` (no gem dependencies), and interoperable with the JS and Python
60
+ clients.
61
+
62
+ ```ruby
63
+ require "freshjots"
64
+
65
+ client = Freshjots::Client.new
66
+ pw = ENV.fetch("FRESHJOTS_PASSPHRASE")
67
+
68
+ # Store an encrypted note: encrypt the body, flag it client_encrypted.
69
+ client.create(title: "Recovery codes", body: Freshjots.encrypt("1234-5678", pw), client_encrypted: true)
70
+
71
+ # Read it back and decrypt locally.
72
+ puts Freshjots.decrypt(client.note("recovery-codes")[:plain_body], pw)
73
+ ```
74
+
75
+ The format is `fj1` (AES-256-CBC + HMAC-SHA256, PBKDF2-HMAC-SHA256), interoperable
76
+ with the JS, Python, MCP, and shell (`brew`) clients. You hold the only key —
77
+ Fresh Jots never receives it and **cannot recover the note if you lose it**, so
78
+ back the passphrase up somewhere safe. Encryption is per-note and personal-only
79
+ (not team notes); the title and metadata stay in the clear, so keep secrets out
80
+ of the title. `Freshjots.decrypt` raises `Freshjots::EncryptionError` on a wrong
81
+ passphrase. See <https://freshjots.com/encrypted-notes>.
54
82
 
55
83
  ## Errors
56
84
 
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "securerandom"
6
+
7
+ # Client-side encryption for Fresh Jots notes — format "fj1".
8
+ #
9
+ # Encrypt locally with your own passphrase; the server stores only the
10
+ # ciphertext and can never read it. Wire format:
11
+ #
12
+ # "fj1:" + base64( salt[16] | iv[16] | ciphertext | mac[32] )
13
+ #
14
+ # A single PBKDF2-HMAC-SHA256 pass (210_000 iterations) derives 64 bytes from
15
+ # the passphrase and salt: the first 32 are the AES-256-CBC key, the last 32 the
16
+ # HMAC-SHA256 key. The note is AES-256-CBC encrypted, then authenticated
17
+ # encrypt-then-MAC over iv|ciphertext; decryption verifies the MAC before
18
+ # decrypting. The output is a single line (base64 carries no newlines), so it
19
+ # survives the server's newline append separator. The format is identical
20
+ # across the Fresh Jots JS, Python, Ruby, and shell clients: a note encrypted
21
+ # by one decrypts with the others. (CBC+HMAC, not GCM, because it is the one
22
+ # authenticated construction every client — including the bash CLI, whose
23
+ # openssl refuses AEAD — can implement identically.) Uses only Ruby's stdlib
24
+ # openssl (no gem deps).
25
+ module Freshjots
26
+ class EncryptionError < StandardError; end
27
+
28
+ FJ_PREFIX = "fj1:"
29
+ FJ_ITERATIONS = 210_000
30
+ FJ_SALT_LEN = 16
31
+ FJ_IV_LEN = 16
32
+ FJ_MAC_LEN = 32
33
+
34
+ # True if the text carries the Fresh Jots ciphertext prefix ("fj1:"). A
35
+ # declaration of shape, not a guarantee it decrypts.
36
+ def self.encrypted?(text)
37
+ text.is_a?(String) && text.start_with?(FJ_PREFIX)
38
+ end
39
+
40
+ # Encrypt a string with a passphrase; returns an "fj1:" token.
41
+ def self.encrypt(plaintext, passphrase)
42
+ raise ArgumentError, "encrypt requires a passphrase" if passphrase.nil? || passphrase.empty?
43
+
44
+ salt = SecureRandom.random_bytes(FJ_SALT_LEN)
45
+ iv = SecureRandom.random_bytes(FJ_IV_LEN)
46
+ enc_key, mac_key = fj_derive_keys(passphrase, salt)
47
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
48
+ cipher.encrypt
49
+ cipher.key = enc_key
50
+ cipher.iv = iv
51
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
52
+ mac = OpenSSL::HMAC.digest("SHA256", mac_key, iv + ciphertext)
53
+ FJ_PREFIX + Base64.strict_encode64(salt + iv + ciphertext + mac)
54
+ end
55
+
56
+ # Decrypt an "fj1:" token back to its plaintext. Raises EncryptionError on a
57
+ # malformed token, a wrong passphrase, or tampering.
58
+ def self.decrypt(token, passphrase)
59
+ raise ArgumentError, "decrypt requires a passphrase" if passphrase.nil? || passphrase.empty?
60
+ raise EncryptionError, "not a Fresh Jots ciphertext (missing 'fj1:' prefix)" unless encrypted?(token)
61
+
62
+ blob =
63
+ begin
64
+ Base64.strict_decode64(token[FJ_PREFIX.length..])
65
+ rescue ArgumentError
66
+ raise EncryptionError, "ciphertext is not valid base64"
67
+ end
68
+ if blob.bytesize < FJ_SALT_LEN + FJ_IV_LEN + FJ_MAC_LEN + 16
69
+ raise EncryptionError, "ciphertext is truncated or corrupted"
70
+ end
71
+
72
+ salt = blob.byteslice(0, FJ_SALT_LEN)
73
+ iv = blob.byteslice(FJ_SALT_LEN, FJ_IV_LEN)
74
+ mac = blob.byteslice(blob.bytesize - FJ_MAC_LEN, FJ_MAC_LEN)
75
+ ct_len = blob.bytesize - FJ_SALT_LEN - FJ_IV_LEN - FJ_MAC_LEN
76
+ ct = blob.byteslice(FJ_SALT_LEN + FJ_IV_LEN, ct_len)
77
+
78
+ enc_key, mac_key = fj_derive_keys(passphrase, salt)
79
+ expected = OpenSSL::HMAC.digest("SHA256", mac_key, iv + ct)
80
+ unless mac.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(mac, expected)
81
+ raise EncryptionError, "decryption failed — wrong passphrase or corrupted ciphertext"
82
+ end
83
+
84
+ cipher = OpenSSL::Cipher.new("aes-256-cbc")
85
+ cipher.decrypt
86
+ cipher.key = enc_key
87
+ cipher.iv = iv
88
+ begin
89
+ (cipher.update(ct) + cipher.final).force_encoding("UTF-8")
90
+ rescue OpenSSL::Cipher::CipherError
91
+ raise EncryptionError, "decryption failed — wrong passphrase or corrupted ciphertext"
92
+ end
93
+ end
94
+
95
+ # PBKDF2-HMAC-SHA256 -> 64 bytes split into (AES-256-CBC key, HMAC-SHA256 key).
96
+ def self.fj_derive_keys(passphrase, salt)
97
+ dk = OpenSSL::KDF.pbkdf2_hmac(
98
+ passphrase.to_s,
99
+ salt: salt, iterations: FJ_ITERATIONS, length: 64, hash: "sha256"
100
+ )
101
+ [dk.byteslice(0, 32), dk.byteslice(32, 32)]
102
+ end
103
+ private_class_method :fj_derive_keys
104
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Freshjots
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/freshjots.rb CHANGED
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "uri"
6
6
 
7
7
  require_relative "freshjots/version"
8
+ require_relative "freshjots/crypto"
8
9
 
9
10
  # Tiny client for the Fresh Jots API (https://freshjots.com/docs).
10
11
  #
@@ -71,18 +72,27 @@ module Freshjots
71
72
  # (the by-filename endpoint creates it with that exact name on first
72
73
  # call). Returns the created note hash (top level); read [:filename]
73
74
  # for the server-derived stream name.
74
- def create(title:, body: "")
75
+ # Pass client_encrypted: true to mark the note as a client-encrypted note
76
+ # — body is opaque ciphertext you produced with Freshjots.encrypt; the
77
+ # server stores it verbatim and never reads it. Personal accounts only.
78
+ def create(title:, body: "", client_encrypted: false)
75
79
  if title.nil? || title.to_s.empty?
76
80
  raise ArgumentError,
77
81
  "create requires a title — the API derives the filename from it. " \
78
82
  "For a note addressable by an exact filename, use append."
79
83
  end
80
- payload = { note: { title: title, plain_body: body, format: "plain" } }
81
- request(:post, "/notes", payload)
84
+ note = { title: title, plain_body: body, format: "plain" }
85
+ note[:client_encrypted] = true if client_encrypted
86
+ request(:post, "/notes", { note: note })
82
87
  end
83
88
 
84
- def append(filename, text)
85
- request(:post, "/notes/by-filename/#{escape(filename)}/append", { text: text })
89
+ # On first-touch creation, pass client_encrypted: true to open the stream
90
+ # as a client-encrypted note (send one ciphertext line per append).
91
+ # Ignored once the note exists.
92
+ def append(filename, text, client_encrypted: false)
93
+ body = { text: text }
94
+ body[:client_encrypted] = true if client_encrypted
95
+ request(:post, "/notes/by-filename/#{escape(filename)}/append", body)
86
96
  true
87
97
  end
88
98
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshjots
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Goran Arsov
@@ -20,6 +20,7 @@ files:
20
20
  - LICENSE
21
21
  - README.md
22
22
  - lib/freshjots.rb
23
+ - lib/freshjots/crypto.rb
23
24
  - lib/freshjots/version.rb
24
25
  homepage: https://github.com/Goran-Arsov/freshjots-ruby
25
26
  licenses: