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 +4 -4
- data/README.md +29 -1
- data/lib/freshjots/crypto.rb +104 -0
- data/lib/freshjots/version.rb +1 -1
- data/lib/freshjots.rb +15 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a9a2097c87005bc6e9b43c35881949e54bce2871962789f463ed347b4e2e415
|
|
4
|
+
data.tar.gz: 18f981d7b062f4e683b0c4bb4331f6be9442e94cad3f4067dea8847ed9d3893e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/freshjots/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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:
|